JSON Patch у виробництві: атомарність, ідемпотентність і конкурентні конфлікти
Головне: JSON Patch — лише формат змін; атомарність, ідемпотентність і конкурентність усе ще ваша робота. Охоплює передумови test, екранування шляхів, відкат і координацію ETag.

Коротко: спершу перевірте цільові шляхи й дозволи патча, потім додайте перевірку конкурентності через test або If-Match; застосовуйте патч до копії, щоб збій не залишав нічого напівзаписаного.
Справжня небезпека запиту
PATCH— не записати неправильне поле. А вважати, що запис вдався, тоді як він мовчки перезаписав чужу зміну. JSON Patch дає вам інструменти, але проєктування безпеки все ще ваше.
1. Швидкий огляд набору операцій RFC 6902
JSON Patch описує зміни як масив операцій, з медіатипом application/json-patch+json. Шість операцій виконуються в порядку масиву:
| Операція | Дія | Ключове обмеження |
|---|---|---|
add |
Додати член або вставити елемент масиву | Індекс масиву може дорівнювати довжині; - означає кінець |
remove |
Видалити член або елемент | Цільовий шлях має існувати |
replace |
Замінити наявне значення | Цільовий шлях має існувати |
move |
Перемістити вузол | Не можна перемістити вузол у власний дочірній шлях |
copy |
Копіювати вузол | Шлях джерела має існувати |
test |
Перевірити рівність значень | Якщо збоїть, весь патч не застосовується |
Багато хто памʼятає лише перші три й забуває, що test — це те, що перетворює сліпий запис на умовний запис. Повна семантика операцій і межі масивів розглянуто в JSON Pointer, Patch і Merge Patch.
2. Використання test, щоб перетворити патч на умовне оновлення
Типовий патч оптимістичного блокування починається не з запису, а з перевірки версії:
{"op":"test","path":"/version","value":1}
Покладіть його в повний патч:
[
{ "op": "test", "path": "/version", "value": 1 },
{ "op": "replace", "path": "/version", "value": 2 },
{ "op": "replace", "path": "/status", "value": "paid" }
]
Логіка така: підтвердьте, що версія документа зараз 1, потім змініть її на 2. Якщо інший запит уже просунув версію до 2, перший test зазнає збою, весь патч не застосовується, і сервер повертає конфлікт. Це запобігає втраченим оновленням без запровадження розподіленого блокування.
Кілька моментів дизайну:
- Ставте
testперед кожним записом. Якщо попередній запис уже набув чинності, перевірка безглузда; - Перевіряйте номер версії або дайджест, згенерований сервером, а не звичайне поле, яке клієнт може вільно змінювати;
- Збільшуйте версію при кожному успішному записі; інакше два запити зі старою версією обидва пройдуть;
valueуtestмає мати точно той самий тип JSON, що й значення документа.1і"1"не рівні.
3. Екранування шляхів і забруднення прототипу
Правила екранування JSON Pointer прості: пишіть ~ як ~0, а / як ~1, і замініть ~ перед /, інакше отримаєте подвійне екранування.
Ключ "a/b" → /a~1b
Ключ "m~n" → /m~0n
Ключ "a~/b" → /a~0~1b
Ризик із зовнішніми патчами зазвичай не в екрануванні, а в небезпечних ключах. У JavaScript ключі на кшталт __proto__, constructor і prototype можуть запустити забруднення прототипу, навіть через звичайний add:
[
{ "op": "add", "path": "/__proto__/polluted", "value": true }
]
Використовуйте білий список, а не чорний:
- Дозволяйте патчам змінювати лише бізнес-поля, як-от
/statusабо/items/0/quantity; - Перевіряйте кожен
pathіfromрегулярним виразом або префіксом шляху, відхиляючи несподівані рівні, крім індексів масивів; - Розбирайте JSON у контейнери
Object.create(null)абоMap, щоб зменшити вплив забрудненого ланцюга прототипів; - Обмежте кількість операцій і байтовий розмір кожного
value.
4. Атомарне застосування й відкат
RFC 6902 вимагає атомарного виконання, але атомарність — семантична вимога, а не типове значення в кожній бібліотеці. Бібліотеки на кшталт fast-json-patch змінюють документ на місці, коли mutateDocument дорівнює true, і збій може залишити часткові зміни.
import { applyPatch, validate } from 'fast-json-patch';
/**
* Apply a patch immutably and return the original document on failure.
* @param document The original document, which callers must not mutate
* @param patch The JSON Patch operation array to apply
* @returns The result, including a success flag and the new document
*/
function applySafely(document: unknown, patch: unknown[]) {
const errors = validate(patch, document);
if (errors) {
return { ok: false, document };
}
try {
const result = applyPatch(document, patch, false, false);
return { ok: true, document: result.newDocument };
} catch {
return { ok: false, document };
}
}
Три рекомендації щодо реалізації:
- Спершу глибока копія або структурне клонування, застосуйте до копії й замініть посилання лише після успіху;
- Не покладайтеся на автоматичний відкат бібліотеки; поведінка при винятках різниться між реалізаціями;
- Перевіряйте перед виконанням:
validateперевіряє лише структуру, тоді як семантичні конфлікти все ще потребуютьtest; жодне не замінює інше.
Якщо патч охоплює кілька ресурсів, наприклад змінює замовлення й його запаси разом, атомарність JSON Patch діє лише в межах одного документа. Між документами потрібна транзакція бази даних або компенсаційні дії.
5. Ідемпотентність: PATCH не є природно ідемпотентним
Семантика HTTP визначає PUT і DELETE як ідемпотентні, але PATCH — не обовʼязково. Повторення того самого JSON Patch може дати цілком різні результати:
add /items/-додає в кінець масиву щоразу, тож повтори створюють дублікати;replace /versionзмінює версію 1 на 2 і знову успішно проходить на повторі, хоча операція вже застосована;remove /tempзазнає збою на другому запуску, бо шляху більше немає.
Є три поширені способи досягти ідемпотентності:
| Підхід | Як працює | Найкраще для |
|---|---|---|
| Передумова версії | Перша операція — test /version, потім збільшення |
Ресурси з явною версією |
| Ключ ідемпотентності | Клієнт надсилає Idempotency-Key; сервер кешує результат |
Платежі, замовлення та інші записи |
| Патч цільового стану | Використовуйте replace замість add, щоб результат залежав лише від кінцевого значення |
Конфігурація й поля статусу |
Найгірший шаблон — патч, що змішує add і remove, припускаючи, що повтори безпечні. Якщо патч щось додає чи видаляє, його треба поєднати з перевіркою версії або ключем ідемпотентності.
6. Конкурентні конфлікти: ETag і If-Match
Окрім test усередині патча, HTTP пропонує стандартний механізм: сервер повертає ETag, а клієнт надсилає If-Match у наступному запиті.
GET /orders/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json
[{"op":"replace","path":"/status","value":"paid"}]
Якщо ресурс змінився, сервер повертає 412 Precondition Failed, і клієнт повторно отримує дані перед повтором. If-Match: * означає «виконувати, поки ресурс існує», що не підходить, коли треба запобігти перезаписам.
Різниця між test і If-Match:
If-Matchперевіряється на рівні HTTP до розбору тіла, тож дешевше;testперевіряється на семантичному рівні патча й може перевірити будь-яке поле, не залежачи від підтримки ETag на сервері;- Їх можна поєднувати, але не впроваджуйте лише одне й не заявляйте, що кінцева точка безпечна за конкурентності.
7. Відмінності бібліотек і вибір
| Реалізація | Мова | Атомарність | Підтримка test |
Примітки |
|---|---|---|---|---|
fast-json-patch |
JavaScript | Необовʼязкова (за замовчуванням повертає новий документ) | Так | Широка екосистема; стежте за mutateDocument |
jsonpatch |
Python | Відкочує на винятках | Так | Близька до стандарту, з чіткими помилками |
json-patch |
Go | Працює з копією | Так | Часто використовується поряд із JSON Merge Patch |
zjsonpatch |
Java | Так | Так | Зручна інтеграція з Jackson |
Не обирайте бібліотеку лише за «підтримує всі шість операцій». Підтвердьте три речі: чи відкочує збій, чи строго порівнює test і чи спричиняє помилку add за індексом поза діапазоном масиву. Ліберальні реалізації роблять той самий патч різним у різних середовищах.
8. Чек-лист безпеки перед запуском
- [ ] Кінцева точка оголошує
application/json-patch+json; - [ ] Патчі застосовуються до копії, і збій ніколи не забруднює оригінальний документ;
- [ ] Кожен
pathіfromу зовнішніх патчах проходить перевірку за білим списком; - [ ] Небезпечні ключі на кшталт
__proto__,constructorіprototypeвідхиляються; - [ ] Кількість операцій і розмір одного значення обмежені;
- [ ]
testабоIf-Matchзабезпечує контроль конкурентності; - [ ] Патчі записуються в журнал аудиту з виконавцем, часовою міткою й повним масивом операцій;
- [ ] Межі транзакцій явні, коли патч охоплює документи.
Патч за своєю природою є повним описом «що змінилося», що робить його природним записом аудиту. Про коди помилок і ідемпотентність на рівні API продовжіть у Проєктування JSON API. Якщо патч походить із злиття двох гілок, вам також потрібна семантика конфліктів, розглянута в Злиття Git для файлів JSON.
Часті питання
Який код статусу має повертати збійна операція test?
Якщо конфлікт походить із перевірки версії, і 409 Conflict, і 412 Precondition Failed захищувані. Надавайте перевагу 412 для узгодженості з If-Match і включіть поточну версію в тіло відповіді, щоб клієнт міг повторити.
Чи можна безпечно повторити патч із add і remove?
Не безпосередньо. add у кінець масиву дублює дані, а remove зазнає збою на другому запуску, бо шляху більше немає. Додайте передумову версії або ключ ідемпотентності, щоб повтори розпізнавалися як та сама операція.
Чи може JSON Patch гарантувати транзакції між документами?
Ні. Атомарність RFC 6902 стосується одного документа JSON. Коли задіяні замовлення, запаси чи інші ресурси, використовуйте транзакції бази даних, компенсацію Saga або чергу повідомлень для узгодженості.
Чому патч ніколи не має писати в __proto__?
Бо більшість реалізацій JavaScript зберігають документ у звичайних обʼєктах, і запис у __proto__ може змінити ланцюг прототипів та вплинути на кожен обʼєкт після цього. Білий список шляхів — найнадійніший захист; не покладайтеся лише на внутрішню фільтрацію бібліотеки.
Як зробити запит PATCH ідемпотентним?
Є три поширені підходи: почніть патч із test /version і збільшуйте версію після успіху, нехай сервер кешує результат для Idempotency-Key або використовуйте replace замість add, щоб результат залежав лише від кінцевого значення. Якщо патч щось додає чи видаляє, його треба поєднати з перевіркою версії або ключем ідемпотентності; його не можна просто повторювати.
Чи використовувати і test, і If-Match?
Не обовʼязково використовувати обидва, але впровадження лише одного з них не робить кінцеву точку безпечною за конкурентності. If-Match перевіряється на рівні HTTP до розбору тіла, тож дешевше; test перевіряється на семантичному рівні патча й може перевірити будь-яке поле, не залежачи від підтримки ETag на сервері.