JSON Toolkit

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.

Początkujący~6 min czytaniaOpublikowano 2026-10-30Zaktualizowano 2026-10-30
Okładka artykułu o porównywaniu dwóch dokumentów JSON

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:

  • jq nie potrafi edytować w miejscu; zapisuje do standardowego wyjścia. Użyj podstawienia procesu (<(...)) albo zapisz plik tymczasowy, zweryfikuj i zastąp;
  • -r usuwa 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ą:

  1. Asercuj sparsowane wartości w testach, nigdy wyjścia JSON.stringify. W Jest używaj toEqual; w Python assert a == b po json.loads. Kolejność kluczy i wcięcia nie biorą udziału.
  2. Białej liście pola dynamiczne. requestId, timestamp, traceId różnią się przy każdym uruchomieniu; bez wykluczeń test jest wiecznie czerwony. propertyFilter w jsondiffpatch i exclude_paths w deepdiff istnieją właśnie po to.
  3. 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 → 3 lokalizuje 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.