Nach der Umstellung von Benutzereinstellungen auf Teilaktualisierungen sieht ein typischer Fehler so aus: Die Anfrage ist erfolgreich, doch alte Felder bleiben erhalten. Der Client versteht null als „ignorieren“, der Server dagegen als Löschanweisung. Mit einer Reihe von Vertragstests in Swift im Build-Job auf einem Cloud-Mac lässt sich über die erfolgreiche Kompilierung hinaus prüfen, ob beide Seiten den Inhalt einer Aktualisierung gleich interpretieren. Hier geht es ausschließlich um die Dokumenttransformation durch JSON Merge Patch – ohne Verbindung zu einer produktiven API und ohne Simulator.
Vier Regeln für das Aktualisierungsprotokoll festlegen
Die Regeln von JSON Merge Patch sind in RFC 7396 definiert. Ein Objekt-Patch wird Feld für Feld verarbeitet: Nicht enthaltene Felder bleiben erhalten, Felder mit dem Wert null werden gelöscht, alle anderen Werte werden rekursiv angewendet. Ist der Patch kein Objekt, ersetzt er den gesamten Zielwert. Arrays werden deshalb nicht indexweise zusammengeführt.
Halten Sie zunächst ausdrücklich fest, dass die Schnittstelle application/merge-patch+json verwendet. Aus der HTTP-Methode allein lässt sich das Protokoll nicht ableiten. Eine gewöhnliche JSON-Anfrage kann auch mit der Methode PATCH völlig anderen fachlichen Regeln folgen.
In einem Objektfeld bedeutet
nulldas Löschen des Feldes. Ein Patch mitnullan der Wurzel setzt dagegen das gesamte Dokument auf null. Muss die Anwendung explizite Nullwerte speichern können, darfnullan derselben Stelle nicht zugleich für Zuweisung und Löschen stehen.
Unterscheiden Sie außerdem zwischen einem leeren Objekt und einem leeren Array: Auf ein bestehendes Objekt angewendet, lässt {} dessen Inhalt unverändert. [] ersetzt den Zielwert hingegen vollständig durch ein leeres Array. Ist der ursprüngliche Zielwert kein Objekt, ergibt ein Patch mit einem leeren Objekt ein leeres Objekt.
Einen eigenständigen Merge-Kern in Swift schreiben
Legen Sie im Repository die Datei ci/merge-patch-check.swift an und fügen Sie den folgenden Code ein. Foundation übernimmt das Parsen von JSON. Die aktivierte Unterstützung für Fragmente erlaubt auch Zahlen, Zeichenketten und null an der Wurzel, statt nur Objekte und Arrays zu akzeptieren.
import Foundation
enum PatchError: Error {
case depthExceeded
case mismatch(String)
}
func parse(_ text: String) throws -> Any {
try JSONSerialization.jsonObject(
with: Data(text.utf8),
options: [.fragmentsAllowed]
)
}
func merge(
_ target: Any,
_ patch: Any,
depth: Int = 0
) throws -> Any {
guard depth <= 64 else {
throw PatchError.depthExceeded
}
guard let fields = patch as? [String: Any] else {
return patch
}
var result = target as? [String: Any] ?? [:]
for (key, value) in fields {
if value is NSNull {
result.removeValue(forKey: key)
} else {
result[key] = try merge(
result[key] ?? NSNull(),
value,
depth: depth + 1
)
}
}
return result
}
func canonical(_ value: Any) throws -> Data {
try JSONSerialization.data(
withJSONObject: value,
options: [.sortedKeys, .fragmentsAllowed]
)
}
func check(
_ name: String,
_ target: String,
_ patch: String,
_ expected: String
) throws {
let actual = try merge(try parse(target), try parse(patch))
let expectedValue = try parse(expected)
guard try canonical(actual) == canonical(expectedValue) else {
throw PatchError.mismatch(name)
}
}
Die Tiefenbegrenzung dient in dieser Implementierung der Ressourcenkontrolle und ist keine Vorgabe des Protokolls. Der Wurzelaufruf beginnt mit der Tiefe null; ein rekursiver Aufruf mit einer Tiefe über 64 schlägt fehl. Am Eingang produktiver Anfragen sollten zusätzlich die Größe des Nachrichtenkörpers in Bytes und die gesamte Verschachtelungstiefe begrenzt werden: Arrays werden direkt ersetzt, sodass ihre Verschachtelung durch diese Objekt-Rekursion nicht vollständig geprüft wird.
Dieser Kern akzeptiert ausschließlich Werte aus geparstem JSON, keine beliebigen Anwendungsobjekte. Legen Sie Datumswerte oder Binärdaten nicht direkt in einem Dictionary ab, in der Erwartung, die Serialisierung werde die Typen automatisch korrigieren.
Löschen und Ersetzen mit festen Testvektoren prüfen
Ergänzen Sie die folgenden Prüfungen in derselben Datei. Jeder Testfall prüft eine zentrale Regel. Der Name eines fehlgeschlagenen Tests lässt sich damit im Code-Review direkt der entsprechenden Protokollregel zuordnen.
try check(
"delete-and-preserve",
#"{"theme":"dark","badge":3}"#,
#"{"badge":null}"#,
#"{"theme":"dark"}"#
)
try check(
"nested-object",
#"{"ui":{"theme":"dark","scale":2}}"#,
#"{"ui":{"scale":3}}"#,
#"{"ui":{"theme":"dark","scale":3}}"#
)
try check(
"replace-array",
#"{"roles":["reader","writer"]}"#,
#"{"roles":["reader"]}"#,
#"{"roles":["reader"]}"#
)
try check("root-null", #"{"enabled":true}"#, "null", "null")
try check("empty-object", "[1,2]", "{}", "{}")
try check("empty-array", #"{"items":[1]}"#, #"{"items":[]}"#, #"{"items":[]}"#)
try check("missing-delete", "{}", #"{"absent":null}"#, "{}")
try check("scalar-root", #"{"count":1}"#, "7", "7")
Vor dem Vergleich werden nur die Objektschlüssel sortiert; die Reihenfolge der Array-Elemente bleibt unverändert. Der Vergleich serialisierter Daten eignet sich für diese festen Testfälle. Er ist jedoch kein allgemeines Verfahren zur Normalisierung von Zahlen und sollte auch nicht zur Berechnung sprachübergreifender Signaturen verwendet werden.
Modelliert der Client ein Feld als gewöhnlichen optionalen Wert, können beim Kodieren sowohl „Feld löschen“ als auch „Feld nicht aktualisieren“ dazu führen, dass das Feld weggelassen wird. Die Schicht zur Anfrageerstellung muss deshalb drei Zustände ausdrücklich unterscheiden: unverändert lassen, löschen und einen Wert zuweisen. Prüfen Sie das erzeugte JSON separat; Tests des Merge-Kerns allein belegen nicht, dass die Anfrage korrekt kodiert wird.
Ressourcengrenzen und wiederholte Anwendung prüfen
Wird derselbe Patch erneut angewendet, muss das Ergebnis unverändert bleiben. Damit lassen sich Implementierungen aufdecken, die Arrays fälschlich erweitern oder Zahlenwerte addieren. Die Prüfung beweist allerdings nicht, dass die gesamte Schnittstelle frei von Nebenwirkungen wie mehrfach versendeten Benachrichtigungen ist.
Fügen Sie die folgenden Prüfungen hinzu, um sicherzustellen, dass zu tief verschachtelte Objekte einen ausdrücklichen Fehler auslösen, statt unbemerkt abgeschnitten zu werden:
let target = try parse(#"{"ui":{"scale":1}}"#)
let patch = try parse(#"{"ui":{"scale":2},"old":null}"#)
let once = try merge(target, patch)
let twice = try merge(once, patch)
guard try canonical(once) == canonical(twice) else {
throw PatchError.mismatch("idempotence")
}
var deep: Any = 1
for _ in 0..<66 {
deep = ["child": deep]
}
do {
_ = try merge(NSNull(), deep)
throw PatchError.mismatch("depth-limit")
} catch PatchError.depthExceeded {
}
Ein weiterer leicht übersehener Fall ist ungültiges JSON. Schlägt das Parsen fehl, muss die Anfrageverarbeitung enden. Der Fehler darf nicht abgefangen werden, um anschließend mit einem leeren Dictionary weiterzumachen: Sonst könnten beschädigte Eingaben als gültige Aktualisierung behandelt werden. Auch der Umgang mit mehrfach vorkommenden Schlüsseln muss am Eingang gesondert festgelegt werden. Verlassen Sie sich nicht darauf, dass verschiedene Parser zufällig denselben Wert beibehalten.
In die Commit-Prüfungen auf dem Cloud-Mac einbinden
Prüfen Sie zunächst, ob die aktuelle Toolchain verfügbar ist, und führen Sie anschließend im Stammverzeichnis des Repositorys diese Befehle aus:
set -eu
xcrun --find swift
xcrun swift ci/merge-patch-check.swift
Das Skript verwendet keine Assertions, die durch Optimierung deaktiviert werden könnten. Jeder fehlgeschlagene Vergleich löst einen Fehler aus und führt zu einem Exit-Status ungleich null. Lassen Sie das Skript vor dem zeitaufwendigen App-Build laufen. Es genügt, die Namen der Testfälle und den Exit-Status festzuhalten; echte Benutzereinstellungen gehören nicht ins Log.
Prüfen Sie die Umsetzung auf drei Ebenen: Der Kern durchläuft die festen Testvektoren, die Schicht zur Anfrageerstellung prüft die tatsächliche Kodierung, und die API-Integrationstests gleichen das Dokument nach der serverseitigen Anwendung ab. Auch der Cache der mobilen App sollte mit denselben Testfällen geprüft werden. So verhindern Sie, dass die Oberfläche zunächst nach falschen Regeln optimistisch aktualisiert und später vom Serverergebnis überschrieben wird. Im abschließenden Review zählt die Bedeutung der Aktualisierung jedes einzelnen Feldes – nicht nur, ob eine Anfrage erfolgreich war.
Häufig gestellte Fragen
Kann ein Objektfeld auf null gesetzt werden?
In einem Patch-Objekt bedeutet null, dass das Feld gelöscht wird. Soll das Feld mit dem Wert null erhalten bleiben, ist ein anderes Protokoll oder eine explizite Operation nötig. Ein null-Patch an der Wurzel ersetzt dagegen das gesamte Dokument durch null.
Werden Array-Elemente anhand ihrer Position zusammengeführt?
Nein. Ein Array im Patch ersetzt das vorhandene Array vollständig, auch wenn das neue Array leer ist. Änderungen anhand von Element-IDs benötigen eine eigene fachliche Operation.
Den ganzen Mac nur für Builds Ihres Teams nutzen
Mieten Sie einen exklusiven Apple Silicon M4 Mac mini und behalten Sie Ihre eigene Toolchain und Runner-Konfiguration. Abrechnung pro Tag, Woche, Monat oder Quartal. Die aktuelle Verfügbarkeit sehen Sie in Echtzeit in der Konsole.
Build-Node mieten