REST architecture – Best practises

Wszyscy (albo prawie wszyscy) robimy usługi REST’owe dla naszych mikroserwisów.  Jest wiele elementów które powinna spełnia architektura REST. W tym wpisie przedstawię wam ogólne zasady dotyczące prawidłowego projektowania API.

1. Rzeczowniki

Wszystkie endpointy powinny reprezentować zasoby jako rzeczowniki w liczbie mnogiej. Dzięki temu zachowujemy spójności, oraz korzystając z odpowiednich metod HTTP możemy wykorzystać pojedynczy adres URI. Agregacje danych powinny być wywoływane od najbardziej ogólnego elemety do najbardziej szczegółowego (jeśli chcemy pobrać wszystkie Todo usera o ID 1 to powinnyśmy wykorzystać adres /users/1/todos).  Jeśli wystawiamy operacje CRUD na obiekcie domenowy, załóżmy Todo to nasze endpointy powinny wyglądać tak:

  • /api/todos – wszystkie todos
  • /api/todos/10 – todo o ID 10
  • /api/todos/titles – wszystkie tytuły wszystkich todos
  • /api/users/1/todos – todos usera o ID 1
  • /api/users/1/todos/10 – todo o ID 10 od usera o ID 1

2. Metody HTTP

Do wykonywania operacji powinniśmy używać odpowiednich metod HTTP zgodnie z ich przeznaczeniem. Nie należy tworzyć metod w stylu /api/addNewTodo czy /api/removeTodoById?id=10, ponieważ zaburzamy konwencje i użytkownicy naszego API mogą się czuć zakłopotani. Różnicę pomiędzy POST, PUT i PATCH znajdziecie w moim innym artykule. Tabela poniżej pokazuje zastosowanie najpopularniejszych metod HTTP (nie są to wszystkie metody):

MetodaURIWynik
GET/api/todosZwraca listę todos
POST/api/todosTworzy nowy todo
PUT/api/todos/10Aktualizuje cały todo lub tworzy todo o ID 10
PATCH/api/todos/10Częściowa aktualizacja
DELETE/api/todos/10Usuwa todo o ID 10

3. Kody statusów HTTP

Kolejnym elementem na który należy zwrócić uwagę to kody statusów HTTP. Dzięki poprawnemu ich stosowaniu klient wie jaka jest odpowiedź z serwera. Poniżej znajduje się tabela z kodami i wyjaśnieniem.

KodZnaczeniePrzeznaczenie
2xxOperacje zakończone sukcesem
200OKZwracamy gdy operacja zakończyła się sukcesem
201CreatedZwracamy gdy został utworzony nowy zasób, na przykład w przypadku użycia metody POST
202AcceptedZwracamy gdy wykonywana jest akcja asynchronicznie.
204No ContentZwracamy w przypadku aktualizacji zasobu PUT, PATCH lub przy jego usunięciu DELETE
3xxPrzekierowania
301Moved PermanentlyZwracamy gdy dane URI zostało przeniesione na stałe pod inne URI. Przydatne przy wersjonowaniu
302FoundZwracany gdy zasób przeniesiony jest na inny adres URI, jednakże ten pod który zostało wysłane żądanie jest dalej obsługiwany
4xxBłędy klienta
401UnauthorizedZwracamy gdy wymagana jest uwierzytelnienie
403ForbiddenZwracamy gdy wymagana jest autoryzacja
404Not FoundZwracamy gdy nie znaleziono zasobu pod danym URI
405Method Not AllowedZwracamy gdy klient wyślę na przykład DELETE którego my nie obsługujemy
406Not AcceptableZwracamy gdy dane wysłane przez klienta są niepoprawne
415Unsupported Media TypeZwracamy gdy klient wyśle dane w formacie przez nas niewspieranym
5xxBłędy serwera
500Internal Server ErrorZwracany gdy wystąpi błąd na serwerze
503Service UnavailableZwracany gdy serwer nie może obsłużyć żądania

4. Content negotiation

Nie chciałem tego tłumaczyć jako negocjowanie treści bo brzmi to dziwnie. Jednakże chodzi tutaj o wysyłanie i odbieraniu danych w odpowiednim formacie. Projektując nasze API możemy ustalić iż przyjmuje dane tylko w formacie JSON. W przypadku gdy klient wyśle złe dane powinnyśmy wysłać kod 415 czyli Unsupported Media Type. Dbanie o odpowiednie nagłówki jest bardzo ważne bo dzięki temu jesteśmy odporni na błędne dane jednocześnie informując o tym użytkowników. Przykład w Spring Bootcie, w momencie gdy wyślemy dane z innym nagłówkiem niż nasz zdefiniowany w sekcji consumes poleci wyjątek z kodem 415.

@PostMapping(value = "/todos",
        consumes = APIVersion.TODO_V1,
        produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> addTodo(@RequestBody Todo todo){
    //Some logic responsible for async creating
    return ResponseEntity.accepted().location(URI.create("/todos/2")).build();
}

5. Wersjonowanie

W pewnym momencie działania naszej aplikacji na produkcji przychodzi zgłoszenie od biznesu, należy dodać jedno pole w modelu a w drugim usunąć. Niestety, nie możemy od tak zmienić ich w naszym kodzie, ponieważ klienci naszych usług będą dostawać kod 406 Not Acceptable który informuje o tym że klient wysyłał niepoprawne dane lub kod 415 czyli Unsupported Media Type  o złym typie danych. Aby zaradzić temu problemowi musimy zastosować wersjonowanie. Wersjonowanie API często implementuje się na jeden z trzech sposób:

  • dodanie informacji o wersji na stałe w URI/api/v2/todos
  • dodanie informacji o wersji jako parametr w URI/api/todos?ver=2
  • wykorzystanie nagłówków Accept w HTTPAccept: application/vnd.codecouple.pl.todo.v2+json

Każdy typ ma swoje wady i zalety, polecam zapoznać się z tym wpisem.

6. Cache

Cachowanie danych może znacznie przyśpieszyć działanie naszego API oraz zmniejszyć ilość operacji na serwerze. Proces buforowania można zrealizować na dwa sposoby:

  • korzystając z nagłówka Cache-Control
  • korzystając z ETagów

Nagłówek Cache-Control oznacza na ile czasu chcemy zatrzymać aktualny stan zasobu w cache bez potrzeby pobierania nowej wersji. Długość ta definiowana jest w wartości max-age. W poniższym przykładzie mamy dodany cache do odpowiedzi z wartością 100 sekund. Oznacza to że zasób w tym stanie będzie dostępny przez 100 sekund. Po tym czasie zasób zostanie zaktualizowany.

@GetMapping(value = "/todos",
        consumes = APIVersion.TODO_V1,
        produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<Todo>> showTodo(){
    Todo todoFirst = new Todo("First");
    Todo todoSecond = new Todo("Second");
    return ResponseEntity.ok().cacheControl(CacheControl.maxAge(100l, TimeUnit.SECONDS)).body(Arrays.asList(todoFirst, todoSecond));
}

Kolejnym sposobem na realizację cachu jest użycie Entity Tagów zwanych ETagi. W momencie zapytania o obiekt dostajemy ETag z unikalnym numerem obiektu. W kolejnym wywołaniu dodajemy nagłówek If-None-Match z tą wartości i jeśli stan obiektu się nie zmienił dostaniemy kod 304 z treścią Not Modified. Jeśli obiekt się zmienił dostaniemy status 200. Aby aktywować ETagi w SpringBootcie należy dodać ShallowEtagHeaderFilter:

@Bean
public Filter shallowEtagHeaderFilter() {
    return new ShallowEtagHeaderFilter();
}

7. Dokumentacja

Nasze API powinno być bardzo dobrze udokumentowane. Na rynku znajdują się kilka frameworków wspomagających proces tworzenia dokumentacji:

Każdy z nich ma inne podejście. Swagger niestety doprowadza do Annotation Hell przez co kod staje się bardzo ciężki do czytania. Jednakże ma bardzo fajne featury na przykład dla QA. Wygenerowana dokumentacja pozwala na klikanie po tych endpointach. Podobnie jak w Postmanie możemy wysyłać przykładowe JSON’y w już przygotowanym formacie. Spring Rest Docs niema fajne wersji webowej z UI do klikania jednakże ma bardzo fajne podejście nazywane Test-Driven Documentation. Aby napisać fragment dokumentacji należy napisać test do API, który jest naszą dokumentacją. Wybór technologii zależy do nas, najważniejsze aby ta dokumentacja była taka żeby każdy, mimo że to dokumentacja chciał ją czytać!

8. HATEOAS

Według modelu dojrzałości Richardsona poziom 3 jest najwyższym poziomem dojrzałości aplikacji REST’owej. Na tym poziomie znajduje się HATEOAS (ang. Hypertext As The Engine Of Application State). Dzięki temu dostarczamy linki dla klientów do poruszania się po naszymi API. Wyobraźmy sobie sytuację że chcemy sprawdzić jakieś konkretne Todo. Najpierw uderzamy do listy todos:

[
    {
        id: 1,
                name: "Jan"
    },
    {
        id: 2,
                name: "Nowak"
    },
    {
        id: 3,
                name: "Tomasz"
    }
]

Teraz aby dostać się do todo trzeciego kopiujemy id 3 i uderzamy pod adres /todos/3:

[
   {
        id: 3,
                name: "Tomasz"
    }
]

Korzystając z hiperłączy czyli HATEOAS od razu dostajemy linki do zasobów:

{
    name: "Jan",
            _links: {
    self: {
        href: "http://localhost:8081/todos/1"
    },
    todo: {
        href: "http://localhost:8081/todos/1"
    }
}

9. Inne

Oprócz punktów powyżej powinniśmy rozważyć także kilka pozostałych elementów:

  • stronicowanie – każde zapytanie powinno mieć możliwość stronicowania wyników
  • internacjonalizacja – w przypadku gdy projektujemy API które korzysta z wielu języków powinniśmy korzystać z nagłówka Accept-Language
  • ograniczenie liczby żądań – nie musi to być stosowane zawsze ale należy rozważyć ograniczenie liczby żądań na dane IP.
  • metryki – należy mierzyć wszystkie wartości jakie się da, ułatwia to debugowanie oraz analizę wąskich gardeł
  • rozproszone logowanie – w architekturze microserwisów powinniśmy korzystać z rozproszonego logowania korzystając na przykład z Sleuth’a i Zipkina.