JSON Pointer, Patch และ Merge Patch
สรุปสำคัญ: ข้อกำหนดสั้นสามฉบับประกอบกันเป็นโปรโตคอลการเปลี่ยนแปลง: Pointer ระบุตำแหน่ง, Patch แก้ไขอย่างแม่นยำ, Merge Patch รวมบางส่วน การเลือกผิดจะสร้างปัญหาให้ array และ concurrency

สรุปสั้น: ใช้ JSON Pointer เพื่อระบุตำแหน่งตามตำแหน่ง และ JSONPath เพื่อกรองหรือเรียกซ้ำ; มอบการแทรก/ลบใน array, การเขียน null และการยืนยันเวอร์ชันให้ JSON Patch และเก็บ Merge Patch ไว้สำหรับการตั้งค่าแบบแบนและ resource ของฟอร์ม
การระบุตำแหน่งกับการแก้ไขเป็นคนละเรื่อง และข้อกำหนดได้แยกมันออกเป็นสามเอกสาร ราคาที่ต้องจ่ายจากการสับสนมักคือ "array ทั้งก้อนถูกแทนที่" และไม่มีใครสังเกตเห็น
1. JSON Pointer: ไวยากรณ์ระบุตำแหน่งที่สั้นที่สุด
RFC 6901 ทำเพียงสิ่งเดียว: ชี้ไปยังโหนดในเอกสารด้วยสตริง
"" → the whole document
"/store" → the store member under the root
"/store/book" → one level deeper
"/store/book/0/title" → item 0 of the array
กฎสามข้อ:
- สตริงว่างชี้ไปที่ โหนดรากเอง ไม่ใช่ "โหนดว่าง";
/แต่ละตัวคั่น reference token หนึ่งตัว; array ใช้ดัชนีทศนิยม (ไม่อนุญาต leading zero ดังนั้น01ไม่ถูกต้อง);- ภายใน token ต้องเขียน
~เป็น~0และ/เป็น~1— แทนที่~ก่อน/มิฉะนั้นจะเกิดการ escape ซ้อน
/**
* Escape reference token หนึ่งตัวให้เป็น segment ของ JSON Pointer
* @param token ชื่อคีย์หรือดัชนีเดิม
* @returns token ที่ escape แล้ว
*/
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('');
}
คุณค่าจะเห็นชัดที่สุดเมื่อคีย์เองมี / อยู่: pointer ของ {"a/b": 1} คือ /a~1b ไม่ใช่ /a/b (แบบหลังหมายถึง b ภายใต้ a)
เมื่อใช้ภายใน URL ยังต้องทำ percent-encoding ด้วย เพราะ # และ ~ มีความหมายพิเศษใน fragment
2. JSON Patch: ลำดับ atomic ของหก operation
RFC 6902 อธิบายการเปลี่ยนแปลงด้วย อาร์เรย์ของ operation media type 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" }
]
| Operation | ความหมาย | ขอบเขต |
|---|---|---|
add |
เพิ่มสมาชิกหรือแทรกสมาชิกใน array | ดัชนี array อาจเท่ากับความยาว (ต่อท้าย); - หมายถึงตำแหน่งท้าย |
remove |
ลบ | พาธต้องมีอยู่ |
replace |
แทนที่ค่าที่มีอยู่ | พาธต้องมีอยู่ (เทียบเท่า remove + add แต่ต้องมีพาธก่อน) |
move |
ย้าย | ไม่สามารถย้ายโหนดไปอยู่ใต้ subpath ของตัวเอง |
copy |
คัดลอก | ต้นทางต้องมีอยู่ |
test |
ยืนยันความเท่ากัน | ถ้าล้มเหลว แพตช์ทั้งหมดจะไม่ถูกนำไปใช้; ใช้สำหรับ optimistic locking |
คุณสมบัติสำคัญคือ atomicity: ถ้า operation ใดล้มเหลว แพตช์ทั้งหมดจะ rollback จึงไม่มีสถานะกลางที่ "สามตัวแรกใช้ได้แล้ว ตัวที่สี่ล้มเหลว"
สิ่งนี้ทำให้มันเป็น body ของคำขอ PATCH ที่ดีเยี่ยม
2.1 ใช้ test สำหรับ optimistic concurrency
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
ไคลเอนต์ GET ค่า etag ก่อนแล้วเขียนลงใน operation test เมื่อมีการเขียนพร้อมกัน test ของคำขอที่สองจะล้มเหลวและเซิร์ฟเวอร์คืน
409 Conflict หรือ 412 Precondition Failed วิธีนี้ปลอดภัยกว่า last-write-wins มาก และไม่ต้องใช้ distributed lock
2.2 operation กับ array เป็นจุดที่ผิดพลาดง่ายที่สุด
// ต้องการแทรกที่ดัชนี 1
{ "op": "add", "path": "/items/1", "value": {...} } // ถูกต้อง: แทรก; ดัชนี 1 เดิมและทุกอย่างหลังจากนั้นเลื่อนถอยหลัง
// ต้องการแทนที่ดัชนี 1
{ "op": "replace", "path": "/items/1", "value": {...} } // ถูกต้อง: แทนที่
add ที่ดัชนี array n คือการ แทรก ไม่ใช่การเขียนทับ; ดัชนีที่เกินขอบเขต (n > length) ต้องก่อ error
implementation บางตัวผ่อนปรนเรื่องนี้ ซึ่งทำให้แพตช์เดียวกันทำงานต่างกันระหว่างเซิร์ฟเวอร์
3. Merge Patch: fragment ที่หน้าตาเหมือนเป้าหมาย
RFC 7386 มีเพียงสี่กฎ:
- เป้าหมายเป็นออบเจกต์และแพตช์เป็นออบเจกต์ → merge แบบเรียกซ้ำ;
- ค่าของคีย์ในแพตช์เป็น
null→ ลบ คีย์นั้นออกจากเป้าหมาย; - ค่าของคีย์ในแพตช์ไม่ใช่ออบเจกต์ → แทนที่โดยตรง;
- แพตช์เองไม่ใช่ออบเจกต์ → แทนที่ทั้งเป้าหมาย
// เป้าหมาย
{ "a": "b", "c": { "d": "e", "f": "g" } }
// แพตช์
{ "a": "z", "c": { "f": null } }
// ผลลัพธ์
{ "a": "z", "c": { "d": "e" } }
media type คือ application/merge-patch+json มันอ่านแล้วเหมือน "partial update" ซึ่งทำให้ใช้งานผิดได้ง่ายเป็นพิเศษ:
- array ถูกแทนที่ทั้งก้อนเท่านั้น ต้องการลบ
list[2]? Merge Patch ทำไม่ได้; - ไม่สามารถเขียน
nullได้ เพราะnullถูกใช้หมายถึงการลบไปแล้ว; - ไม่สามารถแสดงสถานะที่สามระหว่าง "ปล่อยฟิลด์นี้ไว้" กับ "ลบฟิลด์นี้" (เช่น การตั้งค่าให้ว่างอย่างชัดเจน)
4. ตารางความสามารถ: จะเลือกอันไหน
| ความต้องการ | JSON Patch | Merge Patch |
|---|---|---|
| แทนที่สมาชิก array ตัวใดตัวหนึ่งอย่างแม่นยำ | ✓ | ✗ |
| ลบสมาชิกหนึ่งตัวออกจาก array | ✓ | ✗ |
| อัปเดตหลายฟิลด์แบบบางส่วน | ✓ (replace หลายครั้ง) | ✓ (กระชับที่สุด) |
เขียนค่า null |
✓ | ✗ |
| ควบคุม concurrency (ยืนยันเวอร์ชัน) | ✓ (test) | ✗ (เฉพาะผ่าน If-Match) |
| ความอ่านง่ายของแพตช์ | ปานกลาง | ดี (ดูเหมือนข้อมูลเอง) |
| ความซับซ้อนในการ implement | สูง | ต่ำ |
| รูปร่าง resource ที่เหมาะ | ซ้อนลึก คล้ายลิสต์ | การตั้งค่าแบบแบน ฟอร์ม |
กฎง่าย ๆ:
- ฟอร์ม หน้าตั้งค่า resource ของการตั้งค่า → Merge Patch; frontend ส่งเฉพาะฟิลด์ที่เปลี่ยน;
- การเพิ่มและลบในลิสต์ การเปลี่ยนสถานะของ state machine สิ่งที่ต้องป้องกัน concurrency → JSON Patch;
- ทั้งสองอยู่ร่วมกันได้: endpoint ต่างกันประกาศ
Content-Typeต่างกัน และเซิร์ฟเวอร์ dispatch ตามชนิด
5. หมายเหตุการ implement
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 }] });
// operation test ถูกตรวจสอบโดยค่าเริ่มต้น; การตั้ง mutateDocument เป็น true จะแก้ไขในตำแหน่งเดิม ใช้อย่างระมัดระวัง
const result = applyPatch(document, patch, true, false);
console.log(result.newDocument);
สี่จุดที่ถูกละเลยง่าย:
- อย่าแก้ไขออบเจกต์ที่ใช้ร่วมกันในตำแหน่งเดิม; แพตช์ที่ล้มเหลวจะทิ้งสถานะที่แก้ไขค้างไว้ (เว้นแต่ implementation รับประกัน rollback);
- การตรวจสอบพาธ: แพตช์จากภายนอกต้องตรวจ
path/fromทุกตัว และปฏิเสธคีย์อย่าง__proto__และconstructor; - การจำกัดขนาด: จำกัดจำนวน entry ของแพตช์เพื่อป้องกันแพตช์เดียวทำงานหลายแสน operation;
- การตรวจสอบย้อนหลัง: แพตช์เหมาะกับ audit log โดยธรรมชาติ — มันคือคำอธิบายที่สมบูรณ์ของ "อะไรเปลี่ยนไป"
6. ความหมายของ error
| สถานการณ์ | รหัสสถานะที่แนะนำ | คำอธิบาย |
|---|---|---|
| แพตช์ไม่ใช่อาร์เรย์ / โครงสร้างไม่ถูกต้อง | 400 Bad Request |
รูปแบบ body ของคำขอผิด |
โหนดที่ path อ้างถึงไม่มีอยู่ |
422 Unprocessable Entity |
ถูกต้องตามไวยากรณ์แต่ไม่สามารถทำงานได้ตามความหมาย |
operation test ล้มเหลว |
409 หรือ 412 |
บ่งชี้ความขัดแย้งของเวอร์ชัน |
| ไม่มีสิทธิ์แก้ไขพาธนั้น | 403 Forbidden |
อย่าปกปิดด้วย 404 เว้นแต่ต้องการซ่อนการมีอยู่ของ resource โดยเจตนา |
การแยก 400 ออกจาก 422 สำคัญ: แบบแรกหมายถึง "ฉันเข้าใจแพตช์ของคุณไม่ได้" แบบหลังหมายถึง "ฉันเข้าใจ แต่ข้อมูลนี้ไม่อนุญาตให้เปลี่ยนแปลงเช่นนั้น"
7. รายการตรวจสอบ
- [ ] endpoint ประกาศ
Content-Typeที่ถูกต้อง (json-patch+json/merge-patch+json); - [ ] การนำแพตช์ไปใช้เป็น atomic และความล้มเหลวไม่ทิ้งผลลัพธ์บางส่วน;
- [ ] แพตช์ภายนอกถูกตรวจกับ whitelist ของพาธและขีดจำกัดจำนวน entry;
- [ ] การควบคุม concurrency ใช้
testหรือIf-Match; - [ ] แพตช์ถูกเขียนลง audit log;
- [ ] สถานการณ์เกี่ยวกับ array เลือก JSON Patch อย่างชัดเจน; อย่าหวังพึ่ง Merge Patch
คำถามที่พบบ่อย
จะตัดสินใจระหว่าง JSON Patch กับ Merge Patch อย่างไร
ใช้ JSON Patch เมื่อต้องแทนที่หรือลบสมาชิก array ตัวใดตัวหนึ่ง เขียนค่า null หรือยืนยันเวอร์ชัน; ใช้ Merge Patch สำหรับฟอร์มและ resource ของการตั้งค่าที่อัปเดตเพียงไม่กี่ฟิลด์ Merge Patch ไม่สามารถลบสมาชิก array ตัวเดียวได้ และไม่สามารถเขียน null ได้ เพราะ null หมายถึงการลบอยู่แล้ว
ทำไม add ที่ดัชนี array n ไม่ใช่การเขียนทับ
เพราะ add แทรกเข้าใน array: สมาชิกที่ดัชนี n และทุกอย่างหลังจากนั้นจะเลื่อนถอยหลัง การแทนที่สมาชิกที่มีอยู่ต้องใช้ replace และพาธต้องมีอยู่ก่อน ดัชนีที่เกินขอบเขต (n > length) ควรก่อ error; implementation ที่หลวมจะทำให้แพตช์เดียวกันทำงานต่างกันระหว่างเซิร์ฟเวอร์
ควรแก้ไขออบเจกต์เดิมในตำแหน่งเลยไหม
ไม่ควร แพตช์ที่ล้มเหลวจะทิ้งสถานะที่แก้ไขค้างไว้ เว้นแต่ implementation รับประกัน rollback ให้ใช้แพตช์กับสำเนาที่ deep copy หรือ structured clone ก่อน แล้วสลับ reference หลังสำเร็จ