JSON Toolkit

JSON Patch w produkcji: atomowość, idempotencja i konflikty współbieżne

Podsumowanie: JSON Patch to tylko format zmian; atomowość, idempotencja i współbieżność nadal należą do ciebie. Omawia warunki wstępne test, ucieczkę ścieżek, wycofywanie i koordynację ETag.

Zaawansowany~10 min czytaniaOpublikowano 2026-10-31Zaktualizowano 2026-10-31
Okładka artykułu o JSON Patch w produkcji

Najważniejsze: najpierw zwaliduj docelowe ścieżki i uprawnienia patcha, potem dodaj asercję współbieżności przez test lub If-Match; stosuj patch do kopii, aby porażka nie zostawiała nic na wpół zapisanego.

Prawdziwym niebezpieczeństwem żądania PATCH nie jest zapisanie złego pola. Jest nim przekonanie, że zapis się udał, podczas gdy po cichu nadpisano czyjąś zmianę. JSON Patch daje narzędzia, ale projekt bezpieczeństwa nadal należy do ciebie.

1. Szybkie powtórzenie zestawu operacji RFC 6902

JSON Patch opisuje zmiany jako tablicę operacji, z typem nośnym application/json-patch+json. Sześć operacji wykonuje się w kolejności tablicy:

Operacja Efekt Kluczowe ograniczenie
add Dodaje składową lub wstawia element tablicy Indeks tablicy może równać się długości; - oznacza koniec
remove Usuwa składową lub element Ścieżka docelowa musi istnieć
replace Zastępuje istniejącą wartość Ścieżka docelowa musi istnieć
move Przesuwa węzeł Nie można przenieść węzła do jego własnej podścieżki
copy Kopiuje węzeł Ścieżka źródłowa musi istnieć
test Asercja równości wartości Jeśli zawiedzie, cały patch nie zostaje zastosowany

Wiele osób pamięta tylko pierwsze trzy i zapomina, że test jest tym, co zamienia zapis w ciemno w zapis warunkowy. Pełną semantykę operacji i granice tablic omówiono w JSON Pointer, Patch i Merge Patch.

2. Używanie test do zamiany patcha w warunkową aktualizację

Typowy patch optymistycznego blokowania zaczyna się nie od zapisu, ale od asercji wersji:

{"op":"test","path":"/version","value":1}

Umieść go w kompletnym patchu:

[
  { "op": "test", "path": "/version", "value": 1 },
  { "op": "replace", "path": "/version", "value": 2 },
  { "op": "replace", "path": "/status", "value": "paid" }
]

Logika jest taka: potwierdź, że wersja dokumentu wynosi obecnie 1, a następnie zmień ją na 2. Jeśli inne żądanie już awansowało wersję do 2, pierwszy test zawodzi, cały patch nie zostaje zastosowany i serwer zwraca konflikt. To zapobiega utraconym aktualizacjom bez wprowadzania rozproszonej blokady.

Kilka punktów projektowych:

  • Umieść test przed każdym zapisem. Jeśli wcześniejszy zapis już zadziałał, asercja jest bez znaczenia;
  • Asercuj numer wersji lub skrót wygenerowany przez serwer, a nie zwykłe pole, które klient może swobodnie zmieniać;
  • Zwiększaj wersję przy każdym udanym zapisie; inaczej dwa żądania używające tej samej starej wersji oba przechodzą;
  • value z test musi mieć dokładnie ten sam typ JSON co wartość dokumentu. 1 i "1" nie są równe.

3. Ucieczka ścieżek i zanieczyszczenie prototypu

Reguły ucieczki JSON Pointer są proste: zapisz ~ jako ~0, a / jako ~1 i zastąp ~ przed /, inaczej dostaniesz podwójną ucieczkę.

Key "a/b"    → /a~1b
Key "m~n"    → /m~0n
Key "a~/b"   → /a~0~1b

Ryzykiem zewnętrznych patchy zwykle nie jest ucieczka, ale niebezpieczne klucze. W JavaScript klucze takie jak __proto__, constructor i prototype mogą wywołać zanieczyszczenie prototypu, nawet przez zwykłe add:

[
  { "op": "add", "path": "/__proto__/polluted", "value": true }
]

Używaj białej listy, a nie czarnej:

  1. Zezwól patchom modyfikować tylko pola biznesowe, takie jak /status lub /items/0/quantity;
  2. Waliduj każdy path i from wyrażeniem regularnym lub prefiksem ścieżki, odrzucając nieoczekiwane poziomy inne niż indeksy tablic;
  3. Parsuj JSON do kontenerów Object.create(null) lub Map, aby zmniejszyć wpływ zanieczyszczonego łańcucha prototypów;
  4. Ogranicz liczbę operacji i rozmiar bajtowy każdego value.

4. Atomowe stosowanie i wycofywanie

RFC 6902 wymaga atomowego wykonania, ale atomowość jest wymaganiem semantycznym, a nie domyślnym zachowaniem każdej biblioteki. Biblioteki takie jak fast-json-patch mutują dokument w miejscu, gdy mutateDocument jest prawdziwe, a porażka może zostawić częściowe zmiany.

import { applyPatch, validate } from 'fast-json-patch';

/**
 * Apply a patch immutably and return the original document on failure.
 * @param document The original document, which callers must not mutate
 * @param patch The JSON Patch operation array to apply
 * @returns The result, including a success flag and the new document
 */
function applySafely(document: unknown, patch: unknown[]) {
  const errors = validate(patch, document);

  if (errors) {
    return { ok: false, document };
  }

  try {
    const result = applyPatch(document, patch, false, false);
    return { ok: true, document: result.newDocument };
  } catch {
    return { ok: false, document };
  }
}

Trzy zalecenia implementacyjne:

  • Najpierw głęboka kopia lub klon strukturalny, stosuj na kopii i podmien referencję dopiero po sukcesie;
  • Nie polegaj na automatycznym cofnięciu biblioteki; zachowanie przy wyjątkach różni się między implementacjami;
  • Waliduj przed wykonaniem: validate sprawdza tylko strukturę, a konflikty semantyczne nadal wymagają test; żadne nie zastępuje drugiego.

Jeśli patch obejmuje wiele zasobów, na przykład zmianę zamówienia i jego inwentarza razem, atomowość JSON Patch obowiązuje tylko w obrębie jednego dokumentu. Między dokumentami potrzebujesz transakcji bazodanowej lub działań kompensacyjnych.

5. Idempotencja: PATCH nie jest naturalnie idempotentny

Semantyka HTTP definiuje PUT i DELETE jako idempotentne, ale PATCH niekoniecznie. Powtórzenie tego samego JSON Patch może dać zupełnie inne wyniki:

  • add /items/- dopisuje do końca tablicy za każdym razem, więc ponowienia tworzą duplikaty;
  • replace /version zmienia wersję 1 na 2 i przechodzi ponownie przy ponowieniu, mimo że operacja już została zastosowana;
  • remove /temp zawodzi przy drugim uruchomieniu, bo ścieżka już nie istnieje.

Istnieją trzy częste sposoby osiągnięcia idempotencji:

Podejście Jak działa Najlepsze do
Warunek wersji Pierwsza operacja to test /version, potem inkrementacja Zasobów z jawną wersją
Klucz idempotencji Klient wysyła Idempotency-Key; serwer cache’uje wynik Płatności, zamówień i innych zapisów
Patch stanu docelowego Użyj replace zamiast add, aby wynik zależał tylko od końcowej wartości Konfiguracji i pól statusu

Najgorszy wzorzec to patch mieszający add i remove przy założeniu, że ponowienia są bezpieczne. Jeśli patch cokolwiek dopisuje lub usuwa, musi być połączony z asercją wersji lub kluczem idempotencji.

6. Konflikty współbieżne: ETag i If-Match

Poza test wewnątrz patcha HTTP oferuje standardowy mechanizm: serwer zwraca ETag, a klient wysyła If-Match w następnym żądaniu.

GET /orders/42 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json

[{"op":"replace","path":"/status","value":"paid"}]

Jeśli zasób się zmienił, serwer zwraca 412 Precondition Failed, a klient pobiera go ponownie przed ponowieniem. If-Match: * znaczy „wykonaj, dopóki zasób istnieje”, co nie pasuje, gdy musisz zapobiegać nadpisaniom.

Różnica między test a If-Match:

  • If-Match jest sprawdzane w warstwie HTTP przed parsowaniem ciała, więc jest tańsze;
  • test jest sprawdzane w warstwie semantycznej patcha i potrafi asercować dowolne pole bez zależności od wsparcia ETag po stronie serwera;
  • Można je łączyć, ale nie implementuj tylko jednego i nie twierdź, że punkt końcowy jest bezpieczny przy współbieżności.

7. Różnice bibliotek i wybór

Implementacja Język Atomowość Wsparcie test Uwagi
fast-json-patch JavaScript Opcjonalna (domyślnie zwraca nowy dokument) Tak Szeroki ekosystem; uważaj na mutateDocument
jsonpatch Python Cofa przy wyjątkach Tak Blisko standardu, z jasnymi błędami
json-patch Go Działa na kopii Tak Często używane razem z JSON Merge Patch
zjsonpatch Java Tak Tak Wygodna integracja z Jacksonem

Nie wybieraj biblioteki wyłącznie na podstawie „wspiera wszystkie sześć operacji”. Potwierdź trzy rzeczy: czy porażka się cofa, czy test porównuje ściśle i czy add poza zakresem tablicy zgłasza błąd. Luźne implementacje sprawiają, że ten sam patch zachowuje się różnie w różnych środowiskach.

8. Lista kontrolna bezpieczeństwa przed wdrożeniem

  • [ ] Punkt końcowy deklaruje application/json-patch+json;
  • [ ] Patche są stosowane na kopii, a porażka nigdy nie zanieczyszcza oryginalnego dokumentu;
  • [ ] Każdy path i from w zewnętrznych patchach przechodzi kontrolę białej listy;
  • [ ] Niebezpieczne klucze, takie jak __proto__, constructor i prototype, są odrzucane;
  • [ ] Liczba operacji i rozmiar pojedynczej wartości są ograniczone;
  • [ ] test lub If-Match zapewnia kontrolę współbieżności;
  • [ ] Patche są zapisywane do logu audytu z aktorem, znacznikiem czasu i pełną tablicą operacji;
  • [ ] Granice transakcji są jawne, gdy patch obejmuje wiele dokumentów.

Patch jest z natury kompletnym opisem „co się zmieniło”, co czyni go naturalną notatką audytu. O kodach błędów i idempotencji na poziomie API przeczytasz w Projektowaniu API JSON. Jeśli patch pochodzi ze scalania dwóch gałęzi, potrzebujesz też semantyki konfliktów, omówionej w Scalaniu Git dla plików JSON.

FAQ

Który kod statusu powinna zwrócić zawiedziona operacja test?

Jeśli konflikt pochodzi z asercji wersji, zarówno 409 Conflict, jak i 412 Precondition Failed są do obrony. Preferuj 412, aby pozostać spójnym z If-Match, i dołącz obecną wersję do treści odpowiedzi, aby klient mógł ponowić.

Czy patch zawierający add i remove można bezpiecznie ponawiać?

Nie bezpośrednio. add na koniec tablicy duplikuje dane, a remove zawodzi przy drugim uruchomieniu, bo ścieżka zniknęła. Dodaj warunek wersji lub klucz idempotencji, aby ponowienia zostały rozpoznane jako ta sama operacja.

Czy JSON Patch może zagwarantować transakcje między dokumentami?

Nie. Atomowość RFC 6902 obowiązuje dla pojedynczego dokumentu JSON. Gdy w grę wchodzą zamówienia, inwentarz lub inne zasoby, użyj transakcji bazodanowych, kompensacji Saga albo kolejki komunikatów dla spójności.

Dlaczego patch nigdy nie może zapisywać do __proto__?

Bo większość implementacji JavaScript przechowuje dokument w zwykłych obiektach, a zapis __proto__ może zmienić łańcuch prototypów i wpłynąć na każdy późniejszy obiekt. Biała lista ścieżek jest najbardziej niezawodną obroną; nie polegaj tylko na wewnętrznym filtrowaniu biblioteki.

Jak uczynić żądanie PATCH idempotentnym?

Istnieją trzy częste podejścia: zacznij patch od test /version i zwiększ wersję przy sukcesie, pozwól serwerowi cache’ować wynik dla Idempotency-Key albo użyj replace zamiast add, aby wynik zależał tylko od końcowej wartości. Jeśli patch cokolwiek dopisuje lub usuwa, musi być połączony z asercją wersji lub kluczem idempotencji; nie można go po prostu ponawiać.

Czy używać jednocześnie test i If-Match?

Nie musisz używać obu, ale implementacja tylko jednego nie czyni punktu końcowego bezpiecznym przy współbieżności. If-Match jest sprawdzane w warstwie HTTP przed parsowaniem ciała, więc jest tańsze; test jest sprawdzane w warstwie semantycznej patcha i potrafi asercować dowolne pole bez zależności od wsparcia ETag po stronie serwera.