JSON Toolkit

JSON Pointer, Patch และ Merge Patch

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

ระดับกลางอ่านประมาณ 12 นาทีเผยแพร่ 2026-10-30อัปเดต 2026-10-30
ภาพปกสำหรับ JSON Pointer, Patch และ Merge Patch

สรุปสั้น: ใช้ 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

กฎสามข้อ:

  1. สตริงว่างชี้ไปที่ โหนดรากเอง ไม่ใช่ "โหนดว่าง";
  2. / แต่ละตัวคั่น reference token หนึ่งตัว; array ใช้ดัชนีทศนิยม (ไม่อนุญาต leading zero ดังนั้น 01 ไม่ถูกต้อง);
  3. ภายใน 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 มีเพียงสี่กฎ:

  1. เป้าหมายเป็นออบเจกต์และแพตช์เป็นออบเจกต์ → merge แบบเรียกซ้ำ;
  2. ค่าของคีย์ในแพตช์เป็น null → ลบ คีย์นั้นออกจากเป้าหมาย;
  3. ค่าของคีย์ในแพตช์ไม่ใช่ออบเจกต์ → แทนที่โดยตรง;
  4. แพตช์เองไม่ใช่ออบเจกต์ → แทนที่ทั้งเป้าหมาย
// เป้าหมาย
{ "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);

สี่จุดที่ถูกละเลยง่าย:

  1. อย่าแก้ไขออบเจกต์ที่ใช้ร่วมกันในตำแหน่งเดิม; แพตช์ที่ล้มเหลวจะทิ้งสถานะที่แก้ไขค้างไว้ (เว้นแต่ implementation รับประกัน rollback);
  2. การตรวจสอบพาธ: แพตช์จากภายนอกต้องตรวจ path/from ทุกตัว และปฏิเสธคีย์อย่าง __proto__ และ constructor;
  3. การจำกัดขนาด: จำกัดจำนวน entry ของแพตช์เพื่อป้องกันแพตช์เดียวทำงานหลายแสน operation;
  4. การตรวจสอบย้อนหลัง: แพตช์เหมาะกับ 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 หลังสำเร็จ