Utrzymanie wielu wersji API w obcym środowisku cz. I

W pierwszej części ogólnie omówiłem z jakiego rodzaju problemami możemy mieć do czynienia gdy mamy narzuconą infrastrukturę. W tej części omówię jak radzić sobie z tymi ograniczeniami w zależności od tego jakie restrykcje zostały nam narzucone.

Na samym początku należy uzmysłowić sobie, że nie każda zmiana w aplikacji oznacza konieczność zmiany wersji API.

Co oznacza wersjonowanie API?

Jeżeli będziemy chcieli odpowiedzieć na to pytanie to musimy najpierw określić jakie elementy API powodują, że potrzebujemy je wersjonować. Jeżeli przyjrzymy się typowej metodzie lub funkcji w praktycznie dowolnym języku programowania to będziemy wstanie wyznaczyć takie trzy elementy. Pierwszym najbardziej oczywistym jest nazwa. Jeżeli zmieniamy nazwę metody to zmieniamy nasze API. W przypadku REST mówiąc o nazwie metody mam na myśli adres URL, jego fragment, pod którym leży sobie usługa. Drugim elementem są parametry. Zarówno wejściowe jak i wyjściowe. Możemy je dodawać, usuwać, zamieniać. W REST parametry mogą przyjmować różne postacie. Mogą być częścią adresu URL, mogą znajdować się w RequestBody, a jako odpowiedź będą to zarówno kody odpowiedzi jak i ResponseBody. Parametrami są też nagłówki HTTP. Trzecim elementem jest struktura parametrów. Szczególnie jeżeli wysyłamy i odbieramy je w postaci JSON albo XML. Zmiana w strukturze tych danych może, ale nie musi, pociągać za sobą konieczność zmiany wersji.
Tym co nie wpływa na wersję REST API jest jego zachowanie. Paradoksalnie jeżeli zachowamy sygnaturę metody to zmiana jej implementacji będzie co najwyżej zmianą z bardzo abstrakcyjnego biznesowego punktu widzenia.

Przykład

Jaś i Małgosia rozwijają swoją aplikację „Burn the witch”. Udostępnia ona REST API. Jedna z metod pozwala na pobranie listy czarownic do spalenia. Jako parametr w URL przyjmuje identyfikator regionu, a w RequestBody można doprecyzować dodatkowe reguły wyszukiwania. Lista reguł wyszukiwania jest zamkniętym słownikiem, czyli akceptowane będą tylko określone wartości. Odpowiedź zawiera listę czarownic wraz z ich adresami.

Listing 1. Przykładowe wywołanie serwisu

/api/witches/region/666
{
    'name' : '%Baba%',
    'age' : '>=100'
}

Listing 2. oraz jego odpowiedź

HTTP/1.1 200 OK

[
   {
      'name' : 'Baba Jaga',
      'age' : 101,
      'address' : 'Leśna Łączka 11'
   }
]

Wraz z rozwojem biznesu zaistniała konieczność zmiany bazy danych. Nowa implementacja jest szybsza i wydajniejsza, ale pomimo, że była dość dużą zmiana po stronie serwera to z punktu widzenia API nic się nie zmieniło. Kolejna zmiana polegała na otwarciu słownika. Od tego momentu można włożyć do RequestBody dowolne wartości jako klucze. Aplikacja będzie jednak ignorować nieznane klucze. Z punktu widzenia klienta jest to zmiana API pomimo, że po stronie aplikacji praktycznie prawie nic się nie zmieniło. Co prawda starsze wersje klienta nie muszą być aktualizowane by być kompatybilne z nowym API, ale bez aktualizacji nie będą wstanie korzystać z nowych możliwości. Co ważne aktualizacja klienta może odbywać się poprzez usunięcie z procesu pobrania listy kluczy słownika. Przykładowo:

Listing 3. Stara wersja klienta

var paramMap = getSearchConditions();
var validKeys = $client.getDictionaryKeys();
var paramMap = removeInvalidKeys(paramMap, validKeys);
$client.getWitches(paramMap);

zamieni się na:

Listing 4. Nowa wersja klienta

var paramMap = getSearchConditions();
$client.getWitches(paramMap);

Jednak nie nastąpią zmiany w samym obiekcie $client. Oczywiście o ile przyjmiemy, że nie posiada on własnych mechanizmów walidacji.

Kolejną zmianą była zmiana domeny i mapowania metod. Z punktu widzenia kodu aplikacji nic się nie zmieniło. Z punktu widzenia klienta takiego API jest to bardzo duża zmiana, która pociąga za sobą konieczność zmiany w bardzo wielu miejscach. Taka zmiana wymaga już wprowadzenia nowej wersji ponieważ zmieniło się mapowanie metod. Uwaga zmiana domeny nie oznacza zmiany w mapowaniu samych metod.

To by było na tyle jeśli chodzi o pojęcie zmiany API. Mam świadomość, że może być to niejasne, ale jakoś nie mam pomysłu jak to ładnie ubrać w słowa. Przejdźmy do problemów jakie wynikają z konieczności wersjonowania API.

Problem najprostszy – własny tool klienta

Kiedyś dawno temu jak nie było Dockera… ok nie tak dawno temu w sumie duże instytucje pisały własne narzędzia do zarządzania aplikacjami i ich położeniem na infrastrukturze. Mówiąc prościej w każdej organizacji był zespół, który dostarczał narzędzie pozwalające na umieszczanie konkretnych apek na konkretnych maszynach. Czasami takie narzędzia kupowano, czasami pisano samemu. Wspólnym mianownikiem dla tego typu systemów jest brak publicznego API lub też jego mała popularność.
Wymaga to oczywiście dostosowania naszych skryptów do tego API. W efekcie proces budowania i uruchamiania aplikacji wymaga dużo nauki na „dzień dobry”.

Jest to najprostszy z możliwych problemów. Ponieważ tak naprawdę jest on tylko pozornym ograniczeniem wynikającym z braku wiedzy. W praktyce problem własnego API klienta jest punktem wyjścia w większości przypadków. Jeżeli go rozwiążemy to mamy dużo prościej.

Problemy rzeczywiste

Te zazwyczaj związane są z ilością oraz jakością dostępnych zasobów. Ich rozwiązanie będzie wymagać od nas pracy w dwóch obszarach. Pierwszy to konfiguracja infrastruktury. Drugi to konfiguracja aplikacji. Poniżej postaram się opisać najpopularniejsze problemy, a następnie podać rozwiązania pozwalające na redukowanie ich do problemu z API systemu zarządzania infrastrukturą.

Bierzcie i jedzcie

W tym przypadku klient udostępnia nam nieograniczone zasoby. Praca w takim środowisku rozbija się tylko o przygotowanie odpowiednich skryptów by móc zautomatyzować proces uruchamiania kolejnych instancji aplikacji.

Masz kilka serwerów

W tej klasie problemów mamy nałożony limit na ilość maszyn, które możemy wykorzystać. Może być jedna, może być kilka. Warunkiem koniecznym do rozwiązania tego rodzaju problemu jest odpowiednie balansowanie poszczególnych wersji aplikacji. Inaczej będziemy konfigurować nasze środowisko gdy musimy obsłużyć duży, ale stosunkowo lekki ruch. Inaczej jeżeli ilość żądań jest niewielka, ale są „masywne” (np. produkcja raportów w postaci plików pdf czy excela). Ciężko jest mi tu wskazać jedyną słuszną drogę.

Masz jeden serwer

Jest to specyficzna wersja poprzedniego problemu. W tym przypadku mając jedną maszynę musimy rozważyć możliwość uruchomienia wielu instancji aplikacji. Można zrobić to na dwa sposoby. Pierwszym jest uruchomienie wielu instancji serwera aplikacyjnego na różnych portach. W ten sposób działa domyślna konfiguracja TeamCity. Na jednej maszynie mamy uruchomiony serwer master oraz domyślnie trzy workery, każdy na oddzielnym porcie.
Tego typu rozwiązanie wymaga jednak dużej elastyczności samej maszyny. Niczym niezwykłym jest tu serwerek w rodzaju 16 rdzeni, 128GB ramu. Gorzej jak mamy ograniczenia w mocy maszyny…

Masz jeden serwer 2

Sytuacja gdy poza ograniczeniem ilości maszyn do jednej jesteśmy też ograniczeni w ilości instancji serwerów aplikacyjnych do jednej stawia nas w bardzo trudnym położeniu. W takim przypadku musimy oprzeć się na uruchamianiu poszczególnych aplikacji w różnych kontekstach w ramach jednego serwera. Jako, że standardowe mechanizmy GC wysiadają w okolicach 10-12GB ramu, działają z mniejszą efektywnością, zatem pojawia się też ograniczenie na zasoby. W efekcie musimy zwrócić szczególną uwagę na możliwość wycofywania starych wersji aplikacji.

Masz jeden kontekst

A teraz drogie dzieci najpopularniejsza sytuacja… zazwyczaj ograniczenia w infrastrukturze ze strony klienta objawiają się pełną wirtualizacją środowiska. Wrzucamy naszą apkię na „magiczne API” i wiemy jaki jest adres. Nie mamy możliwości wrzucenia jej wielu instancji. Zapomnijmy o uruchomieniu wielu kontekstów. Jest jedyny słuszny kontekst, który został nam narzucony. W takim wypadku musimy obsłużyć wersjonowanie w ramach pojedynczej aplikacji. Cały routing związany z rozróżnianiem wersji musi zostać zrealizowany w ramach.
Taka sytuacja jest, paradoksalnie, prostsza do obsłużenia od poprzedniej pod warunkiem, że korzystamy z jakiegoś DI oraz mamy przemyślaną architekturę naszej aplikacji. W takim wypadku routing może zostać ograniczony do minimum.

Jak wersjonować API

W ogólności istnieją dwa „koszerne” sposoby na rozróżnianie wersji API.

Zasada ogólna

Niezależnie którą wersję wybierzemy musimy trzymać się jednej zasady. Przed swoją aplikacją należy umieścić router w postaci czy to serwera proxy (z loadbalancerem) czy to mechanizmu w aplikacji, który pozwoli na konfigurowalne decydowanie, która wersja API zostanie wywołana.

Różne wersje – różne URL-e

Jest to najprostszy mechanizm. Czasami jest on też najbardziej naturalny sposób na dostęp do API. Przykładowo jeżeli nasz router znajduje się po stronie klienta np. jest to system ERP albo BPM, który umożliwia korzystanie z wielu wersji danego API i potrafi parametryzować wywołania, oraz nasza aplikacja działa na kilku różnych serwerach lub w kilku kontekstach to wykorzystanie URL-i do wersjonowania jest bardzo fajne.
Oczywiście oznacza to, że zmiana wersji API może powodować pewne problemy po stronie infrastruktury. Nowy URL może wymagać dodatkowych reguł na firewallach.
Bardzo ważną rzeczą w przypadku wyboru tego rozwiązania jest odpowiednie zbudowanie adresu. Numer wersji nie powinien znajdować się w samych mapowaniach usług.

Listing 5. Złe mapowania

http://mojaaplikacja.com:80/api/usługa/v1/metoda
http://mojaaplikacja.com:80/api/usługa/metoda?v1

Listing 6. Dobre mapowania

http://mojaaplikacja.com:80/api/v1/usługa/metoda
http://v1.mojaaplikacja.com:80/api/usługa/metoda

Złe mapowania powodują, że obsługa mapowania jest trudna. Trzeba używać dodatkowych mechanizmów do ekstrahowania wersji. Trudna jest też konfiguracja wersji domyślnej.

Nagłówek

Protokół HTTP pozwala na zdefiniowanie własnych nagłówków oraz na zdefiniowanie dodatkowych wartości do istniejących, standardowych nagłówków.
Jeżeli wybieramy własny nagłówek to należy pamiętać o pewnej konwencji, która mówi, że nazwa własnego nagłówka powinna zaczynać się od X-.
Inną metodą jest użycie nagłówka Accept z niestandardową (czytaj nie wspieraną przez np. przeglądarki czy serwery w domyślnej konfiguracji) wartością. Tu znowuż konwencja jest taka, że wartość powinna wyglądać mniej więcej w taki sposób

Listing 7. Przykładowa wartość nagłówka Accept

Accept: application/vnd.company.appname.witch-v1+json

W przypadku użycia nagłówka Accept należy zadbać o odpowiedni nagłówek Content-Type

Listing 8. Przykładowa wartość nagłówka Content-Type

Content-Type: application/vnd.company.appname-v1+json

Pierwszy z nagłówków określa czego oczekuje klient. ważne jest doprecyzowanie, że oczekujemy reprezentacji pewnego obiektu witch w formacie json. Odpowiedź z aplikacji informuje nas w jakim formacie są dane.

Podsumowanie

Wersjonowanie aplikacji to sprawa dość upierdliwa. Z punktu widzenia programisty rozwiązanie oparte o użycie nagłówka Accept jest najlepsze i najbliższe ideałowi. Jednocześnie ma też największy narzut.
Z drugiej strony użycie różnych URL-i jest bardzo proste, ale może odbić się w przyszłości ograniczeniem elastyczności aplikacji. Wymusi na nas trzymanie się złej konwencji, a próba zmiany będzie oznaczać konieczność przepisywania dużej ilości kodu zarówno po naszej stronie jak i stronie klientów.

W trzeciej części przyjrzymy się temu jak rozwiązać problem wersjonowania API w bazie danych gdy nasze zasoby są ograniczone.