Коли два документи JSON різняться: позиційний diff, diff за id чи спершу нормалізація
Головне: Пʼятдесят записів стають пʼятдесятьма одним, і diff повідомляє про пʼятдесят одну зміну. Охоплює хибні сигнали текстового diff, зіставлення масивів за id, нормалізацію jq і придатні патчі.

Коротко: спершу нормалізуйте, потім порівнюйте — використовуйте jq -S або стабільну серіалізацію, щоб стерти порядок ключів, відступи й написання чисел, сортуйте або індексуйте масиви за бізнес-ключем, коли порядок не має сенсу, і лише потім перетворюйте diff на патч.
JSON-відмінність — не текстова відмінність. Порівняйте файли як рядки — і перевпорядкування ключів засвітить усю сторінку; порівняйте масиви за індексом — і вставлення одного запису позначить інші пʼятдесят як змінені.
Патч доставлено в сервіс замовлень, а у вас є лише дві відповіді: пʼятдесят записів до, пʼятдесят один після, з одним новим записом, вставленим посередині. Інструмент diff повідомляє про пʼятдесят одну зміну, стіну червоного, і ви все одно не можете сказати, який запис насправді перемістився.
Інструмент не зламаний. «Порівняння» на JSON означає три різні речі, і більшість людей за замовчуванням хапаються за найменш корисну.
Відтворення хибної тривоги: одне вставлення, пʼятдесят одна зміна
Тридцять рядків Node 22: побудуйте пʼятдесят замовлень ({id, sku, qty}), потім додайте одне в кінець і вставте одне на початок, рахуючи відмінності з вирівнюванням індекс за індексом.
| Операція | Записи | Зміни, повідомлені за позицією |
|---|---|---|
| Додати одне в кінець | 50 → 51 | 1 |
| Вставити одне на початок | 50 → 51 | 51 |
Те саме «один зайвий запис», пʼятдесят один раз шуму. Причина буденна: коли порівнюєте за індексом, новий елемент потрапляє на позицію 0, і кожен наявний запис зсувається на один слот. Позиція 0 проти позиції 0, позиція 1 проти позиції 1 — жодна пара не рівна.
Побудуйте натомість Map із ключем id — і ті самі дані повідомлять рівно один запис: $[id=ORD-999] added.
Цей експеримент дає правило, важливіше за будь-яку рекомендацію інструменту: чи масив — це впорядкована послідовність, чи невпорядкована множина, вирішує, яке порівняння вам слід використовувати.
Три способи, якими текстовий diff бреше про JSON
diff a.json b.json порівнює символи. JSON несе структуру даних. Ця невідповідність дає три надійні види хибних сигналів.
1. Перевпорядковані ключі обʼєкта
Розділ 4 RFC 8259 прямо каже: обʼєкт — це «невпорядкована колекція пар імʼя/значення». Ці два документи — те саме значення:
{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }
Рядковий diff називає всі три рядки зміненими. Я обернув порядок ключів обʼєкта з десятьма ключами, прикрасив обидва й порівняв рядок за рядком: усі десять рядків позначено, тоді як структурне порівняння каже, що вони рівні. Співвідношення шуму 10:0.
Чому порядок ключів узагалі змінюється? Перемикання серіалізаторів, запуск форматувальника з відсортованими ключами, перебудова обʼєкта зі словника чи хеш-мапи, перехід між середовищами виконання мов — будь-чого з цього достатньо, щоб ідентичні дані виглядали по-різному на диску.
2. Пробільні символи й відступи
{"a":1} і {\n "a": 1\n} розбираються в те саме значення. Текстовий diff бачить переведення рядків і пробіли й позначає все.
3. Різне написання чисел і escape
1.0 і 1, 1e2 і 100, "\u00e9" і "é" — те саме значення після розбору, різний текст після серіалізації. Цей випадок мʼякший, бо торкається небагатьох рядків, але в рев'ю конфігурацій його достатньо, щоб ви подумали, ніби число змінилося.
Зворотний випадок — те, чого текст не бачить, а структура бачить: 5 проти "5". Одна зайва пара лапок, але тип зсунувся — зазвичай це баг, а не оновлення. Структурне порівняння повідомляє це як modify без вагань.
Три режими порівняння, три роботи
| Режим | Що порівнюється | Звідки шум | Вивід | Підходить |
|---|---|---|---|---|
| Текстовий diff | Рядки символів | Порядок ключів, відступи, написання чисел | Номери рядків, червоне/зелене | Пакети локалізації, тестові дані зі стабільним порядком ключів |
| Структурний diff | Розібрані дерева, вирівняні за шляхом | Масиви, вирівняні за індексом | Шляхи з add/remove/modify | Відповіді API, конфігурації, webhooks |
| Семантичний diff | Дерева плюс бізнес-ключі | Лише поганий вибір ключа | Шляхи з виявленням move/insert | Списки записів, замовлення, запаси |
Структурний diff розвʼязує шар «порядок ключів і форматування». Семантичний diff іде на крок далі й розвʼязує «ідентичність елементів масиву». Різниця не в складності реалізації; вона в тому, чи готові ви сказати інструменту, яке поле є паспортом запису.
Масиви — роздільна лінія
Задайте одне питання: чи порядок цього масиву сам є даними?
- Порядок — це дані: кроки міграції, рейтинги пошуку, точки графіка, ланцюги затвердження. Зміна позиції — зміна поведінки, тож позиційне порівняння правильне.
- Порядок безглуздий: теги, набори ID дозволів, позиції замовлення, списки запасів. Вставлення не має впливати на те, як судять інші елементи.
Коли порядок безглуздий, індексуйте записи за ключем, а не бийте їх за позицією:
// Зіставлення за id: вставлення чи видалення повідомляє один запис, інші залишаються на місці
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;
}
Зрілі бібліотеки перетворюють це на опцію. jsondiffpatch використовує LCS, щоб знайти найдовшу спільну підпослідовність, але обʼєктні елементи всередині масивів за замовчуванням зіставляються за позицією — офіційна документація наголошує, що ви маєте надати функцію objectHash, щоб зіставляти їх за вмістом. Новачки часто роблять висновок, що бібліотека дурна; насправді вони ніколи не передавали ключ.
Вибір ключа теж важливий. id — безпечний вибір. sku працює, лише коли SKU унікальні. Хешування всього обʼєкта перетворює «одне поле змінилося» на «одне видалено плюс одне додано», що гірше за порівняння за позицією.
Найшвидший трюк у терміналі: нормалізація через jq
Якщо не хочете бібліотеку й треба лише поглянути, спершу нормалізуйте, потім робіть diff. jq -S рекурсивно сортує ключі обʼєктів і застосовує узгоджені відступи:
# Нормалізуйте, потім порівняйте рядок за рядком; шум порядку ключів і відступів зникає
diff -u <(jq -S . before.json) <(jq -S . after.json)
# Коли масив справді невпорядкований, сортуйте за ключем перед порівнянням
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
<(jq -S '.items |= sort_by(.id)' after.json)
Я запустив printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S . і отримав {"a":1,"arr":[1,2],"b":2} — ключі відсортовані, arr недоторканий. Запамʼятайте: -S сортує лише ключі обʼєктів, ніколи елементи масивів. Або сортуйте масиви явно через sort / sort_by, або приймайте позиційний шум.
Дві пастки:
jqне може редагувати на місці; він пише в стандартний вивід. Використовуйте підстановку процесу (<(...)) або запишіть тимчасовий файл, перевірте, потім замініть;-rзнімає лапки з рядків, тож вивід може містити переведення рядків або значущі для оболонки символи. Беріть недовірені дані в лапки, перш ніж передавати далі.
Що робить /diff на цьому сайті й чого він не робить
Вбудований інструмент порівняння JSON дотримується того самого структурного підходу: обидві сторони розбираються в дерева й рекурсивно вирівнюються за шляхом, даючи path + type(add/remove/modify) + before/after. Порядок ключів обʼєкта не впливає на результат, а все працює в браузері — вставлені навантаження ніколи не завантажуються.
Його масиви, однак, вирівнюються за індексом. Це навмисний компроміс, а не дефект:
- Додавання в кінець, зміна тієї самої позиції чи заміна цілком дають чистий вивід;
- Вставлення запису на початок чи посередині позначає все після нього як
modify, і вам доводиться самостійно помічати, що «насправді новий лише один запис».
Тож порівнюючи списки замовлень, правильний хід — відсортувати обидві сторони за id перед вставленням. Відсортований вхід фіксує точку вставлення й згортає шум до одного запису. Без встановлення, і це покриває більшість повсякденних випадків.
Якщо потрібне «згенерувати придатний патч» або «виявити переміщені елементи», інструмент на сторінці цього не робить. Це наступний розділ.
Перетворення diff на патч: два формати, не змішуйте їх
Люди читають diff; програми застосовують патчі. У JSON є два формати патчів із цілком різною семантикою.
| Вимір | JSON Patch (RFC 6902) | JSON Merge Patch (RFC 7396) |
|---|---|---|
| Форма | Масив операцій з op, path, value |
Обʼєкт JSON за формою цільового документа |
| Масиви | Так, за індексом, /items/0, /- для додавання |
Ні, масиви замінюються цілком |
| Видалити ключ | remove |
Лише через null, що не може видалити поле, чиє значення законно null |
| Виразити переміщення | move / copy |
Ні |
| Підходить | Ресурси з масивами, журнали аудиту, атомарні оновлення | Часткові оновлення звичайних обʼєктів, доставка конфігурації |
У JSON Patch є одна операція, яку люди пропускають: test.
[
{ "op": "test", "path": "/version", "value": 3 },
{ "op": "replace", "path": "/status", "value": "shipped" }
]
Якщо test зазнає збою, весь патч відхиляється. Це оптимістична конкурентність, вбудована в протокол — без додаткового заголовка If-Match, щоб гарантувати «я писав, лише бо бачив версію 3». Для кінцевої точки PATCH це вартує більше за будь-яку документаційну конвенцію.
Шляхи використовують JSON Pointer (RFC 6901): ~0 екранує ~, ~1 екранує /. Ключі, що містять скісні риски (наприклад, медіатипи як ключі), треба екранувати, інакше покажчик вкаже в інше місце. Деталі — в JSON Pointer і Patch.
Залишається одна практична проблема: патчі, автоматично згенеровані з diff, зазвичай адресують масиви за індексом. Індекси крихкі у виробництві — одне конкурентне вставлення, і /items/3 більше не вказує на той самий запис. Або обмежте згенеровані патчі полями обʼєктів, або перевірте критичні поля через test перед застосуванням.
Впровадіть порівняння в CI: перевіряйте значення, а не текст
Достатньо трьох правил:
- У тестах перевіряйте розібрані значення, ніколи вивід
JSON.stringify. У Jest використовуйтеtoEqual; у Python —assert a == bпісляjson.loads. Порядок ключів і відступи не беруть участі. - Додайте динамічні поля в білий список.
requestId,timestamp,traceIdрізняться при кожному запуску; без виключень тест постійно червоний.propertyFilterу jsondiffpatch іexclude_pathsу deepdiff існують для цього. - Виводьте шляхи у разі збою, а не весь документ. Ніхто не читає 5 МБ навантаження в логу;
$.data.items[id=ORD-042].qty: 2 → 3локалізує проблему миттєво.
Дрейф конфігурації працює так само: проженіть jq -S над конфігураціями staging і продакшн, сортуйте масиви за бізнес-ключем, і все, що залишиться, — справжня відмінність. Цей клас проблем — ті самі дані, що виглядають по-різному в різних середовищах — зазвичай є невизначеним контрактом, про що й проєктування контракту API.
Часті питання
Чи вважається порядок ключів відмінністю?
Ні. RFC 8259 визначає обʼєкти як невпорядковані колекції, тож {"a":1,"b":2} і {"b":2,"a":1} — те саме значення. Будь-який інструмент, що повідомляє про перевпорядкування як зміну, робить текстове порівняння, а не порівняння JSON.
Чому вставлення одного запису позначає кожен запис як змінений?
Бо інструмент вирівнює масиви індекс за індексом. Новий елемент сидить на позиції 0, кожен наявний запис зсувається на один, і жодні дві позиції не збігаються. Сортуйте або індексуйте за ключем на кшталт id перед порівнянням, замість порівнювати позиційно.
Чи онлайн-інструменти порівняння завантажують мої дані?
Залежить від реалізації. Серверні інструменти надсилають навантаження на бекенд, де воно може потрапити в логи; клієнтські інструменти розбирають у браузері й нічого не завантажують. Перш ніж порівнювати виробничі конфігурації чи користувацькі навантаження, підтвердьте, що сторінка обчислює локально, або відключіть мережу й спробуйте знову.
Чи можна використовувати результат diff безпосередньо як патч?
Не безпосередньо. Diff каже, де що різниться; JSON Patch каже, які операції виконувати по порядку. Між ними лежить шар перетворення. Згенеровані патчі також схильні адресувати масиви за індексом, що ламається за конкурентності — додайте операції test перед застосуванням.
Як порівнювати файли в десятки мегабайт?
Спершу потоково (jq -c пише один запис на рядок) або вилучіть лише піддерево, яке вас цікавить, наприклад jq '.data.items'. Повне рекурсивне порівняння десятків мегабайт усе ще вміщується в памʼять; понад сто мегабайт — наріжте або індексуйте за ключем. Про конкретні цифри памʼяті й часу дивіться продуктивність великого JSON.
Чи сортувати масиви за id перед порівнянням?
Так. Вбудований інструмент порівняння JSON вирівнює масиви за індексом, тож вставлення одного запису на початок чи посередині позначає все після нього як modify; сортування обох сторін за id перед вставленням фіксує точку вставлення й згортає шум до одного запису. Виняток — коли порядок масиву сам є даними — кроки міграції, рейтинги пошуку, точки графіка — де позиційне порівняння правильне, а сортування неправильне.