Gdy dwa dokumenty JSON się różnią: diff pozycyjny, diff po id albo najpierw normalizacja
Podsumowanie: Pięćdziesiąt rekordów staje się pięćdziesięcioma jeden, a diff zgłasza pięćdziesiąt jeden zmian. Omawia fałszywe alarmy diffu tekstowego, dopasowywanie tablic po id, normalizację przez jq i stosowalne patche.

Najważniejsze: najpierw normalizuj, potem porównuj — użyj jq -S lub stabilnej serializacji, aby wymazać kolejność kluczy, wcięcia i pisownię liczb, sortuj lub indeksuj tablice po kluczu biznesowym, gdy kolejność jest znacząca, a dopiero potem zamień diff w patch.
Różnica JSON to nie różnica tekstowa. Porównaj pliki jako łańcuchy, a zmiana kolejności kluczy zaświeci całą stronę; porównaj tablice po indeksie, a wstawienie jednego rekordu oznaczy pozostałe pięćdziesiąt jako zmienione.
Patch wdrożono do usługi zamówień, a masz tylko dwie odpowiedzi: pięćdziesiąt rekordów przed, pięćdziesiąt jeden po, z jednym nowym rekordem wstawionym pośrodku. Narzędzie diffu zgłasza pięćdziesiąt jeden zmian, ścianę czerwieni, a mimo to nie potrafisz powiedzieć, który rekord faktycznie się przesunął.
Narzędzie nie jest zepsute. „Porównywanie” na JSON-ie znaczy trzy różne rzeczy, a większość ludzi domyślnie sięga po najmniej użyteczną.
Odtworzenie fałszywego alarmu: jedno wstawienie, pięćdziesiąt jeden zmian
Trzydzieści linii Node 22: zbuduj pięćdziesiąt zamówień ({id, sku, qty}), następnie dopisz jedno na końcu i wstaw jedno na początku, licząc różnice przy wyrównaniu indeks-po-indeksie.
| Operacja | Rekordy | Zmiany zgłoszone po pozycji |
|---|---|---|
| Dopisanie jednego na końcu | 50 → 51 | 1 |
| Wstawienie jednego na początku | 50 → 51 | 51 |
To samo „jeden dodatkowy rekord”, pięćdziesiąt jeden razy więcej szumu. Powód jest prozaiczny: przy porównaniu po indeksie nowy element ląduje na pozycji 0 i każdy istniejący rekord przesuwa się o jeden slot w dół. Pozycja 0 versus pozycja 0, pozycja 1 versus pozycja 1 — żadna para nie jest równa.
Zbuduj Map kluczowaną po id, a te same dane zgłoszą dokładnie jeden wpis: $[id=ORD-999] added.
Ten eksperyment daje regułę ważniejszą niż jakakolwiek rekomendacja narzędzia: to, czy tablica jest uporządkowaną sekwencją czy nieuporządkowanym zbiorem, decyduje o tym, którego porównania powinieneś użyć.
Trzy sposoby, w jakie diff tekstowy kłamie o JSON
diff a.json b.json porównuje znaki. JSON niesie strukturę danych. Ta niezgodność produkuje trzy niezawodne rodzaje fałszywych alarmów.
1. Zmieniona kolejność kluczy obiektu
Sekcja 4 RFC 8259 mówi wyraźnie: obiekt to „nieuporządkowany zbiór par nazwa/wartość”. Te dwa dokumenty to ta sama wartość:
{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }
Diff liniowy uznaje wszystkie trzy linie za zmienione. Odwróciłem kolejność kluczy w obiekcie z dziesięcioma kluczami, sformatowałem oba i porównałem linia po linii: wszystkie dziesięć linii oznaczone, podczas gdy porównanie strukturalne mówi, że są równe. Stosunek szumu 10:0.
Dlaczego kolejność kluczy w ogóle się zmienia? Zmiana serializatora, uruchomienie formatera sortującego klucze, odbudowa obiektu ze słownika lub mapy haszującej, przejście między środowiskami językowymi — wystarczy którakolwiek, by identyczne dane wyglądały inaczej na dysku.
2. Białe znaki i wcięcia
{"a":1} i {\n "a": 1\n} parsują się do tej samej wartości. Diff tekstowy widzi nowe linie i spacje i oznacza wszystko.
3. Różna pisownia liczb i ucieczek
1.0 i 1, 1e2 i 100, "\u00e9" i "é" — ta sama wartość po parsowaniu, inny tekst po serializacji. Ten przypadek jest łagodniejszy, bo dotyka kilku linii, ale w przeglądach konfiguracji wystarcza, byś myślał, że liczba się zmieniła.
Przypadek odwrotny to coś, czego tekst nie widzi, a struktura widzi: 5 versus "5". Jedna dodatkowa para cudzysłowów, ale typ dryfował — zwykle błąd, a nie aktualizacja. Porównanie strukturalne zgłasza to jako modify bez wahania.
Trzy tryby porównania, trzy zadania
| Tryb | Co jest porównywane | Szum pochodzi z | Wyjście | Pasuje do |
|---|---|---|---|---|
| Diff tekstowy | Linie znaków | Kolejność kluczy, wcięcia, pisownia liczb | Numery wierszy, czerwień/zieleń | Pakietów lokalizacyjnych, danych testowych o stabilnej kolejności kluczy |
| Diff strukturalny | Sparsowane drzewa, wyrównane po ścieżce | Tablice wyrównane po indeksie | Ścieżki z add/remove/modify | Odpowiedzi API, konfiguracji, webhooków |
| Diff semantyczny | Drzewa plus klucze biznesowe | Tylko zły wybór klucza | Ścieżki z wykrywaniem move/insert | List rekordów, zamówień, inwentarza |
Diff strukturalny rozwiązuje warstwę „kolejność kluczy i formatowanie”. Diff semantyczny idzie krok dalej i rozwiązuje „tożsamość elementów tablicy”. Różnica nie leży w złożoności implementacji; leży w tym, czy jesteś gotów powiedzieć narzędziu, które pole jest paszportem rekordu.
Tablice to linia podziału
Zadaj jedno pytanie: czy kolejność tej tablicy sama w sobie jest danymi?
- Kolejność jest danymi: kroki migracji, rankingi wyszukiwania, punkty wykresu, łańcuchy zatwierdzeń. Zmiana pozycji to zmiana zachowania, więc porównanie pozycyjne jest poprawne.
- Kolejność jest bez znaczenia: tagi, zbiory identyfikatorów uprawnień, pozycje zamówienia, listy inwentarza. Wstawienie nie powinno wpływać na ocenę innych elementów.
Gdy kolejność jest bez znaczenia, indeksuj rekordy po kluczu, zamiast walić w nie po pozycji:
// Match by id: an insert or delete reports one entry, other items stay put
function diffById(before, after, path = '$') {
const out = [];
const beforeMap = new Map(before.map((item) => [item.id, item]));
const afterMap = new Map(after.map((item) => [item.id, item]));
for (const [id, value] of afterMap) {
if (!beforeMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'added' });
}
for (const [id, value] of beforeMap) {
if (!afterMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'removed' });
else if (JSON.stringify(value) !== JSON.stringify(afterMap.get(id))) {
out.push({ path: `${path}[id=${id}]`, type: 'changed' });
}
}
return out;
}
Dojrzałe biblioteki zamieniają to w opcję. jsondiffpatch używa LCS do znalezienia najdłuższego wspólnego podciągu, ale elementy obiektowe wewnątrz tablic są domyślnie dopasowywane po pozycji — oficjalna dokumentacja podkreśla, że musisz dostarczyć funkcję objectHash, aby dopasowywać je po treści. Początkujący często dochodzą do wniosku, że biblioteka jest głupia; w rzeczywistości nigdy nie przekazali klucza.
Wybór klucza też ma znaczenie. id to bezpieczny wybór. sku działa tylko wtedy, gdy SKU są unikalne. Haszowanie całego obiektu zamienia „jedno pole się zmieniło” w „jedno usunięte plus jedno dodane”, co jest gorsze niż porównanie po pozycji.
Najszybszy trik terminalowy: normalizacja przez jq
Jeśli nie chcesz biblioteki i potrzebujesz tylko spojrzenia, najpierw normalizuj, potem rób diff. jq -S sortuje klucze obiektów rekurencyjnie i stosuje spójne wcięcia:
# Normalize, then compare line by line; key order and indentation noise disappear
diff -u <(jq -S . before.json) <(jq -S . after.json)
# When the array is genuinely unordered, sort by key before comparing
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
<(jq -S '.items |= sort_by(.id)' after.json)
Uruchomiłem printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S . i dostałem {"a":1,"arr":[1,2],"b":2} — klucze posortowane, arr nietknięte. Zapamiętaj to: -S sortuje tylko klucze obiektów, nigdy elementy tablic. Albo sortuj tablice jawnie przez sort / sort_by, albo zaakceptuj szum pozycyjny.
Dwie pułapki:
jqnie potrafi edytować w miejscu; zapisuje do standardowego wyjścia. Użyj podstawienia procesu (<(...)) albo zapisz plik tymczasowy, zweryfikuj i zastąp;-rusuwa cudzysłowy z łańcuchów, więc wyjście może zawierać nowe linie lub znaki znaczące dla powłoki. Ujmij niezaufane dane w cudzysłowy, zanim przekażesz je dalej.
Co robi /diff w tej witrynie, a czego nie
Wbudowane narzędzie do porównywania JSON podąża za tym samym podejściem strukturalnym: obie strony są parsowane do drzew i wyrównywane rekurencyjnie po ścieżce, produkując path + type(add/remove/modify) + before/after. Kolejność kluczy obiektu nie wpływa na wynik, a wszystko działa w przeglądarce — wklejone ładunki nigdy nie są wysyłane.
Jego tablice są jednak wyrównywane po indeksie. To świadomy kompromis, a nie defekt:
- Dopisanie na końcu, zmiana tej samej pozycji lub zastąpienie w całości daje czyste wyjście;
- Wstawienie rekordu na początku lub pośrodku oznacza wszystko za nim jako
modify, a ty musisz dostrzec, że „faktycznie tylko jeden rekord jest nowy”.
Więc porównując listy zamówień, właściwym ruchem jest posortowanie obu stron po id przed wklejeniem. Posortowane wejście przypina punkt wstawienia i zwija szum do jednego wpisu. Bez instalacji i pokrywa większość codziennych przypadków.
Jeśli potrzebujesz „wygenerować stosowalny patch” albo „wykryć przesunięte elementy”, narzędzie w przeglądarce tego nie robi. To następna sekcja.
Zamiana diffu w patch: dwa formaty, nie mieszaj ich
Ludzie czytają diffy; programy stosują patche. JSON ma dwa formaty patch o zupełnie różnej semantyce.
| Wymiar | JSON Patch (RFC 6902) | JSON Merge Patch (RFC 7396) |
|---|---|---|
| Kształt | Tablica operacji z op, path, value |
Obiekt JSON o kształcie dokumentu docelowego |
| Tablice | Tak, po indeksie, /items/0, /- do dopisania |
Nie, tablice są zastępowane w całości |
| Usunięcie klucza | remove |
Tylko przez null, co nie potrafi usunąć pola, którego wartość legalnie wynosi null |
| Wyrażenie przesunięcia | move / copy |
Nie |
| Pasuje do | Zasobów zawierających tablice, logów audytu, aktualizacji atomowych | Częściowych aktualizacji zwykłych obiektów, dostarczania konfiguracji |
JSON Patch ma jedną operację, którą ludzie pomijają: test.
[
{ "op": "test", "path": "/version", "value": 3 },
{ "op": "replace", "path": "/status", "value": "shipped" }
]
Jeśli test zawiedzie, cały patch zostaje odrzucony. To optymistyczna współbieżność wpisana w protokół — bez dodatkowego nagłówka If-Match, by zagwarantować „napisałem tylko dlatego, że widziałem wersję 3”. Dla punktu końcowego PATCH jest to warte więcej niż jakakolwiek konwencja dokumentacyjna.
Ścieżki używają JSON Pointer (RFC 6901): ~0 escape'uje ~, ~1 escape'uje /. Klucze zawierające ukośniki (na przykład typy MIME jako klucze) muszą być escape'owane, inaczej wskaźnik trafi gdzie indziej. Szczegóły znajdują się w JSON Pointer i Patch.
Pozostaje jeden praktyczny problem: patche generowane automatycznie z diffu zwykle adresują tablice po indeksie. Indeksy są kruche w produkcji — jedno współbieżne wstawienie i /items/3 nie wskazuje już tego samego rekordu. Albo ogranicz wygenerowane patche do pól obiektowych, albo zwaliduj krytyczne pola przez test przed zastosowaniem.
Włóż porównanie do CI: asercuj wartości, nie tekst
Trzy zasady wystarczą:
- Asercuj sparsowane wartości w testach, nigdy wyjścia
JSON.stringify. W Jest używajtoEqual; w Pythonassert a == bpojson.loads. Kolejność kluczy i wcięcia nie biorą udziału. - Białej liście pola dynamiczne.
requestId,timestamp,traceIdróżnią się przy każdym uruchomieniu; bez wykluczeń test jest wiecznie czerwony.propertyFilterw jsondiffpatch iexclude_pathsw deepdiff istnieją właśnie po to. - Wypisuj ścieżki przy porażce, nie cały dokument. Nikt nie czyta 5 MB ładunku w logu;
$.data.items[id=ORD-042].qty: 2 → 3lokalizuje problem natychmiast.
Dryf konfiguracji działa tak samo: jq -S zarówno konfigurację staging, jak i produkcji, posortuj tablice po kluczu biznesowym, a cokolwiek zostanie, jest prawdziwą różnicą. Ta klasa problemów — te same dane wyglądające inaczej w różnych środowiskach — to zwykle niezdefiniowany kontrakt, o czym jest projektowanie kontraktu API.
FAQ
Czy kolejność kluczy liczy się jako różnica?
Nie. RFC 8259 definiuje obiekty jako zbiory nieuporządkowane, więc {"a":1,"b":2} i {"b":2,"a":1} to ta sama wartość. Każde narzędzie, które zgłasza zmianę kolejności jako zmianę, wykonuje porównanie tekstowe, a nie porównanie JSON.
Dlaczego wstawienie jednego rekordu oznacza każdy wpis jako zmieniony?
Bo narzędzie wyrównuje tablice indeks po indeksie. Nowy element siedzi na pozycji 0, każdy istniejący rekord przesuwa się o jeden, a żadne dwie pozycje się nie zgadzają. Zamiast porównywać pozycyjnie, posortuj lub zindeksuj po kluczu takim jak id.
Czy narzędzia porównywania online wysyłają moje dane?
Zależy od implementacji. Narzędzia renderowane po stronie serwera wysyłają ładunek do backendu, gdzie może trafić do logów; narzędzia po stronie klienta parsują w przeglądarce i nic nie wysyłają. Przed porównaniem produkcyjnych konfiguracji lub ładunków użytkowników potwierdź, że strona liczy lokalnie, albo odłącz sieć i spróbuj ponownie.
Czy wynik diffu można użyć bezpośrednio jako patch?
Nie bezpośrednio. Diff mówi, gdzie rzeczy się różnią; JSON Patch mówi, które operacje wykonać po kolei. Między nimi stoi warstwa konwersji. Wygenerowane patche też zwykle adresują tablice po indeksie, co psuje się przy współbieżności — dodaj operacje test przed zastosowaniem.
Jak porównywać pliki o rozmiarze kilkudziesięciu megabajtów?
Najpierw strumieniuj (jq -c zapisuje jeden rekord per linia) albo wyciągnij tylko interesujące cię poddrzewo, takie jak jq '.data.items'. Pełne rekurencyjne porównanie kilkudziesięciu megabajtów nadal mieści się w pamięci; powyżej stu megabajtów podziel je lub zindeksuj po kluczu. Konkretne liczby pamięci i czasu znajdziesz w wydajności dużego JSON.
Czy sortować tablice po id przed porównaniem?
Tak. Wbudowane narzędzie do porównywania JSON wyrównuje tablice po indeksie, więc wstawienie jednego rekordu na początku lub pośrodku oznacza wszystko za nim jako modify; posortowanie obu stron po id przed wklejeniem przypina punkt wstawienia i zwija szum do jednego wpisu. Wyjątkiem jest sytuacja, gdy kolejność tablicy sama jest danymi — kroki migracji, rankingi wyszukiwania, punkty wykresu — gdzie porównanie pozycyjne jest poprawne, a sortowanie błędne.