Zmiany łamiące kompatybilność: co się liczy i jak je wydać
8 min czytania zaktualizowano
Zmiana łamiąca kompatybilność to zmiana, której poprawnie napisany wywołujący nie mógłby przetrwać. Definicja ma znaczenie, ponieważ większość sporów o to, czy coś “się liczy”, to tak naprawdę spory o to, kto trzymał to źle. Jeśli wywołujący postępował zgodnie z waszą dokumentacją, a wasza zmiana sprawiła, że jego kod przestał działać, zmiana łamała kompatybilność. To, co zamierzaliście, nie ma z tym nic wspólnego.
To cały test. Reszta tego artykułu to to, co z niego wynika: co go nie przechodzi, co przechodzi, jak wyłapać porażkę, zanim trafi do maina, i co zrobić, gdy już wiecie, że wydajecie jedną z nich.
Co liczy się jako zmiana łamiąca kompatybilność?
Zastosujcie test do wywołującego, nie do diffa. Zmiana łamie kompatybilność, gdy wywołujący, który polegał wyłącznie na udokumentowanym zachowaniu, musi zmienić swój kod, konfigurację lub dane, by nadal działać. Usunięcie pola, zmiana nazwy endpointu, zaostrzenie walidacji, zmiana domyślnej wartości i zmiana typu wartości wszystkie się kwalifikują. Dodanie opcjonalnego pola nie. Naprawienie błędu zwykle nie, z jednym ważnym wyjątkiem poniżej.
| Zmiana | Łamie kompatybilność? | Dlaczego |
|---|---|---|
| Usunięcie lub zmiana nazwy pola, endpointu, flagi lub opcji | Tak | Poprawni wywołujący się do tego odwołują |
| Dodanie opcjonalnego pola lub nowego endpointu | Nie | Istniejące wywołania się nie zmieniają |
| Uczynienie opcjonalnego wejścia wymaganym | Tak | Wywołania, które je pomijały, teraz zawodzą |
| Zaostrzenie wcześniej akceptowanej walidacji | Tak | Dane wejściowe, które działały, są teraz odrzucane |
| Zmiana domyślnej wartości | Tak | Wywołujący, którzy jej nie ustawili, dostają nowe zachowanie |
| Zmiana typu (string na liczbę, pojedyncza wartość na tablicę) | Tak | Parsery napisane dla udokumentowanego typu zawodzą |
| Zmiana kolejności kluczy obiektu | Nie | Chyba że udokumentowaliście kolejność |
| Naprawienie błędu, na którym polegali wywołujący | W praktyce tak | Zobacz sekcję o przypadkowych kontraktach |
| Podniesienie limitu szybkości lub rozmiaru | Nie | Nic, co działało, nie przestaje działać |
| Obniżenie limitu szybkości lub rozmiaru | Tak | Ruch, który był w porządku, jest teraz ograniczany |
| Zmiana sformułowania komunikatu błędu | Zależy | Łamie kompatybilność, jeśli to udokumentowaliście lub wywołujący dopasowują do tego |
Co nie jest zmianą łamiącą kompatybilność?
Zmiana nie łamie kompatybilności, gdy każde wywołanie, które działało wcześniej, nadal działa bez zmian i nadal znaczy to samo. Dodanie nowego endpointu, opcjonalnego parametru żądania lub pola w odpowiedzi, uczynienie wymaganego wejścia opcjonalnym, podniesienie limitu i poprawa komunikatu błędu, do którego nikt nic nie dopasowuje, przechodzą test. Takie zmiany addytywne mogą trafić do wydania minor ze zwykłym wpisem w changelogu.
Zmiany addytywne mimo to łamią wywołujących w trzech sytuacjach. Klient, którego deserializer odrzuca nieznane pola, zawodzi na pierwszym nowym polu odpowiedzi, więc dokumentujcie od początku, że wywołujący muszą ignorować pola, których nie rozpoznają. Nowa wartość enum łamie każdego wywołującego z wyczerpującym switch’em (więcej o tym niżej). A rosnąca odpowiedź może wypchnąć wywołującego poza limit rozmiaru, timeout lub szerokość kolumny, o których nigdy nie musiał myśleć.
Cztery wiersze tabeli zasługują na bliższe spojrzenie, ponieważ tam pojawiają się niezgody.
Cztery zmiany łamiące kompatybilność, które pomijają zespoły
Przypadkowe kontrakty. Jeśli wasze API zwracało to samo nieudokumentowane pole przez trzy lata, wywołujący na tym zbudował. Prawo Hyruma to krótka wersja: przy wystarczającej liczbie użytkowników każde obserwowalne zachowanie waszego systemu będzie od kogoś zależne. Dlatego “to była poprawka błędu” nie jest obroną. Poprawka może być poprawna i mimo to łamać kompatybilność. Wydajcie ją jako taką.
Zmiany zachowania bez zmiany schematu. Pole nadal tam jest, typ jest ten sam, a wartość
znaczy teraz coś innego. status, który był active lub inactive, a teraz zwraca też
suspended, łamie każdego wywołującego z wyczerpującym switch’em. Znacznik czasu przechodzący z
czasu lokalnego na UTC łamie każdego, kto nie przeczytał dokumentacji dwa razy. Nic w diffie
pliku OpenAPI tego nie pokazuje.
Zaostrzona walidacja. Zaczynacie odrzucać e-maile bez TLD, lub spacje na końcu, lub imiona dłuższe niż 80 znaków. Każdy wywołujący, który wysyłał dokładnie to, teraz dostaje 400 za żądanie, które działało w zeszłym tygodniu. Zmiany walidacji są najczęściej wydawane jako poprawka “utwardzania”.
Zmienione wartości domyślne. Nikt, kto ustawił wartość jawnie, nic nie zauważa. Wszyscy, którzy tego nie zrobili, czyli większość wywołujących, dostają nowe zachowanie bez zmiany ani jednej linii. Zmieniona wartość domyślna łamie większość waszych użytkowników dokładnie dlatego, że nigdy nie widzieli tego ustawienia.
Jak wykryć zmianę łamiącą kompatybilność, zanim zostanie wydana?
Porównajcie kontrakt z pull requesta z kontraktem z głównej gałęzi, w CI, i oblejcie build przy łamiącej różnicy. Narzędzia do diffowania schematów istnieją dla większości formatów interfejsów, a każde zna reguły łamania kompatybilności swojego formatu:
| Interfejs | Narzędzie | Co porównuje |
|---|---|---|
| REST (OpenAPI) | oasdiff | Dwie specyfikacje OpenAPI, z raportem zmian łamiących kompatybilność |
| gRPC (Protobuf) | buf breaking | Pliki .proto, na poziomie wire lub źródła |
| GraphQL | GraphQL Inspector | Dwa schematy, z oznaczeniem zmian łamiących i niebezpiecznych |
| Crate’y Rusta | cargo-semver-checks | Publiczne API względem ostatniej opublikowanej wersji |
| Pakiety TypeScript | API Extractor | Zatwierdzony raport publicznego API pakietu |
Te narzędzia niezawodnie łapią usunięte pola, zmienione nazwy operacji i zmienione typy. Nie widzą pierwszych dwóch z czterech rodzajów powyżej, przypadkowego kontraktu i zmiany zachowania, bo żaden z nich nie pojawia się w schemacie. Użyjcie narzędzia, by zatrzymać oczywiste, a pytania w review “czy poprawny wywołujący mógłby to zauważyć?” dla reszty. To samo zadanie CI to naturalne miejsce, by wymagać wpisu w changelogu, jak opisuje wymuszanie wpisu w changelogu w CI, a zmiany API w gRPC i Protobuf omawiają przypadki na poziomie wire.
Jak oznaczyć zmianę łamiącą kompatybilność w commicie?
W Conventional Commits zmianę łamiącą
kompatybilność oznacza ! przed dwukropkiem (feat(api)!: remove the legacy export endpoint) lub
stopka zaczynająca się od BREAKING CHANGE: z opisem. Każde z nich odpowiada wersji major. Napiszcie
stopkę jako pierwszy szkic wpisu w changelogu, nazywając, kogo to dotyczy i co muszą zrobić.
Conventional commits a changelog opisuje, jak daleko
sięga ta konwencja.
Ta sama zasada obowiązuje biblioteki. Usunięta funkcja publiczna, zawężony typ parametru lub zmieniona wartość zwracana to wersja major w wersjonowaniu semantycznym. Biblioteki nie zawsze się tego trzymają: badanie 119 879 aktualizacji z Maven Central wykazało, że 16,6% naruszyło wersjonowanie semantyczne, a mimo to dotknęło to tylko 7,9% projektów klienckich, bo większość tych zmian dotyczyła kodu, którego żaden klient nie wywoływał. Złamanie kompatybilności mierzy się u wywołującego.
Jak wydaje się zmianę łamiącą kompatybilność?
Wydajecie ją otwarcie, z datą, ze ścieżką. Kroki poniżej są w kolejności, a ostatni to ten, który pomija większość zespołów: powiedzenie ludziom, których to dotknęło, że to, na co czekali, już się wydarzyło.
- Zdecydujcie, czy to ona. Użyjcie testu powyżej, nie diffa. Jeśli dwoje inżynierów się nie zgadza, to łamie kompatybilność; niezgoda jest dowodem, że wywołujący mógł rozsądnie polegać na starym zachowaniu.
- Wersjonujcie ją. Pod wersjonowaniem semantycznym zmiana łamiąca kompatybilność to wersja major. Jeśli prowadzicie API datowane lub wersjonowane, trafia do nowej wersji, a stara nadal działa do zadeklarowanej daty. Jeśli nie możecie wersjonować, nie wydajecie zmiany łamiącej kompatybilność, wydajecie awarię z wpisem changelogu. To, który schemat niesie wersję, jest tematem najlepszych praktyk wersjonowania API.
- Napiszcie wpis przed zmergowaniem kodu. Wpis ma stałą formę: co się zmienia, kogo dotyczy, co muszą zrobić, i do kiedy. Jeśli nie możecie wypełnić wszystkich czterech, zmiana nie jest gotowa. Szablon release notes stawia te wpisy jako pierwsze, z datą zamiast numeru wersji, dokładnie z tego powodu.
- Podajcie termin, nie numer wydania. “Usunięte w v5” nic nie znaczy dla kogoś, kto nie śledzi waszych wydań. “Przestaje działać 1 listopada 2026” znaczy to samo dla wszystkich.
- Zapewnijcie migrację. Przykład kodu ze starego wywołania obok nowego. Jeśli zmiana to zmiana nazwy, podajcie obie nazwy w tym samym zdaniu. Jeśli to usunięte pole, powiedzcie, dokąd trafiły dane.
- Ogłoście wszędzie, gdzie stare zachowanie było udokumentowane. Changelog, stronę dokumentacji opisującą endpoint, release notes SDK, i nagłówek deprecacji w odpowiedzi, jeśli go macie. Ogłoszone w jednym miejscu jest ogłoszone ludziom, którzy akurat tam spojrzeli.
- Zamknijcie pętlę. Jeśli klientka poprosiła o zmianę, lub zgłosiła błąd, który do niej doprowadził, powiedzcie jej, gdy zostanie wydana. To krok, który zmienia to z czegoś zrobionego waszym użytkownikom w coś zrobionego razem z nimi.
Jak wygląda dobry wpis o zmianie łamiącej kompatybilność?
Dobry wpis nazywa dotkniętego wywołującego w pierwszej linii, podaje datę, i zawiera poprawkę. Oto jeden dla przypadku zaostrzonej walidacji, w formie, której używamy:
Adresy e-mail bez domeny są odrzucane od 1 listopada 2026.
POST /usersiPATCH /users/:idobecnie akceptują wartościalice@localhost. Od 1 listopada te zwracają400 invalid_email. Dotyczy każdej integracji, która tworzy użytkowników z wewnętrznych katalogów. Migracja: wyślijcie w pełni kwalifikowany adres, lub pomińcie pole i ustawcie je później. Żadna zmiana nie jest potrzebna, jeśli wasze adresy już mają domenę, co dotyczy 99,4% kont utworzonych w tym roku.
Gdzie to powiadomienie powinno mieszkać, i co jeszcze powinno mu towarzyszyć, omawia changelog API.
Procent na końcu to nie dekoracja. Mówi czytelniczce, czy powinna się martwić, co jest pytaniem, z którym otworzyła wpis.
Dlaczego po prostu ich nie unikać?
Ponieważ alternatywa jest gorsza. API, które nigdy niczego nie psuje, gromadzi każdy błąd, jaki kiedykolwiek popełniło: źle nazwane pole, złą wartość domyślną, znacznik czasu w czasie lokalnym. Każdy z nich to podatek dla każdego nowego wywołującego na zawsze, by chronić wywołujących, którzy mogliby migrować w jedno popołudnie. Zespoły z najlepszą reputacją stabilności rzadko coś psują, według harmonogramu, ze ścieżką migracji i ostrzeżeniem, które dotarło do ludzi, dla których było przeznaczone.
Mechanika tego ostrzeżenia jest tematem towarzyszącego artykułu o deprecjonowaniu API. Wpis, który to ogłasza, jest przygotowywany w ten sam sposób co każdy inny wpis w kanale changelogu: ze zmergowanego pull requesta, zatrzymany dla człowieka, a potem opublikowany w miejscu, gdzie dotknięci wywołujący już czytają.
FAQ
Jaka jest różnica między zmianą łamiącą a niełamiącą kompatybilność? Zmiana łamiąca zmusza poprawnego wywołującego do zmiany kodu, konfiguracji lub danych, by nadal działał. Zmiana niełamiąca zostawia każde istniejące wywołanie działającym z tym samym znaczeniem, dlatego dodania zwykle są bezpieczne, a usunięcia, zmiany nazw i zaostrzone reguły zwykle nie.
Czy dodanie wymaganego pola się liczy? Tak. Każde istniejące wywołanie je pomija, więc każde istniejące wywołanie teraz zawodzi. Dodajcie je jako opcjonalne z sensowną wartością domyślną, lub wersjonujcie endpoint.
Czy poprawka błędu się liczy? Może. Jeśli wywołujący polegali na wadliwym zachowaniu, naprawienie go ich łamie, bez względu na to, co mówiła dokumentacja. Traktujcie każdą poprawkę zmieniającą obserwowalny wynik jako łamiącą kompatybilność, chyba że możecie wykazać, że nikt na niej nie polegał.
Czy wersjonowanie semantyczne dotyczy API webowego? Zasada tak: zmiany łamiące kompatybilność dostają nową wersję major, a stara nadal działa przez zadeklarowany okres. Numer często żyje w URL lub nagłówku daty zamiast w wersji pakietu.
Ile wyprzedzenia wystarczy? Wystarczająco, by wywołujący znalazł powiadomienie i wykonał pracę. Dziewięćdziesiąt dni to powszechna dolna granica dla publicznych API; dłużej dla wszystkiego, co jest używane w kodzie dostarczanym użytkownikom końcowym i nie może być zaktualizowane zdalnie.
Twierdzenia techniczne w tym artykule nie zostały niezależnie zweryfikowane. Jeśli coś się nie zgadza, daj nam znać, a poprawimy to.