JSON Pointer, Patch i Merge Patch
Podsumowanie: Trzy krótkie specyfikacje tworzą protokół zmian: Pointer lokalizuje, Patch edytuje precyzyjnie, Merge Patch scala częściowo. Zły wybór boli przy tablicach i współbieżności.

Najważniejsze: używaj JSON Pointer do lokalizowania po pozycji, a JSONPath do filtrowania lub rekurencji; wstawianie i usuwanie elementów tablicy, zapis null oraz asercje wersji oddaj JSON Patch, a Merge Patch zarezerwuj dla płaskiej konfiguracji i zasobów formularzy.
Lokalizowanie i modyfikowanie to dwie różne rzeczy, a specyfikacje dzielą je na trzy dokumenty. Typową ceną ich mylenia jest „cała tablica została zastąpiona” i nikt tego nie zauważa.
1. JSON Pointer: najkrótsza składnia lokalizowania
RFC 6901 robi dokładnie jedną rzecz: wskazuje węzeł w dokumencie za pomocą łańcucha.
"" → the whole document
"/store" → the store member under the root
"/store/book" → one level deeper
"/store/book/0/title" → item 0 of the array
Trzy reguły:
- Pusty łańcuch wskazuje sam węzeł główny, a nie „pusty węzeł”;
- Każdy
/rozdziela jeden token referencyjny; tablice używają indeksów dziesiętnych (zera wiodące są niedozwolone, więc01jest niepoprawne); - Wewnątrz tokenu
~zapisuje się jako~0, a/jako~1— zastąp~przed/, inaczej skończysz z podwójną ucieczką.
/**
* Escapes a single reference token into a JSON Pointer segment.
* @param token Original key name or index
* @returns Escaped token
*/
function escapePointerToken(token: string): string {
return token.replace(/~/g, '~0').replace(/\//g, '~1');
}
/**
* Joins an array of key names into a JSON Pointer.
* @param tokens Array of key names or indices
* @returns Pointer string beginning with /
*/
function buildPointer(tokens: Array<string | number>): string {
return tokens.map((token) => `/${escapePointerToken(String(token))}`).join('');
}
Wartość widać najlepiej, gdy sam klucz zawiera /: wskaźnik dla {"a/b": 1} to /a~1b, a nie /a/b (to drugie znaczy b pod a).
Używany wewnątrz URL wymaga też kodowania procentowego, bo # i ~ mają znaczenie specjalne w fragmencie.
2. JSON Patch: atomowa sekwencja sześciu operacji
RFC 6902 opisuje zmiany tablicą operacji, typ nośny application/json-patch+json:
[
{ "op": "test", "path": "/version", "value": 7 },
{ "op": "replace", "path": "/name", "value": "新的名字" },
{ "op": "add", "path": "/tags/-", "value": "json" },
{ "op": "remove", "path": "/temp" },
{ "op": "copy", "from": "/name", "path": "/alias" },
{ "op": "move", "from": "/tags/0", "path": "/tags/1" }
]
| Operacja | Semantyka | Granica |
|---|---|---|
add |
Dodaje składową lub wstawia element tablicy | Indeks tablicy może równać się długości (dopisanie); - oznacza koniec |
remove |
Usuwa | Ścieżka musi istnieć |
replace |
Zastępuje istniejącą wartość | Ścieżka musi istnieć (równoważne remove + add, ale wymaga najpierw istnienia ścieżki) |
move |
Przesuwa | Nie można przenieść węzła pod jego własną podścieżkę |
copy |
Kopiuje | Źródło musi istnieć |
test |
Asercja równości | Jeśli zawiedzie, cały patch nie zostaje zastosowany; używane do optymistycznego blokowania |
Kluczową właściwością jest atomowość: jeśli dowolna operacja zawiedzie, cały patch się cofa, więc nie ma stanu pośredniego, w którym „pierwsze trzy się zastosowały, a czwarta zawiodła”.
To czyni go świetnym ciałem żądania PATCH.
2.1 Używanie test do optymistycznej współbieżności
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
Klient najpierw GET-uje etag i wpisuje go do operacji test. Przy współbieżnym zapisie test drugiego żądania zawodzi i serwer zwraca
409 Conflict lub 412 Precondition Failed. To znacznie bezpieczniejsze niż last-write-wins i nie wymaga rozproszonej blokady.
2.2 Operacje na tablicach są najbardziej podatne na błędy
// Want to insert at index 1
{ "op": "add", "path": "/items/1", "value": {...} } // Correct: insert; the original index 1 and everything after shift back
// Want to replace index 1
{ "op": "replace", "path": "/items/1", "value": {...} } // Correct: replace
add pod indeksem tablicy n to wstawienie, a nie nadpisanie; indeks poza zakresem (n > length) musi zgłosić błąd.
Niektóre implementacje są w tym łagodne, przez co ten sam patch zachowuje się różnie na różnych serwerach.
3. Merge Patch: fragment, który wygląda jak cel
RFC 7386 ma tylko cztery reguły:
- Cel jest obiektem i patch jest obiektem → scalaj rekurencyjnie;
- Wartość klucza w patchu to
null→ usuń ten klucz z celu; - Wartość klucza w patchu nie jest obiektem → zastąp bezpośrednio;
- Sam patch nie jest obiektem → cały cel zostaje zastąpiony.
// Target
{ "a": "b", "c": { "d": "e", "f": "g" } }
// Patch
{ "a": "z", "c": { "f": null } }
// Result
{ "a": "z", "c": { "d": "e" } }
Typ nośny to application/merge-patch+json. Czyta się jak „częściowa aktualizacja”, co czyni go szczególnie łatwym do niewłaściwego użycia:
- Tablice można tylko zastępować w całości. Chcesz usunąć
list[2]? Merge Patch tego nie potrafi; - Nie można zapisać
null.nulljest już zajęte przez znaczenie usunięcia; - Nie potrafi wyrazić trzeciego stanu między „zostaw to pole w spokoju” a „usuń to pole” (na przykład jawnego ustawienia go na puste).
4. Macierz możliwości: który wybrać
| Wymaganie | JSON Patch | Merge Patch |
|---|---|---|
| Precyzyjne zastąpienie konkretnego elementu tablicy | ✓ | ✗ |
| Usunięcie jednego elementu z tablicy | ✓ | ✗ |
| Częściowa aktualizacja kilku pól | ✓ (wiele replace) | ✓ (najzwięźlej) |
Zapis wartości null |
✓ | ✗ |
| Kontrola współbieżności (asercja wersji) | ✓ (test) | ✗ (tylko przez If-Match) |
| Czytelność patcha | Średnia | Dobra (wygląda jak same dane) |
| Złożoność implementacji | Wysoka | Niska |
| Pasujące kształty zasobów | Głęboko zagnieżdżone, listowe | Płaska konfiguracja, formularze |
Zasady praktyczne:
- Formularze, strony ustawień, zasoby konfiguracji → Merge Patch; frontend wysyła tylko zmienione pola;
- Dodawanie i usuwanie list, przejścia maszyny stanów, cokolwiek wymagającego ochrony współbieżności → JSON Patch;
- Oba mogą współistnieć: różne punkty końcowe deklarują różne
Content-Type, a serwer kieruje według typu.
5. Uwagi implementacyjne
import { applyPatch, compare } from 'fast-json-patch';
const document = { version: 7, items: [{ id: 1 }, { id: 2 }] };
const patch = compare(document, { version: 8, items: [{ id: 1 }] });
// test operations are validated by default; setting mutateDocument to true modifies in place, use with caution
const result = applyPatch(document, patch, true, false);
console.log(result.newDocument);
Cztery łatwe do przeoczenia punkty:
- Nie mutuj współdzielonych obiektów w miejscu; zawiedziony patch zostawia na wpół zmieniony stan (chyba że implementacja gwarantuje cofnięcie);
- Walidacja ścieżek: patche z zewnątrz muszą walidować każdy
path/fromi odrzucać klucze takie jak__proto__iconstructor; - Limity rozmiaru: ogranicz liczbę wpisów patcha, aby pojedynczy patch nie wykonywał setek tysięcy operacji;
- Audytowanie: patche naturalnie pasują do logów audytu — są kompletnym opisem „co się zmieniło”.
6. Semantyka błędów
| Sytuacja | Sugerowany kod statusu | Wyjaśnienie |
|---|---|---|
| Patch nie jest tablicą / jest strukturalnie niepoprawny | 400 Bad Request |
Format treści żądania jest zły |
Węzeł wskazany przez path nie istnieje |
422 Unprocessable Entity |
Składniowo poprawne, ale semantycznie niewykonalne |
Operacja test zawiodła |
409 lub 412 |
Wskazuje konflikt wersji |
| Brak uprawnień do modyfikacji tej ścieżki | 403 Forbidden |
Nie maskuj tego przez 404, chyba że świadomie ukrywasz istnienie zasobu |
Rozróżnianie 400 i 422 ma znaczenie: pierwsze znaczy „nie rozumiem twojego patcha”, drugie „rozumiem go, ale te dane nie pozwalają na taką zmianę”.
7. Lista kontrolna
- [ ] Punkt końcowy deklaruje właściwy
Content-Type(json-patch+json/merge-patch+json); - [ ] Stosowanie patcha jest atomowe, a porażka nie zostawia częściowego wyniku;
- [ ] Zewnętrzne patche są walidowane względem białej listy ścieżek i limitu liczby wpisów;
- [ ] Kontrola współbieżności używa
testlubIf-Match; - [ ] Patche są zapisywane do logu audytu;
- [ ] Scenariusze tablicowe jawnie wybierają JSON Patch; nie licz na Merge Patch.
FAQ
Jak zdecydować między JSON Patch a Merge Patch?
Użyj JSON Patch, gdy musisz zastąpić lub usunąć konkretny element tablicy, zapisać wartość null albo asercować wersję; użyj Merge Patch dla formularzy i zasobów konfiguracji, które aktualizują tylko kilka pól. Merge Patch nie potrafi usunąć pojedynczego elementu tablicy ani zapisać null, ponieważ null już znaczy usunięcie.
Dlaczego add pod indeksem tablicy n nie jest nadpisaniem?
Bo add wstawia do tablicy: element pod indeksem n i wszystko za nim przesuwa się w tył. Aby zastąpić istniejący element, musisz użyć replace, a ścieżka musi najpierw istnieć. Indeks poza zakresem (n > length) powinien zgłosić błąd; łagodne implementacje sprawiają, że ten sam patch zachowuje się różnie na różnych serwerach.
Czy modyfikować oryginalny obiekt w miejscu?
Nie. Zawiedziony patch zostawia na wpół zmieniony stan, chyba że implementacja gwarantuje cofnięcie. Zastosuj patch do głęboko skopiowanej lub sklonowanej strukturalnie kopii, a po sukcesie podmien referencję.