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.

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
PATCHnie 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ść
testprzed 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ą;
valueztestmusi mieć dokładnie ten sam typ JSON co wartość dokumentu.1i"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:
- Zezwól patchom modyfikować tylko pola biznesowe, takie jak
/statuslub/items/0/quantity; - Waliduj każdy
pathifromwyrażeniem regularnym lub prefiksem ścieżki, odrzucając nieoczekiwane poziomy inne niż indeksy tablic; - Parsuj JSON do kontenerów
Object.create(null)lubMap, aby zmniejszyć wpływ zanieczyszczonego łańcucha prototypów; - 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:
validatesprawdza 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 /versionzmienia wersję 1 na 2 i przechodzi ponownie przy ponowieniu, mimo że operacja już została zastosowana;remove /tempzawodzi 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-Matchjest sprawdzane w warstwie HTTP przed parsowaniem ciała, więc jest tańsze;testjest 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
pathifromw zewnętrznych patchach przechodzi kontrolę białej listy; - [ ] Niebezpieczne klucze, takie jak
__proto__,constructoriprototype, są odrzucane; - [ ] Liczba operacji i rozmiar pojedynczej wartości są ograniczone;
- [ ]
testlubIf-Matchzapewnia 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.