Когда два документа 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, конфигурации, вебхуки |
| Семантический 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
Если вы не хотите библиотеку и просто нужен взгляд, сначала нормализуйте, затем сравнивайте. 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 экранирует /. Ключи, содержащие слэши (например MIME-типы в роли ключей), должны быть экранированы, иначе pointer попадает куда-то ещё. Подробности в JSON Pointer и Patch.
Остаётся одна практическая проблема: патчи, автоматически генерируемые из diff, обычно адресуют массивы по индексу. Индексы хрупки в продакшене — одна параллельная вставка, и /items/3 больше не указывает на ту же запись. Либо ограничьте генерируемые патчи полями объектов, либо валидируйте критичные поля через test перед применением.
Поместите сравнение в CI: проверяйте значения, а не текст
Достаточно трёх правил:
- В тестах проверяйте разобранные значения, никогда вывод
JSON.stringify. В Jest используйтеtoEqual; в Pythonassert a == bпослеjson.loads. Порядок ключей и отступы не участвуют. - Внесите динамические поля в белый список.
requestId,timestamp,traceIdразличаются при каждом запуске; без исключений тест вечно красный.propertyFilterв jsondiffpatch иexclude_pathsв deepdiff существуют для этого. - При сбое печатайте пути, а не весь документ. Никто не читает 5 МБ полезной нагрузки в логе;
$.data.items[id=ORD-042].qty: 2 → 3локализует проблему мгновенно.
Дрейф конфигурации работает так же: jq -S обе конфигурации, staging и production, сортируйте массивы по бизнес-ключу, и всё оставшееся является реальным различием. Этот класс проблем — одни данные, выглядящие по-разному в разных средах — обычно является неопределённым контрактом, о чём и проектирование контрактов 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 перед вставкой фиксирует точку вставки и схлопывает шум до одной записи. Исключение — когда порядок массива сам является данными — шаги миграции, рейтинги поиска, точки графика — где позиционное сравнение правильно, а сортировка неверна.