Wersjonowanie kontraktów JSON: zgodność, wycofywanie i migracja danych
Podsumowanie: Dodanie pola nie jest automatycznie bezpieczne. Omawia granice zgodności, pola wersji, okna wycofywania, migrację z podwójnym zapisem, testy kontraktu i metryki canary.

Najważniejsze: prowadź ewolucję wersji jako expand, backfill, potem contract, i utrwal wersję wewnątrz dokumentu; potwierdź, że konsumenci tolerują nieznane pola i wartości wyliczeń, zanim cokolwiek wdrożysz, a wycofywanie przeprowadź w kolejności freeze, announce, observe, remove.
Trudną częścią wersjonowania nie jest zmiana kodu; jest nią to, że nie wiesz, kto nadal czyta stare pole. Zgodność nigdy nie jest obietnicą jednokierunkową — to ciągłe uzgadnianie między wydawcą a nieznanymi konsumentami.
1. Granice zgodności: dodanie nie jest automatycznie bezpieczne
Podręczniki nazywają nowe pole opcjonalne „zgodnym wstecznie”, ale to obowiązuje tylko wtedy, gdy konsumenci tolerują nieznane pola. Dodanie pola nadal łamie klientów w co najmniej trzech sytuacjach:
- konsument waliduje schematem, który ustawia
additionalProperties: false; - konsument przechodzi obiekt w stałej kolejności albo czyta wartości po pozycji, a nie po kluczu;
- nowe pole używa starej nazwy, ale zmienia znaczenie, na przykład poszerzając
statusz „status zamówienia” na „status realizacji”.
Bezpieczne dodanie wymaga trzech warunków wstępnych: pole jest opcjonalne, jego znaczenie jest niezależne, a konsumenci ignorują nieznane klucze. Potwierdź wszystkie trzy, zanim w ogóle zaczniesz rozmawiać o numerze wersji.
{
"schemaVersion": 2,
"orderId": "ord-8842",
"status": "paid",
"refundDeadline": "2026-11-30T00:00:00+08:00",
"channel": "wechat"
}
Tutaj refundDeadline i channel to opcjonalne dodatki v2, podczas gdy orderId i status zachowują oryginalną semantykę. schemaVersion jest utrwalane z dokumentem, a nie żyje tylko w nagłówku HTTP.
2. Zmiany łamiące zgodność, których nie możesz wdrożyć
Traktuj poniższe zmiany jako czerwone linie wymagające nowej wersji lub przejścia z podwójnym zapisem. Aktualizacja dokumentacji cię nie uratuje:
| Zmiana | Dlaczego łamie zgodność | Akceptowalna alternatywa |
|---|---|---|
| Usunięcie pola | Konsumenci natychmiast czytają undefined |
Wycofaj, obserwuj, usuń po zamknięciu okna |
| Zmiana typu pola | "1" staje się 1; klienci silnie typowani zawodzą od razu, a luźno typowani często nie przechodzą walidacji |
Dodaj nową nazwę pola i podwójny zapis w okresie przejściowym |
| Dodanie pola wymaganego | Stare wywołania go nie wysyłają, więc walidacja zawsze zawodzi | Zapewnij wartość domyślną albo czyń je wymaganym tylko w nowej wersji |
| Zmiana zbioru wartości wyliczeń | Instrukcje switch konsumenta nie mają gałęzi fallback |
Najpierw naucz klientów akceptować nieznane wartości, potem poszerz wyliczenie |
| Zmiana znaczenia pola | Typy pozostają poprawne, a zachowanie się zmienia — najtrudniejszy przypadek do debugowania | Traktuj jak nowe pole i zablokuj stare znaczenie |
| Zmiana głębokości zagnieżdżenia | Każda ścieżka dostępu musi się zmienić | Zachowaj stary kształt i dodaj płaskie pola lub parametr expand |
Zmiana znaczenia to najniebezpieczniejszy wiersz: walidacja schematu pozostaje zielona, monitoring nie zgłasza błędów, a dryfują tylko metryki biznesowe. Każdą zmianę znaczenia należy wdrażać jako nowe pole.
3. Gdzie żyje wersja: dokument, URL, typ nośny
Trzy częste umiejscowienia służą różnym celom:
| Miejsce | Przykład | Dobre do | Ograniczenie |
|---|---|---|---|
| Pole dokumentu | "schemaVersion": 2 |
Baz danych, kolejek komunikatów, kopii zapasowych offline | Warstwa transportu nie potrafi routować po nim |
| Ścieżka URL | /v2/orders/8842 |
Publicznych API | Wiąże adresy zasobów z wersjami i czyni migrację drogą |
| Typ nośny | application/vnd.acme.order.v2+json |
Usług wewnętrznych wymagających negocjacji treści | Wywołujący muszą poprawnie ustawić Accept |
Praktyczny podział pracy: używaj negocjacji typu nośnego na łączu i zawsze utrwal pole wersji w dokumencie. Stare wiadomości w kolejce nie awansują, bo zmieniłeś URL, i pliki kopii zapasowych też nie. Tylko schemaVersion w dokumencie daje odtwarzanie, backfill i audytom fundament.
GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json
Jeśli wersjonujesz URL, przynajmniej utrzymuj stary ścieżkę zwracającą stary kształt przez całe okno wycofywania, zamiast po cichu wskazywać ją na nową implementację. Szersze decyzje o kontrakcie API opisano w Kluczowych punktach projektowania kontraktów danych API JSON.
4. Otwarcie okna wycofywania
Wycofywanie to nie pojedyncze ogłoszenie; to okno z początkiem, okresem obserwacji i końcem. Wykonalny rytm:
- Freeze: przestań zapisywać stare pole, ale nadal je czytaj.
- Announce: oznacz datę usunięcia w dokumentacji, changelogu i nagłówku odpowiedzi jednocześnie.
- Observe: licz odczyty starego pola per wywołujący, aż pozostaną zerowe przez dwa kolejne okresy.
- Remove: dopiero po zamknięciu okna, gdy odczyty są zerowe i testy regresji przechodzą, usuń pole i jego kod zgodności.
Nagłówek odpowiedzi Sunset (RFC 8594) daje klientom czytelną maszynowo datę usunięcia. Deprecation to wciąż draft IETF: użyteczny, ale nie zakładaj, że każdy klient go rozumie. Połącz go z nagłówkiem Link wskazującym przewodnik migracji:
Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"
Długość okna to konwencja zespołowa. Częsty dolny próg to jeden cykl wydania lub 90 dni, w zależności co dłuższe. Publiczne API powinny dostać więcej, usługi wewnętrzne mogą dostać mniej, ale nigdy mniej niż czas potrzebny każdemu wywołującemu na aktualizację.
5. Znajdowanie konsumentów przed wycofaniem
Przed wycofaniem musisz umieć odpowiedzieć „kto nadal to czyta?”. Inaczej okno jest samooszukiwaniem. Uszereguj dowody według wiarygodności:
- Logi bramy: agreguj dostęp do starego pola po
appId,User-Agenti wersji klienta. - Rozproszone tracings: oznacz stare pole w warstwie serializacji, aby tracing pokazywał nazwę wywołującej usługi bezpośrednio.
- Rejestr konsumentów kolejki: lista grup konsumentów jest bardziej wiarygodna niż wyszukiwanie w kodzie.
- Wyszukiwanie w repozytorium:
rgobejmuje tylko twój własny kod; wywołujący zewnętrzni nadal wymagają logów.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats
Trafienie w wyszukiwaniu dowodzi „ktoś w tym repozytorium to czyta”, a nie „nikt inny tego nie czyta”. Eksportuj logi bramy dzień po dniu i potwierdź, że odczyty starego pola pozostają zerowe, zanim cokolwiek usuniesz.
6. Migracja danych: expand, backfill, contract
Parallel Change Martina Fowlera opisuje trzy kroki: expand (stare i nowe pola współistnieją i można czytać obie wersje), backfill (przenieś historyczne rekordy do nowego kształtu) i contract (usuń stare pole oraz gałęzie zgodności). Nie odwracaj kolejności, inaczej nie będziesz mieć ścieżki wycofania.
// Migration skeleton: cursor by primary key, conditional writes for idempotency
async function migrateBatch(collection, cursor, batchSize = 500) {
const records = await collection
.find({ _id: { $gt: cursor } })
.limit(batchSize)
.toArray();
if (records.length === 0) {
return { nextCursor: cursor, done: true };
}
const operations = records
.filter((record) => record.schemaVersion < 2)
.map((record) => ({
updateOne: {
filter: { _id: record._id, schemaVersion: record.schemaVersion },
update: {
$set: {
schemaVersion: 2,
status: record.legacyStatus ?? record.status,
refundDeadline: record.refundDeadline ?? null,
},
},
},
}));
if (operations.length > 0) {
await collection.bulkWrite(operations);
}
return { nextCursor: records.at(-1)._id, done: false };
}
Szczegóły łatwe do przeoczenia:
- Idempotencja: dołącz stary numer wersji do
filter, aby ponowne uruchomienie nigdy nie przepisało już zmigrowanego rekordu. - Partie i punkty kontrolne: postępuj po kursorze
_id, przechowuj punkt kontrolny i wznawiaj z niego po awarii. - Próba: uruchom skrypt na produkcyjnym snapshotcie i porównaj liczbę rekordów oraz sumy kontrolne przed i po.
- Wycofanie: trzymaj stare pole, dopóki nowe pole nie przejdzie walidacji; przełączaj ścieżki odczytu flagą i nigdy nie cofaj tylko przez edycję
schemaVersion. - Walidacja: uruchom ograniczenia biznesowe po migracji (nieujemne kwoty, legalne przejścia stanów), a nie tylko sprawdzenie parsowania JSON.
Dane konfiguracyjne podążają za tym samym procesem; jak organizować te pliki, opisano w Zarządzaniu JSON dla konfiguracji, danych testowych i danych lokalnych.
7. Testy kontraktu: włóż zgodność do CI
Zgodność nie może zależeć od pamięci przeglądu kodu; potrzebuje testów. Dwie warstwy wystarczą:
- Testy kształtu: waliduj odpowiedzi JSON Schema, ale nie ustawiaj
additionalProperties: falsena schematach używanych przez konsumentów. - Testy podzbioru konsumenta: wyciągnij pola, na których stary konsument naprawdę polega, do podschematu i asercuj, że nowe odpowiedzi nadal go spełniają.
// Contract test: new responses must still satisfy the old consumer subset
import Ajv from 'ajv';
import assert from 'node:assert/strict';
import { test } from 'node:test';
const ajv = new Ajv({ strict: false });
const validateLegacySubset = ajv.compile(legacyConsumerSubsetSchema);
test('a v2 response still satisfies the v1 consumer subset', () => {
const response = buildOrderResponse({ schemaVersion: 2 });
const { schemaVersion, ...legacyFields } = response;
const isValid = validateLegacySubset(legacyFields);
assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});
Podschemat deklaruje tylko pola i typy, które czyta stary konsument, i przepuszcza każde nowe pole. To łapie usunięte pola i zmiany typów bez fałszywych alarmów przy dodaniach. Trzymaj dane przypadków w katalogu danych testowych, uporządkowane i diffowalne; o konfliktach scalania w tych plikach przeczytasz w Konfliktach Git w plikach JSON: union zapisuje zepsuty JSON, scalaj po kluczach.
8. Wdrażanie canary: skaluj wymiarami, zatrzymuj na metrykach
Problemy zgodności rzadko ujawniają się w testach jednostkowych; ujawniają się w canary. Poszerzaj ekspozycję od wąskiej do szerokiej:
| Wymiar | Praktyka | Co waliduje |
|---|---|---|
| Allowlista dzierżawców | Najpierw włącz nową ścieżkę odczytu dla wewnętrznych lub życzliwych klientów | Różnice kształtów danych w realnym świecie |
| Wersja klienta | Włącz tylko dla klientów już zaktualizowanych do nowego kształtu | Starych i nowych klientów działających obok siebie |
| Procent ruchu | 5% → 25% → 100%, obserwując jeden pełny cykl biznesowy per krok | Wywołań long-tail i zachowania trafień cache |
Obserwuj co najmniej trzy metryki: wskaźnik błędów API, wskaźnik porażek walidacji kontraktu oraz stosunek odczytów starych i nowych pól. Zdefiniuj warunki automatycznego zatrzymania, takie jak 5xx powyżej 1% lub nagły powrót odczytów starego pola, i cofnij flagę. Utrzymuj podwójne zapisy podczas canary, aby wycofanie wymagało tylko przełączenia ścieżki odczytu, a nie naprawy danych.
9. Lista kontrolna zgodności przed wdrożeniem
- [ ] Nowe pola są opcjonalne, semantycznie niezależne i nie używają starych nazw.
- [ ] Konsumenci obsługują nieznane pola i nieznane wartości wyliczeń przez fallback.
- [ ] Pole wersji jest utrwalane z dokumentem i negocjowalne na łączu.
- [ ] Okno wycofywania ma ogłoszenie, nagłówek
Sunseti obserwację. - [ ] Odczyty starego pola można powiązać z konkretnymi wywołującymi przez logi lub tracings.
- [ ] Skrypt migracji jest idempotentny, partiami, wznawialny i ma ścieżkę wycofania.
- [ ] Testy kontraktu obejmują podzbiór starszego konsumenta i działają w CI.
- [ ] Canary ma jawne wymiary ekspozycji i metryki zatrzymania.
FAQ
Czy dodanie pola opcjonalnego zawsze jest zgodne wstecznie?
Nie. Jest zgodne tylko wtedy, gdy konsumenci ignorują nieznane pola i unikają ścisłej walidacji schematem. Konsument używający additionalProperties: false lub czytający po pozycji nadal zawiedzie. Zweryfikuj jego tolerancję testem kontraktu przed wdrożeniem.
Czy schemaVersion powinno żyć w URL czy w treści odpowiedzi?
Łącze może negocjować wersje przez typy nośne lub ścieżki URL, ale przechowywane rekordy muszą nieść schemaVersion wewnątrz dokumentu. Kolejki, kopie zapasowe offline i audyty nigdy nie przechodzą przez URL, więc tylko wersja w dokumencie może wspierać odtwarzanie i backfill.
Czy przestarzałe pole można usunąć natychmiast?
Nie. Przestań je zapisywać, ogłoś datę usunięcia i licz odczyty per wywołujący. Usuń dopiero po zamknięciu okna i gdy odczyty pozostają zerowe. Przed usunięciem uruchom pełną regresję, aby potwierdzić, że żadne ukryte zadanie cron lub raport nadal go nie czyta.
Co jeśli skrypt migracji zawiedzie w połowie?
Zaprojektuj go jako idempotentne zadanie: warunkowe aktualizacje, kursor klucza głównego i punkty kontrolne. Wznów z punktu kontrolnego po awarii, a powtarzane uruchomienia nie mogą zapisać dwa razy. Do wycofania zachowaj stare pole i snapshot; nie cofaj tylko schemaVersion.
Jak uporządkować sekwencję migracji danych?
Podążaj za Parallel Change: najpierw expand, aby stare i nowe pola współistniały i można było czytać obie wersje, backfill historycznych rekordów do nowego kształtu, a potem contract przez usunięcie starego pola i gałęzi zgodności. Nie odwracaj kolejności, inaczej tracisz ścieżkę wycofania, i uczyń skrypt migracji idempotentnym, partiami i wznawialnym.