JSON Pointer, Patch и Merge Patch
Главное: Три короткие спецификации образуют протокол изменения: 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 массива
Три правила:
- Пустая строка указывает на сам корневой узел, а не на «пустой узел»;
- Каждый
/разделяет один ссылочный токен; массивы используют десятичные индексы (ведущие нули не допускаются, поэтому01невалиден); - Внутри токена
~записывается как~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 имеет только четыре правила:
- Цель — объект и патч — объект → слияние рекурсивно;
- Значение ключа в патче равно
null→ удалить этот ключ из цели; - Значение ключа в патче не объект → заменить напрямую;
- Сам патч не объект → вся цель заменяется.
// Цель
{ "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);
Четыре легко упускаемых пункта:
- Не мутируйте общие объекты на месте; провалившийся патч оставляет полуизменённое состояние (если только реализация не гарантирует откат);
- Валидация путей: патчи извне должны валидировать каждый
path/fromи отвергать ключи вроде__proto__иconstructor; - Лимиты размера: ограничьте число записей патча, чтобы один патч не выполнял сотни тысяч операций;
- Аудит: патчи по природе подходят для журналов аудита — они являются полным описанием «что изменилось».
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) должен поднимать ошибку; снисходительные реализации заставляют один и тот же патч вести себя по-разному на разных серверах.
Стоит ли изменять исходный объект на месте?
Нет. Провалившийся патч оставляет полуизменённое состояние, если только реализация не гарантирует откат. Применяйте патч к глубоко копированной или структурированно клонированной копии, затем меняйте ссылку после успеха.