kompatybilność wsteczna

Kompatybilność wsteczna

Kompatybilność wsteczna to temat, który jest bardzo często świadomie lub nieświadomie pomijany. Wiele osób myśli, że kompatybilnością wsteczną musimy się przejmować tylko i wyłącznie w momencie kiedy chcemy mieć niezależne wdrożenia mikroserwisów lub wystawiamy publiczne API z którego korzystają nasi klienci. Otóż nic bardziej mylnego!

W tym artykule chciałbym opowiedzieć Wam o tym czym jest kompatybilność wsteczna, o pułapkach z nią związanych oraz o rzeczach na które należy zwracać szczególną uwagę w momencie kiedy chcemy ją osiągnąć.

Czym jest kompatybilność wsteczna?

Kompatybilność wsteczna to właściwość systemu, która pozwala na użycie jakiejś usługi, która jest w nowej wersji za pomocą starego API. W skrócie można powiedzieć, że nowa wersja naszego systemu powinna wspierać stare zapytania (formaty danych oraz interfejsy) i umożliwić poprawne korzystanie z aplikacji bez konieczności ich zmiany.

Czasami systemy wspierają kilka wersji wstecz, a innym razem tylko jedną – poprzednią. Należy również pamiętać o tym, że nie każdy stosuje system wersjonowania. Nie raz dodanie jakiejś zmiany wiąże się tylko z aktualizacją obecnego interfejsu. Wszystko zależy od intencji autora i driverów architektonicznych.

Przykład:

// Funkcja w starej wersji
function add(a: number, b: number): number {
    return a + b;
}

// Wykorzystanie
add(1, 2);

// Nowa wersja funkcji, umożliwiająca dodanie trzech cyfr
function add(a: number, b: number, c: number = 0): number {
    return a + b + c;
}

// Wykorzystanie

// Nowy sposób wywołania funkcji umożliwiający dodanie trzech liczb
add(1, 2, 3)

// Stary sposób nadal działa, więc klient naszej metody nie musi wprowadzać żadnych zmian, aby z niej poprawnie korzystać
add(1, 2)

Jeśli wprowadzamy do systemu zmiany, które uniemożliwiają jego poprawne użycie za pomocą starych interfejsów to robimy tzw. breaking changes. Z tym terminem dosyć często można się spotkać w różnego rodzaju bibliotekach czy publicznych API.

Przykład:

// Funkcja w starej wersji
function add(a: number, b: number): number {
    return a + b;
}

// Wykorzystanie
add(1, 2);

// Nowa wersja funkcji, umożliwiająca dodanie trzech cyfr
function add(a: number, b: number, c: number): number {
    return a + b + c;
}

// Wykorzystanie

// Nowy sposób wywołania funkcji umożliwiający dodanie trzech liczb
add(1, 2, 3)

// Stary sposób nie działa, ponieważ nowa wersja funkcji wymusza podanie trzech liczb
add(1, 2) // Expected 3 arguments, but got 2

Należy również pamiętać, że kompatybilność wsteczna jest bardzo szerokim pojęciem i nie ogranicza się tylko do software’u. Natomiast w tym artykule skupimy się tylko na nim.

Skąd wiadomo, czy wprowadzona przez kogoś zmiana jest kompatybilna wstecz?

Wersjonowanie

Z reguły w publicznych API oraz bibliotekach jest używany specjalny system wersjonowania, który mówi nam o tym, czy wprowadzone zmiany są kompatybilne wstecz, czy też nie. Istnieje wiele sposobów wersjonowania, natomiast najpopularniejszym jest wersjonowanie semantyczne.

W skrócie polega ono na tym, że nasza wersja jest opisana w taki sposób: MAJOR.MINOR.PATCH przykładowo 1.0.0. W momencie jak wprowadzamy zmianę kompatybilną wstecz np. dodajemy nową funkcjonalność lub naprawiamy jakiś błąd to podbijamy pozycje MINOR lub PATCH na przykład: 1.0.1. Natomiast jeśli wprowadzamy zmianę niekompatybilną wstecz np. całkowicie zmieniając nasze API to podbijamy pozycję MAJOR 2.0.0. Dzięki czemu nasi klienci wiedzą czy mogą bezpiecznie zaktualizować bibliotekę lub przerzucić się na nową wersję API, czy też nie.

Jednak tutaj należy być bardzo ostrożnym. Czasami autorzy bibliotek i publicznych API nieumiejętnie lub przez pomyłkę zmieniają nieodpowiednią pozycję w wersji (MINOR lub PATCH) uznając jakieś zmiany za kompatybilne wstecz w momencie w którym takowe nie są. Często dowiadujemy się o tym dopiero podczas kompilacji lub co gorsza na produkcji. W najlepszym przypadku dostaniemy po prostu błąd, natomiast zdarza się, że API lub funkcja nie rzuci nam błędem, ale zwróci niepoprawny wynik np. błędnie obliczy podatek. Przez co decyzje podjęte na podstawie tych danych mogą okazać się nieprawidłowe co często niesie za sobą bardzo poważne konsekwencje. Najlepszym sposobem zabezpieczenia się przed takimi problemami są odpowiednio napisane testy.

Changelog

Systemy korzystające z wersjonowania nierzadko prowadzą również changelog. Jest to świetne miejsce do tego aby przekonać się co zostało zmienione, a w niektórych przypadkach nawet co przestanie być wspierane w kolejnej wersji. Zapoznając się z takim changelogiem będziemy w stanie za wczasu przygotować nasz sytem pod zmiany, które będą wprowadzone w przyszłości.

Na słowo

Wersjonowanie jest jednym z lepszych sposobów informowania klientów o zmianach w naszym systemie. Jednak niestety nie żyjemy w świecie idealnym. Bardzo często jeśli jakieś API lub biblioteka jest używana tylko wewnątrz firmy lub przez jednego klienta to z różnych powodów (brak czasu, wspieranie tylko jednej wersji, „a na co to komu”, „jak będziemy mieli czas to dodamy”) wersjonowanie jest traktowane po macoszemu albo w ogóle go nie ma.

Wtedy informacja o breaking changes jest przekazywana ustnie bądź pisemnie np. za pomocą wiadomości na slack’u. Nie twierdzę, że to jest zły sposób szczególnie jeśli dobrze działa w danym kontekście lub domyślnie nie wspieramy kompatybilności wstecznej. Zwracam tylko uwagę, na to, że nie zawsze będziemy mieli dostęp do wygody jaką jest wersjonowanie 🙂 W takim przypadku należy położyć jeszcze większy nacisk na testy w celu upewnienia się, czy kolega z pracy na pewno nas nie okłamał lub nie zapomniał nam o czymś powiedzieć przed pójściem na urlop.

Zdarza się również, że taka informacja nigdy nie zostanie przekazana i dowiemy się o niej dopiero podczas implementacji.

Zalety kompatybilności wstecznej

Niezależne wdrożenia

Czytając powyższy tytuł pewnie od razu przychodzą Ci na myśl mikroserwisy, jednak nie tylko one będą czerpać korzyści z kompatybilności wstecznej. Ale najpierw skupmy się na tym czym są niezależne wdrożenia.

Niezależne wdrożenia pozwalają nam na aktualizacje wybranych części naszego oprogramowania bez konieczności aktualizowania wszystkich jego elementów. Przykładowo chcąc wdrożyć nową wersję modułu odpowiedzialnego za sprawdzanie legalności transakcji (compliance), nie musimy wdrażać nowej wersji modułu płatności. Możemy to osiągnąć odpowiednio dobierając architekturę, czyli prawidłowo wyznaczając granice naszego modułu oraz kontrakty (abstrakcje) za pomocą których nasi klienci będą się z nami komunikować.

Przykładowy kodzik:

// Kontrakt modułu sprawdzającego legalność transakcji
// Jest to zwykłe zapytanie HTTP

POST /compliance/can-make-transfer
{
    from: string;
    to: string;
    value: number;
}
// Użycie w kodzie

// Funkcja pomocnicza wysyłająca zapytanie HTTP
declare function request<Request, Response>(url: string, method: string, body: Request): Promise<Response>;

// Kontrakt
interface CanMakeTransferRequest {
    from: string;
    to: string;
    value: number;
}

// Kontrakt
interface CanMakeTransferResponse {
    result: boolean;
}

// Metoda korzystająca z kontraktu
async function transfer(from: string, to: string, value: number): Promise<void> {
    ...

    const canMakeTransfer = await request<CanMakeTransferRequest, CanMakeTransferResponse>('compliance/can-make-transfer', 'POST', {
        from,
        to,
        value,
    });

    if (canMakeTransfer) {
        ...
    }
    
    ...
}

Jednak jak wszyscy wiemy nawet najlepiej dobrane kontrakty z czasem mogą ulec zmianie.

Nietrudno wyobrazić sobie sytuacje w której część biznesu odpowiedzialna za sprawdzanie legalności transakcji (compliance) będzie chcieć aby prócz adresu nadawcy, odbiorcy oraz kwoty przekazywać również walutę. Jeśli zmienimy nasz kontrakt w ten sposób:

// Kontrakt modułu sprawdzającego legalność transakcji
// Jest to zwykłe zapytanie HTTP

POST /compliance/can-make-transfer
{
    from: string;
    to: string;
    value: number;
    currency: string;
}

To automatycznie wymusimy zmianę w module płatności, a co za tym idzie jego ponowne wdrożenie, ponieważ reguły walidacyjne rzucą błędem mówiącym o braku currency:

// Kontrakt
interface CanMakeTransferRequest {
    from: string;
    to: string;
    value: number;
}

// Kontrakt
interface CanMakeTransferResponse {
    result: boolean;
}

// Metoda korzystająca z kontraktu
async function transfer(from: string, to: string, value: number): Promise<void> {
    ...

    // W tym miejsu moduł Compliance zwróci nam ERROR mówiący o braku currency
    const canMakeTransfer = await request<CanMakeTransferRequest, CanMakeTransferResponse>('compliance/can-make-transfer', 'POST', {
        from,
        to,
        value,
    });

    if (canMakeTransfer) {
        ...
    }
    
    ...
}

Jesteśmy zmuszeni do jednoczesnej aktualizacji modułu Compliance oraz modułu płatności, ponieważ w innym przypadku zostanie odrzucone 100% transakcji. Aby tego uniknąć musimy zapewnić kompatybilność wsteczną i na przykład dodać currency jako wartość opcjonalną. Co pozwoli nam na komunikację z modułem za pomocą starego kontraktu:

// Kontrakt modułu sprawdzającego legalność transakcji
// Jest to zwykłe zapytanie HTTP

POST /compliance/can-make-transfer
{
    from: string;
    to: string;
    value: number;
    // To pole jest opcjonalne
    currency?: string;
}

Dzięki temu będziemy mogli wdrożyć moduł Compliance bez konieczności aktualizacji modułu płatności. Co pozwoli innym klientom na skorzystanie z nowej funkcjonalności bez konieczności czekania, aż moduł płatności się dostosuje i wdroży nową wersję.

Oczywiście strategii na zapewnienie kompatybilności wstecznej w tym przypadku jest mnóstwo, ale jest to zbyt obszerny temat aby poruszyć go w tym artykule.

Wcześniej wspomniałem, że nie tylko mikroserwisy skorzystają z kompatybilności wstecznej. No bo przecież analogiczna sytuacja byłaby jakby zamiast modułu płatności to zapytanie wysyłał nasz frontend. Robiąc zmianę niekompatybilną wstecz wymusilibyśmy jednoczesną aktualizację frontendu oraz backendu co może być bardzo kłopotliwe szczególnie przy większych systemach oraz aplikacjach mobilnych/desktopowych.

Możliwość odroczenia adaptacji zmian w czasie

Kompatybilność wsteczna daje nam również możliwość odroczenia zmian w czasie. Przykładowo aktualizując react’a do wersji kompatybilnej wstecz nie musimy nic zmieniać w naszym kodzie. Podczas kompilacji dostaniemy tylko informację, że jakaś funkcja przestanie być wspierana w kolejnej wersji lub się zmieni, natomiast nie spowoduje to błędów i nie wymusi na nas żadnych poprawek. Daje to ogromną swobodę podczas wdrażania nowej wersji, ponieważ możemy to sobie rozłożyć na kilka etapów i nie robić wszystkiego za jednym zamachem.

Nasi klienci na pewno to docenią i znacznie częściej będą aktualizować naszą bibliotekę lub przerzucać się na nową wersje API. Wracając do przykładu z niezależnych wdrożeń, ludzie odpowiedzialni za rozwijanie modułu płatności, na pewno ucieszą się z tego, że nagle w połowie sprintu nie wskoczy im nowy task z priorytetem „people are dying” pod tytułem „zaktualizuj zapytanie do modułu compliance„. Założe się, że i bez tego mają sporo na głowie 🙂

Autonomia zespołów

Jest to temat niejako powiązany z dwoma poprzednimi punktami, ale chciałem na niego zwrócić uwagę, ponieważ żeby czerpać korzyści z kompatybilności wstecznej w cale nie musimy mieć niezależnych wdrożeń. Możemy mieć aplikację w architekturze modularnego monolitu, gdzie mamy jedno repo i każdy zespół pracuje nad kawałkiem swojego kodu.

W momencie kiedy zmienimy sygnaturę metody (na przykład dodając nowy wymagany argument) w naszym module z którego korzysta inny moduł, będziemy musieli wprowadzić zmianę również tam, ponieważ kod się nam zwyczajnie nie skompiluje.

W efekcie czego PR’ka jest o wiele większa szczególnie jeśli dana metoda jest wykorzystywana w kilku miejscach. Jesteśmy również zmuszeni do „grzebania” w cudzym kodzie. Z doświadczenia wiem, że nikt tego nie lubi robić, a już na pewno nie są z tego zadowoleni właściciele kodu, który zmieniamy. Oczywiście możemy poprosić drugi zespół o dodanie zmian do naszej PR’ki lub asystowanie przy grzebaniu w implementacji (jeśli jest skomplikowana to wręcz będziemy do tego zmuszeni), ale co gdy zamiast jednego zespołu jest ich 10?

Nie oznacza to wcale, że mamy źle dobraną architekturę. Możemy mieć po prostu wielu klientów naszego kontraktu, którzy mogli by się dostosować do zmian w swoim tempie jeśli tylko zapewnilibyśmy kompatybilność wsteczną.

Zero downtime deployment

Mówiąc wprost bez zapewnienia kompatybilności wstecznej to nie ma sensu. W momencie wdrożenia nowej wersji działa również stara, więc np. zmieniając schemat w bazie danych lub kontrakt eventu w sposób niekompatybilny wstecz, automatycznie powodujemy, że stara wersja zacznie rzucać błędami i nie będzie w stanie pomyślnie przeprowadzić żadnej operacji.

Możliwość obsługi „starych” danych

Nierzadko nasze aplikacje tworzą jakieś pliki tekstowe, raporty, csv’ki itp. Jeśli nowa wersja naszego oprogramowania nie będzie ich wspierać to na pewno nasi klienci mocno się zirytują. Wyobraźmy sobie sytuacje w której nowa wersja Microsoft Word, nie wspiera pliku stworzonego w poprzedniej wersji. Jest to wręcz nie do pomyślenia.

Na co zwracać uwagę?

Strategii na zapewnienie kompatybilności wstecznej jest wiele, natomiast jest to na tyle obszerny temat, że każdej z nich można by poświęcić osobny artykuł. Każda ze strategii ma swoje wady i zalety. Żadna nie jest idealna i wybór odpowiedniej zawsze zależy od kontekstu w którym się znajdujemy. Należy również pamietać, że aby zapewnić kompatybilność wsteczną w cale nie musimy używać żadnego systemu wersjonowania.

W tym punkcie chciałbym zwrócić Waszą uwagę na oczywiste oraz mniej oczywiste miejsca, o których musimy pamiętać chcąc zapewnić kompatybilność wsteczną. Niestety ze swojego doświadczenia wiem, że bardzo łatwo można je pominąć, szczególnie przy zero downtime deployment, a to niesie za sobą bardzo poważne konsekwencje. Natomiast dobór odpowiedniej strategii zapewnienia kompatybilność wstecznej pozostawiam Wam.

API

Mówiąc API mam na myśli komunikację w stylu client-server czyli np. SOAP, RPC, Websocket, REST itp. Jest to o tyle istotne gdyż zmieniając takie API nasz kompilator nie rzuci nam błędem (a przynajmniej nie bez wcześniejszej konfiguracji) pod tytułem „brakuje jednego parametru” o błędzie dowiemy się dopiero podczas wysłania zapytania lub procesowania odpowiedzi.

O API musimy dbać nie tylko w momencie kiedy wystawiamy je publicznie lub jeśli mamy mikroserwisy, ale również kiedy chcemy mieć niezależne wdrożenia backendu oraz frontendu.

Przed omyłkową breaking change możemy się zabezpieczyć odpowiednim zestawem testów np. kontraktowych.

Bazy danych

Bazy danych to kolejna rzecz jaką należy wziąć pod uwagę, ale tutaj sprawa jest nieco bardziej skomplikowana niż w przypadku API. Zmieniają schemat bazy danych trzeba wziąc pod uwagę kilka rzeczy:

Komunikacja za pomocą bazy danych

Zdarza się, że nasze systemy komunikują się ze sobą za pomocą bazy danych. Nie jest to zbyt popularny wzorzec i często jest traktowany jako antypattern. Natomiast jeśli wykorzystujemy go w naszym systemie to przed zmianą schematu należy sprawdzić, czy aby na pewno serwis który się z nami komunikuje będzie w stanie go obsłużyć. Przykładowo zmieniając nazwę tabeli lub kolumny w przypadku baz relacyjnych robimy tzw. breaking change przez co nasz klient dostanie błąd w momencie odpalenia zapytania SQL i nie będzie w stanie prawidłowo funkcjonować.

Niektórzy mogą pomyśleć, że to bardzo niszowy przypadek, ale założe się, że nie raz w waszym systemie macie sytuację, w której ktoś się joinuje do waszej tabeli w celu wyciągnięcia dodatkowych danych. Problem nasila się w momencie kiedy nie używamy ORM’a, który zadba o poprawność schematu i typów, tylko gdy korzystamy z raw SQL lub co gorsza kod poszczególnych modułów jest trzymamy w osobnych repozytoriach.

Zero downtime deployment

Jest to bardzo pomijany temat i wiele osób o tym zapomina. Nawet jeśli mamy system, w architekturze monolitu, który jest jedną jednostką wdrożeniową to i tak musimy zadbać o kompatybilność wsteczną bazy danych chcąc mieć zero downtime deployment. Dlaczego? Ponieważ w jednym momencie będziemy mieć uruchomione dwie wersje naszej aplikacją – starą i nową.

Kiedy nowa wersja zmieni schemat bazy danych w sposób niekompatybilny wstecz np. zmieniając nazwę kolumny to stara wersja podczas próby wykonania zapytania SQL dostanie błąd, mówiący o tym, że dana kolumna nie istnieje. Jeśli stara wersja żyje tylko przez klika sekund to BYĆ MOŻE jest to do przeżycia, natomiast pamiętajmy, że czasami takie wdrożenia trwają o wiele dłużej na przykład w przypadku testów A/B.

Należy mieć również na uwadzę, że nawet jeśli dodajemy dodatkową kolumnę z której korzysta nowa wersja naszego oprogramowania to logika istniejąca w tej wersji powinna być w stanie obsłużyć również stary schemat. Jesto to ważne, ponieważ w tzw. miedzyczasie stara wersja może wrzucać dane do tabelki według starego schematu w którym nie ma naszej nowej kolumny.

Kolejki (message bus)

Event driven architecture – komunikacja za pomocą eventów

Temat jest dosyć prosty. Podobnie jak w przypadku API musimy zadbać o to aby stare eventy, które mogą odbierać lub wysyłać nasi klienci były kompatybilne z nowymi eventami. Czyli mówiąc wprost w nowej wersji musimy umieć obsłużyć stary event, a klient który korzysta jeszcze ze starej wersji eventu powinien być w stanie również obsłużyć nowy event.

Zero downtime deployment

W przypadku zero downtime deployment problem jest analogiczny do baz danych. Musimy zadbać o to aby nowe eventy mogły zostać obsłużone przez starą wersje oprogramowania, a stare przez nową.

Najczęstsza pułapka

Wiele osób myśli, że jeśli mamy modularny monolit (pojedynczą jednostkę wdrożeniową), który komunikuje się za pomocą eventów tylko z samym sobą i nie wspieramy zero downtime deployment to możemy pominąć kompatybilność wsteczną. Otóż nic bardziej mylnego!

Należy pamiętać, że nawet jeśli w momencie wdrożenia zatrzymamy starą aplikację i dopiero wtedy uruchomimy nową to stare eventy, które jeszcze nie zostały obsłużone nadal znajdują się na kolejce! Przez co trafią one do handlerów znajdujących się w nowej wersji aplikacji zaraz po jej uruchomieniu i jeśli nie zapewnimy kompatybilności wstecznej to otrzymamy błąd i żaden event nie zostanie poprawnie obsłużony co może nieść za sobą bardzo poważne konsekwencje.

Serwisy zewnętrzne

Ten temat jest bardzo podobny do baz danych. Jeśli korzystamy z jakiegoś serwisu zewnętrznego na przykład AWS w którym mamy skonfigurowane jakieś usługi, to przed ich zmiana musimy się upewnić czy aby na pewno stara wersja naszego oprogramowania będzie mogła się z nimi skomunikować/poprawnie wykonać operacje.

Przykładem może być storage jakim jest S3. W momencie kiedy w nowej wersji oprogramowania zmienimy nazwę bucket’a to stara wersja podczas próby odczytania bądz wysyłania pliku dostanie błąd.

Pliki i dane

O tym temacie wspominałem już wcześniej. Nasze systemy nierzadko wytwarzają jakieś pliki lub zwracają dane, które gdzieś zapisujemy. Jeśli chcemy zapewnić kompatybilność wsteczną to należy sprawdzić czy aby na pewno plik, który ktoś wygenerował X czasu temu będzie poprawnie obsłużony w nowej wersji naszego oprogramowania.

Trzeba mieć też na uwadze nazwy plików generowanych na przykład za pomocą CRON’a. Jeśli nagle zmienimy nazewnictwo to nasz klient lub stara wersja aplikacji w przypadku zero downtime deployment nie będzie w stanie odczytać nowych plików, a nowa wersja starych.

Zmienne środowiskowe

Temat jest bardzo prosty ale również należy o nim pamiętać. W przypadku zero downtime deployment jeśli modyfikujemy jakieś zmienne środowiskowe to należy się upewnić czy stara wersja aplikacji będzie w stanie je poprawnie obsłużyć.

Ten problem nie występuje zawsze. W przypadku kiedy korzystamy np. z dockera to każdy kontener ma swoje zmienne środowiskowe ustawiane w momencie startu (podobnie ma node.js), więc zmiana zmiennych na maszynie nie wpłynie na ten kontener. No chyba, że wystartujemy go jeszcze raz i ponownie zaczytamy zmienne. Może się to wydarzyć np. kiedy robimy rollback lub w momencie skalowania starej wersji.

Event sourcing

W przypadku kiedy w naszym systemie mamy event sourcing to należy zwrócić szczególną uwagę na kompatybilność wsteczną eventów wysyłanych na kolejkę oraz zapisywanych w bazie danych. Jest to w zasadzie kumulacja problemów o których wspomniałem wcześniej w sekcjach baz danych oraz Event driven architecture.

Wady kompatybilności wstecznej

Kompatybilność wsteczna jak każde podejście nie jest bez wad i nienależy jej bezmyślnie stosować w każdym systemie. Pamiętajmy o tym, że aby zapewnić kompatybilność wsteczną jest potrzebny ogromny nakład pracy co znacznie przekłada się na czas oraz koszty potrzebne na dostarczenie efektów. Co więcej musimy również utrzymywać kilka wersji kodu odpowiedzialnego za obsługiwanie starych zapytań, które do nas spływają. Problem nasila się kiedy wspieramy kilka wersji wstecz.

Niektórych zmian nie jesteśmy w stanie przeprowadzić za jednym razem i musimy je rozłożyć na kilka wdrożen. Na przykład mając zero downtime deployment nie jesteśmy w stanie tak po prostu zmienić nazwy kolumny. Musimy robić to etapami co znacznie wydłuża czas i zwiększa koszty.

Dodatkowo należy pamiętać o tym, że aby mieć pewność czy na pewno jesteśmy kompatybilni wstecz powinniśmy się zabezpieczycz odpowiednimi testami. Często jest potrzebny większy nakład testów niż w przypadku braku takiej kompatybilności. No bo przecież nie testujemy tylko najnowszego kodu, ale również musimy utrzymywać testy odpowiedzialne za stary kod. Co więcej takie testy są znacznie bardziej skomplikowane, ponieważ bardzo ciężko jest przetestować zewnętrzne serwisy, kolejki czy baze danych.