JSON Pointer、Patch 與 Merge Patch
結論:三份短規範組成一套變更協議:Pointer 定位、Patch 精確操作、Merge Patch 區域性合併。選錯一種就會在陣列與併發上出事。

結論:只按位置定位用 JSON Pointer,要篩選或遞迴用 JSONPath;陣列增刪、寫入 null 與版本斷言交給 JSON Patch,扁平配置和表單才用 Merge Patch。
定位和修改是兩件事,規範把它們拆成了三份文件。搞混的代價通常是「整個陣列被替換掉」並且沒人發現。
一、JSON Pointer:最短的定位語法
RFC 6901 只做一件事:用一個字串指向文件裡的某個節點。
"" → 整個文件
"/store" → 根下的 store 成員
"/store/book" → 再下一層
"/store/book/0/title" → 陣列第 0 項
三條規則:
- 空字串指向根節點本身,不是「空節點」;
- 每個
/分隔一段引用令牌,陣列用十進位制下標(不允許前導零,01非法); - 令牌中的
~寫作~0,/寫作~1——先換~再換/,否則二次轉義。
/**
* 把單個引用令牌轉義為 JSON Pointer 片段。
* @param token 原始鍵名或下標
* @returns 轉義後的令牌
*/
function escapePointerToken(token: string): string {
return token.replace(/~/g, '~0').replace(/\//g, '~1');
}
/**
* 把鍵名陣列拼成 JSON Pointer。
* @param tokens 鍵名或下標陣列
* @returns 以 / 開頭的指標字串
*/
function buildPointer(tokens: Array<string | number>): string {
return tokens.map((token) => `/${escapePointerToken(String(token))}`).join('');
}
鍵名本身含 / 時最能體現價值:{"a/b": 1} 的指標是 /a~1b,而不是 /a/b(後者表示 a 下的 b)。
URL 裡使用時還要做百分號編碼,因為 # 與 ~ 在 fragment 中有特殊含義。
二、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 陣列操作最容易出錯
// 想在 index 1 插入
{ "op": "add", "path": "/items/1", "value": {...} } // 正確:插入,原 1 及之後後移
// 想替換 index 1
{ "op": "replace", "path": "/items/1", "value": {...} } // 正確:替換
add 到陣列下標 n 是插入,不是覆蓋;下標越界(n > length)必須報錯。
一些實現寬鬆處理,導致同一份補丁在不同服務端行為不一致。
三、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被佔用為刪除語義; - 無法表達「不改這個欄位」與「刪除這個欄位」的第三種狀態(比如顯式置空)。
四、能力矩陣:選哪個
| 需求 | JSON Patch | Merge Patch |
|---|---|---|
| 精確替換某個陣列元素 | ✓ | ✗ |
| 刪除陣列中的一項 | ✓ | ✗ |
| 區域性更新若干欄位 | ✓(多條 replace) | ✓(最簡潔) |
寫入 null 值 |
✓ | ✗ |
| 併發控制(版本斷言) | ✓(test) | ✗(只能靠 If-Match) |
| 補丁可讀性 | 一般 | 好(像資料本身) |
| 實現複雜度 | 高 | 低 |
| 適合的資源形態 | 深層巢狀、列表 | 扁平配置、表單 |
經驗法則:
- 表單、設定頁、配置類資源 → Merge Patch,前端只需提交變化欄位;
- 列表增刪、狀態機流轉、需要併發保護 → JSON Patch;
- 兩者可以共存:不同端點宣告不同
Content-Type,服務端按型別分派。
五、實現要點
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這類鍵; - 大小限制:限制補丁條目數,防止一份補丁做幾十萬次操作;
- 審計:補丁天然適合寫審計日誌——它本身就是「改了什麼」的完整描述。
六、錯誤語義
| 情況 | 建議狀態碼 | 說明 |
|---|---|---|
| 補丁不是陣列 / 結構非法 | 400 Bad Request |
請求體格式錯誤 |
path 指向的節點不存在 |
422 Unprocessable Entity |
語法合法但語義不可執行 |
test 操作失敗 |
409 或 412 |
表示版本衝突 |
| 無許可權修改該路徑 | 403 Forbidden |
不要用 404 掩蓋,除非刻意隱藏資源存在性 |
區分 400 與 422 很重要:前者是「我看不懂你的補丁」,後者是「我看懂了但這份資料不允許這樣改」。
七、檢查清單
- [ ] 端點宣告瞭正確的
Content-Type(json-patch+json/merge-patch+json); - [ ] 補丁應用是原子的,失敗不留半成品;
- [ ] 外部補丁做路徑白名單與條目數限制;
- [ ] 用
test或If-Match做併發控制; - [ ] 補丁寫入審計日誌;
- [ ] 陣列場景顯式選擇 JSON Patch,別指望 Merge Patch。
常見問題
如何判斷該用 JSON Patch 還是 Merge Patch?
需要精確替換或刪除陣列元素、寫入 null、做版本斷言時用 JSON Patch;只是區域性更新若干欄位的表單與配置類資源用 Merge Patch。Merge Patch 無法刪除陣列中的某一項,也無法寫入 null,因為 null 已被佔用為刪除語義。
為什麼 add 到陣列下標 n 不是覆蓋?
因為 add 在陣列裡是插入語義,原下標 n 及其之後的元素會整體後移。要替換已有元素必須寫 replace,且路徑先存在;下標越界(n > length)應當報錯,寬鬆實現會讓同一份補丁在不同服務端行為不一致。
是否可以直接在原地修改原物件?
不建議。補丁失敗會留下半改狀態,除非實現保證回滾;應當在深拷貝或結構化複製的副本上套用,成功後再替換引用。