JSON 工具箱

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。

定位和修改是兩件事,規範把它們拆成了三份文件。搞混的代價通常是「整個陣列被替換掉」並且沒人發現。

一、JSON Pointer:最短的定位語法

RFC 6901 只做一件事:用一個字串指向文件裡的某個節點。

""             → 整個文件
"/store"       → 根下的 store 成員
"/store/book"  → 再下一層
"/store/book/0/title" → 陣列第 0 項

三條規則:

  1. 空字串指向根節點本身,不是「空節點」;
  2. 每個 / 分隔一段引用令牌,陣列用十進位制下標(不允許前導零,01 非法);
  3. 令牌中的 ~ 寫作 ~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 的規則只有四條:

  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 被佔用為刪除語義;
  • 無法表達「不改這個欄位」與「刪除這個欄位」的第三種狀態(比如顯式置空)。

四、能力矩陣:選哪個

需求 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);

四個容易忽略的點:

  1. 不要原地修改共享物件,補丁失敗會留下半改狀態(除非實現保證回滾);
  2. 路徑校驗:來自外部的補丁必須校驗每個 path/from,拒絕 __proto__、constructor 這類鍵;
  3. 大小限制:限制補丁條目數,防止一份補丁做幾十萬次操作;
  4. 審計:補丁天然適合寫審計日誌——它本身就是「改了什麼」的完整描述。

六、錯誤語義

情況 建議狀態碼 說明
補丁不是陣列 / 結構非法 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)應當報錯,寬鬆實現會讓同一份補丁在不同服務端行為不一致。

是否可以直接在原地修改原物件?

不建議。補丁失敗會留下半改狀態,除非實現保證回滾;應當在深拷貝或結構化複製的副本上套用,成功後再替換引用。