本番で使う 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を置く。先行する書き込みがすでに効いていれば、表明は無意味です。 - クライアントが自由に変えられる普通のフィールドではなく、サーバーが生成するバージョン番号やダイジェストで表明する。
- 書き込みが成功するたびにバージョンを増やす。さもないと同じ古いバージョンを使う二つのリクエストが両方通ります。
testのvalueは文書の値とまったく同じ 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';
/**
* パッチを不変に適用し、失敗時は元の文書を返す。
* @param document 元の文書。呼び出し側は変更してはならない
* @param patch 適用する JSON Patch の操作配列
* @returns 成否フラグと新しい文書を含む結果
*/
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 };
}
}
実装上の推奨三つ:
- まず深いコピーか structured clone を行い、コピーに適用し、成功後にだけ参照を差し替える。
- ライブラリの自動ロールバックに頼らない。例外時の振る舞いは実装によって異なります。
- 実行前に検証する:
validateは構造だけを検査し、意味的な競合には依然testが必要です。どちらも他方を置き換えません。
パッチが複数リソースにまたがる場合、たとえば注文と在庫を同時に変える場合、JSON Patch の原子性は単一文書内でしか成り立ちません。文書をまたぐにはデータベーストランザクションか補償アクションが必要です。
5. 冪等性:PATCH は自然には冪等ではない
HTTP の意味論は PUT と DELETE を冪等と定義しますが、PATCH は必ずしもそうではありません。同じ JSON Patch を繰り返すと、まったく異なる結果になりえます。
add /items/-は毎回配列の末尾に追加するので、リトライで重複が生まれます。replace /versionはバージョン 1 を 2 に変え、操作がすでに適用済みでもリトライで再び成功します。remove /tempは二度目の実行で失敗します。パスがもう存在しないからです。
冪等性を達成する一般的な方法は三つです。
| 方法 | 仕組み | 向いている用途 |
|---|---|---|
| バージョン前提条件 | 最初の操作を test /version にし、その後インクリメント |
明示的なバージョンを持つリソース |
| 冪等キー | クライアントが Idempotency-Key を送り、サーバーが結果をキャッシュ |
決済、注文などの書き込み |
| 目標状態パッチ | add の代わりに replace を使い、結果が最終値だけに依存する |
設定とステータスフィールド |
最悪のパターンは、リトライが安全だと仮定しながら 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 設計に続きます。パッチが二つのブランチのマージ由来なら競合の意味論も必要で、JSON ファイルの Git マージで扱っています。
FAQ
失敗した test 操作はどのステータスコードを返すべきですか?
競合がバージョン表明由来なら、409 Conflict も 412 Precondition Failed も擁護できます。If-Match との一貫性のために 412 を優先し、レスポンスボディに現在のバージョンを含めてクライアントがリトライできるようにします。
add と remove を含むパッチは安全にリトライできますか?
そのままではできません。配列末尾への add はデータを重複させ、remove はパスが消えているので二度目に失敗します。バージョン前提条件か冪等キーを加え、リトライが同じ操作として認識されるようにしてください。
JSON Patch は文書をまたぐトランザクションを保証できますか?
いいえ。RFC 6902 の原子性は単一の JSON 文書に適用されます。注文、在庫、その他のリソースが関わる場合は、データベーストランザクション、Saga の補償、メッセージキューで整合性を取ってください。
パッチが __proto__ に書いてはならないのはなぜですか?
ほとんどの JavaScript 実装が文書を普通のオブジェクトに保存するため、__proto__ への書き込みはプロトタイプチェーンを変え、それ以降のすべてのオブジェクトに影響します。パスの許可リストが最も信頼できる防御です。ライブラリ内部のフィルタリングだけに頼らないでください。
PATCH リクエストを冪等にするには?
一般的な方法は三つです。パッチを test /version で始め成功時にバージョンを増やす、サーバーが Idempotency-Key の結果をキャッシュする、add の代わりに replace を使って結果を最終値だけに依存させる。パッチが何かを追加または削除するなら、バージョン表明か冪等キーと組み合わせなければならず、単純にリトライはできません。
test と If-Match の両方を使うべきですか?
両方を使う必要はありませんが、片方だけの実装ではエンドポイントが並行下で安全になりません。If-Match はボディのパース前に HTTP 層で検査されるので安価です。test はパッチの意味論層で検査され、サーバー側の ETag 対応に依存せず任意のフィールドを表明できます。