JSON Pointer, Patch und Merge Patch
Fazit: Drei kurze Spezifikationen bilden ein Änderungsprotokoll: Pointer lokalisiert, Patch bearbeitet präzise, Merge Patch führt teilweise zusammen. Eine falsche Wahl schmerzt bei Arrays und Nebenläufigkeit.

Kurz gesagt: JSON Pointer zum Lokalisieren nach Position und JSONPath zum Filtern oder Rekursieren verwenden; Array-Einfügen und -Löschen, null-Schreibvorgänge und Versionsprüfungen JSON Patch überlassen und Merge Patch für flache Konfiguration und Formularressourcen reservieren.
Lokalisieren und Ändern sind zwei verschiedene Dinge, und die Spezifikationen teilen sie in drei Dokumente auf. Der übliche Preis, sie zu verwechseln, ist „das ganze Array wurde ersetzt“, und niemand merkt es.
1. JSON Pointer: Die kürzeste Lokalisierungssyntax
RFC 6901 tut genau eine Sache: mit einem String auf einen Knoten in einem Dokument zeigen.
"" → the whole document
"/store" → the store member under the root
"/store/book" → one level deeper
"/store/book/0/title" → item 0 of the array
Drei Regeln:
- Der leere String zeigt auf den Wurzelknoten selbst, nicht auf einen „leeren Knoten“;
- Jedes
/trennt ein Referenz-Token; Arrays verwenden dezimale Indizes (führende Nullen sind nicht erlaubt,01ist also ungültig); - Innerhalb eines Tokens wird
~als~0und/als~1geschrieben –~vor/ersetzen, sonst endet man beim doppelten Escaping.
/**
* 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('');
}
Der Wert zeigt sich am besten, wenn der Schlüssel selbst / enthält: Der Pointer für {"a/b": 1} ist /a~1b, nicht /a/b (Letzteres bedeutet b unter a).
In einer URL ist außerdem Prozentkodierung nötig, weil # und ~ in einem Fragment besondere Bedeutung haben.
2. JSON Patch: Eine atomare Folge von sechs Operationen
RFC 6902 beschreibt Änderungen mit einem Array von Operationen, Medientyp 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" }
]
| Operation | Semantik | Grenze |
|---|---|---|
add |
Fügt ein Mitglied hinzu oder ein Array-Element ein | Der Array-Index darf der Länge entsprechen (Anhängen); - bedeutet das Ende |
remove |
Entfernt | Der Pfad muss existieren |
replace |
Ersetzt einen vorhandenen Wert | Der Pfad muss existieren (entspricht remove + add, erfordert aber zuerst einen vorhandenen Pfad) |
move |
Verschiebt | Ein Knoten kann nicht unter seinen eigenen Teilpfad verschoben werden |
copy |
Kopiert | Die Quelle muss existieren |
test |
Prüft Gleichheit | Scheitert sie, wird der ganze Patch nicht angewendet; dient der optimistischen Sperre |
Die zentrale Eigenschaft ist Atomarität: Scheitert eine Operation, wird der ganze Patch zurückgerollt, es gibt also keinen Zwischenzustand, in dem „die ersten drei angewendet und die vierte gescheitert ist“.
Das macht ihn zu einem großartigen PATCH-Request-Body.
2.1 test für optimistische Nebenläufigkeit nutzen
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
Der Client GETet zuerst die etag und schreibt sie in die test-Operation. Bei gleichzeitigen Schreibvorgängen scheitert der test der zweiten Anfrage, und der Server liefert
409 Conflict oder 412 Precondition Failed. Das ist weit sicherer als Last-Write-Wins und braucht keine verteilte Sperre.
2.2 Array-Operationen sind am fehleranfälligsten
// 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 an Array-Index n ist ein Einfügen, kein Überschreiben; ein Index außerhalb des Bereichs (n > length) muss einen Fehler auslösen.
Manche Implementierungen sind dabei nachsichtig, wodurch derselbe Patch auf verschiedenen Servern unterschiedlich wirkt.
3. Merge Patch: Ein Fragment, das wie das Ziel aussieht
RFC 7386 hat nur vier Regeln:
- Das Ziel ist ein Objekt und der Patch ist ein Objekt → rekursiv zusammenführen;
- Der Wert eines Schlüssels im Patch ist
null→ diesen Schlüssel im Ziel löschen; - Der Wert eines Schlüssels im Patch ist kein Objekt → direkt ersetzen;
- Der Patch selbst ist kein Objekt → das ganze Ziel ersetzen.
// Target
{ "a": "b", "c": { "d": "e", "f": "g" } }
// Patch
{ "a": "z", "c": { "f": null } }
// Result
{ "a": "z", "c": { "d": "e" } }
Der Medientyp ist application/merge-patch+json. Es liest sich wie ein „partielles Update“, was Missbrauch besonders leicht macht:
- Arrays lassen sich nur als Ganzes ersetzen.
list[2]löschen? Merge Patch kann das nicht; nulllässt sich nicht schreiben.nullist bereits mit der Bedeutung „Löschen“ belegt;- Zwischen „dieses Feld unangetastet lassen“ und „dieses Feld löschen“ gibt es keinen dritten Zustand (etwa ausdrücklich auf leer setzen).
4. Fähigkeitsmatrix: Welches wählen
| Anforderung | JSON Patch | Merge Patch |
|---|---|---|
| Ein bestimmtes Array-Element präzise ersetzen | ✓ | ✗ |
| Ein Element aus einem Array löschen | ✓ | ✗ |
| Mehrere Felder teilweise aktualisieren | ✓ (mehrere replace) | ✓ (am knappsten) |
Einen null-Wert schreiben |
✓ | ✗ |
| Nebenläufigkeitskontrolle (Versionsprüfung) | ✓ (test) | ✗ (nur über If-Match) |
| Lesbarkeit des Patches | Mittel | Gut (sieht aus wie die Daten selbst) |
| Implementierungskomplexität | Hoch | Niedrig |
| Passende Ressourcenformen | Tief verschachtelt, listenartig | Flache Konfiguration, Formulare |
Faustregeln:
- Formulare, Einstellungsseiten, Konfigurationsressourcen → Merge Patch; das Frontend sendet nur die geänderten Felder;
- Hinzufügen und Entfernen in Listen, Zustandsübergänge, alles mit Nebenläufigkeitsschutz → JSON Patch;
- Beide können koexistieren: verschiedene Endpunkte deklarieren verschiedene
Content-Types, und der Server verteilt nach Typ.
5. Umsetzungshinweise
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);
Vier leicht übersehene Punkte:
- Gemeinsame Objekte nicht in place verändern; ein gescheiterter Patch hinterlässt einen halb veränderten Zustand (es sei denn, die Implementierung garantiert Rollback);
- Pfadvalidierung: Patches von außen müssen jeden
path/fromvalidieren und Schlüssel wie__proto__undconstructorablehnen; - Größenlimits: die Zahl der Patch-Einträge begrenzen, damit ein einzelner Patch nicht Hunderttausende Operationen ausführt;
- Auditierung: Patches eignen sich naturgemäß für Audit-Logs – sie sind eine vollständige Beschreibung von „was sich geändert hat“.
6. Fehlersemantik
| Situation | Vorgeschlagener Statuscode | Erklärung |
|---|---|---|
| Der Patch ist kein Array / strukturell ungültig | 400 Bad Request |
Das Request-Body-Format ist falsch |
Der von path referenzierte Knoten existiert nicht |
422 Unprocessable Entity |
Syntaktisch gültig, aber semantisch nicht ausführbar |
Eine test-Operation ist gescheitert |
409 oder 412 |
Zeigt einen Versionskonflikt |
| Keine Berechtigung, diesen Pfad zu ändern | 403 Forbidden |
Nicht mit 404 verschleiern, außer Sie wollen die Existenz der Ressource bewusst verbergen |
400 von 422 zu unterscheiden ist wichtig: Ersteres heißt „ich kann deinen Patch nicht verstehen“, Letzteres „ich verstehe ihn, aber diese Daten erlauben diese Änderung nicht“.
7. Checkliste
- [ ] Der Endpunkt deklariert den richtigen
Content-Type(json-patch+json/merge-patch+json); - [ ] Das Anwenden des Patches ist atomar, und ein Fehler hinterlässt kein Teilresultat;
- [ ] Externe Patches werden gegen eine Pfad-Whitelist und ein Eintragszahl-Limit validiert;
- [ ] Die Nebenläufigkeitskontrolle verwendet
testoderIf-Match; - [ ] Patches werden ins Audit-Log geschrieben;
- [ ] Array-Szenarien wählen ausdrücklich JSON Patch; verlassen Sie sich nicht auf Merge Patch.
FAQ
Wie entscheide ich zwischen JSON Patch und Merge Patch?
Verwenden Sie JSON Patch, wenn Sie ein bestimmtes Array-Element ersetzen oder entfernen, einen null-Wert schreiben oder eine Version prüfen müssen; verwenden Sie Merge Patch für Formulare und Konfigurationsressourcen, die nur wenige Felder aktualisieren. Merge Patch kann kein einzelnes Array-Element löschen und kein null schreiben, weil null bereits Löschen bedeutet.
Warum ist add an Array-Index n kein Überschreiben?
Weil add in ein Array einfügt: Das Element an Index n und alles danach rücken nach hinten. Um ein vorhandenes Element zu ersetzen, müssen Sie replace verwenden, und der Pfad muss zuerst existieren. Ein Index außerhalb des Bereichs (n > length) sollte einen Fehler auslösen; nachsichtige Implementierungen lassen denselben Patch auf verschiedenen Servern unterschiedlich wirken.
Sollte ich das Originalobjekt in place verändern?
Nein. Ein gescheiterter Patch hinterlässt einen halb veränderten Zustand, es sei denn, die Implementierung garantiert Rollback. Wenden Sie den Patch auf eine tief kopierte oder strukturierte Klone an und tauschen Sie die Referenz nach Erfolg.