JSON Toolkit

JSON Pointer, Patch и Merge Patch

Главное: Три короткие спецификации образуют протокол изменения: Pointer локализует, Patch точно редактирует, Merge Patch частично слияет. Неверный выбор вредит массивам и параллелизму.

Средний~12 мин чтенияОпубликовано 2026-10-30Обновлено 2026-10-30
Обложка JSON Pointer, Patch и Merge Patch

Главное: используйте JSON Pointer для локализации по позиции и JSONPath для фильтрации или рекурсии; передайте вставку/удаление массива, записи null и утверждения версии в JSON Patch, и приберегите Merge Patch для плоской конфигурации и ресурсов форм.

Локализация и изменение — две разные вещи, и спецификации разделяют их на три документа. Обычная цена их смешения — «весь массив заменён», и никто не замечает.

1. JSON Pointer: самый короткий синтаксис локализации

RFC 6901 делает ровно одно: указывает на узел в документе строкой.

""             → весь документ
"/store"       → член store под корнем
"/store/book"  → на один уровень глубже
"/store/book/0/title" → элемент 0 массива

Три правила:

  1. Пустая строка указывает на сам корневой узел, а не на «пустой узел»;
  2. Каждый / разделяет один ссылочный токен; массивы используют десятичные индексы (ведущие нули не допускаются, поэтому 01 невалиден);
  3. Внутри токена ~ записывается как ~0, а / как ~1 — заменяйте ~ перед /, иначе вы получите двойное экранирование.
/**
 * Escapes a single reference token into a JSON Pointer segment.
 * @param token Original key name or index
 * @returns Escaped token
 */
function escapePointerToken(token: string): string {
  return token.replace(/~/g, '~0').replace(/\//g, '~1');
}

/**
 * Joins an array of key names into a JSON Pointer.
 * @param tokens Array of key names or indices
 * @returns Pointer string beginning with /
 */
function buildPointer(tokens: Array<string | number>): string {
  return tokens.map((token) => `/${escapePointerToken(String(token))}`).join('');
}

Ценность появляется лучше всего, когда ключ сам содержит /: pointer для {"a/b": 1} — /a~1b, а не /a/b (последний означает b под a).

При использовании внутри URL также требуется процентное кодирование, потому что # и ~ имеют особые значения во фрагменте.

2. JSON Patch: атомарная последовательность шести операций

RFC 6902 описывает изменения массивом операций, медиатип application/json-patch+json:

[
  { "op": "test", "path": "/version", "value": 7 },
  { "op": "replace", "path": "/name", "value": "新的名字" },
  { "op": "add", "path": "/tags/-", "value": "json" },
  { "op": "remove", "path": "/temp" },
  { "op": "copy", "from": "/name", "path": "/alias" },
  { "op": "move", "from": "/tags/0", "path": "/tags/1" }
]
Операция Семантика Граница
add Добавляет член или вставляет элемент массива Индекс массива может равняться длине (добавление); - означает конец
remove Удаляет Путь должен существовать
replace Заменяет существующее значение Путь должен существовать (эквивалент remove + add, но требует, чтобы путь сначала существовал)
move Перемещает Нельзя переместить узел под его собственный подпуть
copy Копирует Источник должен существовать
test Утверждает равенство Если падает, весь патч не применяется; используется для оптимистичной блокировки

Ключевое свойство — атомарность: если любая операция падает, весь патч откатывается, поэтому нет промежуточного состояния, где «первые три применились, а четвёртая упала». Это делает его отличным телом запроса PATCH.

2.1 Использование test для оптимистичного параллелизма

[
  { "op": "test", "path": "/etag", "value": "a3f9c1" },
  { "op": "replace", "path": "/status", "value": "paid" }
]

Клиент сначала GET-ит etag и пишет его в операцию test. При параллельных записях test второго запроса падает, и сервер возвращает 409 Conflict или 412 Precondition Failed. Это гораздо безопаснее, чем «последняя запись побеждает», и не требует распределённой блокировки.

2.2 Операции с массивами наиболее подвержены ошибкам

// Хотим вставить по индексу 1
{ "op": "add", "path": "/items/1", "value": {...} }   // Верно: вставка; исходный индекс 1 и всё после сдвигается назад

// Хотим заменить индекс 1
{ "op": "replace", "path": "/items/1", "value": {...} } // Верно: замена

add по индексу массива n — это вставка, а не перезапись; индекс вне диапазона (n > length) должен поднимать ошибку. Некоторые реализации снисходительны к этому, что заставляет один и тот же патч вести себя по-разному на разных серверах.

3. Merge Patch: фрагмент, выглядящий как цель

RFC 7386 имеет только четыре правила:

  1. Цель — объект и патч — объект → слияние рекурсивно;
  2. Значение ключа в патче равно null → удалить этот ключ из цели;
  3. Значение ключа в патче не объект → заменить напрямую;
  4. Сам патч не объект → вся цель заменяется.
// Цель
{ "a": "b", "c": { "d": "e", "f": "g" } }

// Патч
{ "a": "z", "c": { "f": null } }

// Результат
{ "a": "z", "c": { "d": "e" } }

Медиатип — application/merge-patch+json. Он читается как «частичное обновление», что делает его особенно легко неправильно использовать:

  • Массивы можно только заменять целиком. Хотите удалить list[2]? Merge Patch этого не может;
  • Нельзя записать null. null уже занят значением удаления;
  • Он не может выразить третье состояние между «оставить это поле в покое» и «удалить это поле» (например, явно установить его в пустое).

4. Матрица возможностей: что выбрать

Требование JSON Patch Merge Patch
Точно заменить конкретный элемент массива ✓ ✗
Удалить один элемент из массива ✓ ✗
Частично обновить несколько полей ✓ (несколько replace) ✓ (самое лаконичное)
Записать значение null ✓ ✗
Контроль параллелизма (утверждение версии) ✓ (test) ✗ (только через If-Match)
Читаемость патча Средняя Хорошая (выглядит как сами данные)
Сложность реализации Высокая Низкая
Подходящие формы ресурсов Глубоко вложенные, списковые Плоская конфигурация, формы

Правила большого пальца:

  • Формы, страницы настроек, ресурсы конфигурации → Merge Patch; фронтенд отправляет только изменённые поля;
  • Добавления и удаления списков, переходы состояний, всё, что нуждается в защите параллелизма → JSON Patch;
  • Оба могут сосуществовать: разные конечные точки объявляют разные Content-Type, и сервер диспетчеризует по типу.

5. Замечания по реализации

import { applyPatch, compare } from 'fast-json-patch';

const document = { version: 7, items: [{ id: 1 }, { id: 2 }] };
const patch = compare(document, { version: 8, items: [{ id: 1 }] });

// Операции test валидируются по умолчанию; установка mutateDocument в true изменяет на месте, используйте с осторожностью
const result = applyPatch(document, patch, true, false);
console.log(result.newDocument);

Четыре легко упускаемых пункта:

  1. Не мутируйте общие объекты на месте; провалившийся патч оставляет полуизменённое состояние (если только реализация не гарантирует откат);
  2. Валидация путей: патчи извне должны валидировать каждый path/from и отвергать ключи вроде __proto__ и constructor;
  3. Лимиты размера: ограничьте число записей патча, чтобы один патч не выполнял сотни тысяч операций;
  4. Аудит: патчи по природе подходят для журналов аудита — они являются полным описанием «что изменилось».

6. Семантика ошибок

Ситуация Рекомендуемый код статуса Объяснение
Патч не массив / структурно невалиден 400 Bad Request Формат тела запроса неверен
Узел, на который указывает path, не существует 422 Unprocessable Entity Синтаксически валиден, но семантически неисполним
Операция test провалилась 409 или 412 Указывает на конфликт версий
Нет разрешения изменять этот путь 403 Forbidden Не маскируйте под 404, если только вы намеренно не скрываете существование ресурса

Различение 400 и 422 важно: первое означает «я не могу понять ваш патч», второе — «я понимаю его, но эти данные не допускают такое изменение».

7. Контрольный список

  • [ ] Конечная точка объявляет правильный Content-Type (json-patch+json / merge-patch+json);
  • [ ] Применение патча атомарно, и сбой не оставляет частичного результата;
  • [ ] Внешние патчи валидируются по белому списку путей и лимиту числа записей;
  • [ ] Контроль параллелизма использует test или If-Match;
  • [ ] Патчи записываются в журнал аудита;
  • [ ] Сценарии массивов явно выбирают JSON Patch; не рассчитывайте на Merge Patch.

Вопросы и ответы

Как выбрать между JSON Patch и Merge Patch?

Используйте JSON Patch, когда нужно заменить или удалить конкретный элемент массива, записать значение null или утвердить версию; используйте Merge Patch для форм и ресурсов конфигурации, которые обновляют лишь несколько полей. Merge Patch не может удалить один элемент массива и не может записать null, потому что null уже означает удаление.

Почему add по индексу массива n — не перезапись?

Потому что add вставляет в массив: элемент по индексу n и всё после него сдвигается назад. Чтобы заменить существующий элемент, вы должны использовать replace, и путь должен сначала существовать. Индекс вне диапазона (n > length) должен поднимать ошибку; снисходительные реализации заставляют один и тот же патч вести себя по-разному на разных серверах.

Стоит ли изменять исходный объект на месте?

Нет. Провалившийся патч оставляет полуизменённое состояние, если только реализация не гарантирует откат. Применяйте патч к глубоко копированной или структурированно клонированной копии, затем меняйте ссылку после успеха.