JSON Toolkit

JSON Patch ในสภาพแวดล้อมจริง: Atomicity, Idempotency และความขัดแย้งจาก Concurrency

สรุปสำคัญ: JSON Patch เป็นเพียงรูปแบบการเปลี่ยนแปลงเท่านั้น; atomicity, idempotency และ concurrency ยังคงเป็นหน้าที่ของคุณ ครอบคลุม precondition ด้วย test, การ escape พาธ, rollback และการประสานงานกับ ETag

ขั้นสูงอ่านประมาณ 10 นาทีเผยแพร่ 2026-10-31อัปเดต 2026-10-31
ภาพปกสำหรับ JSON Patch ในสภาพแวดล้อมจริง

สรุปสั้น: ตรวจสอบพาธเป้าหมายและสิทธิ์ของแพตช์ก่อน จากนั้นเพิ่มการยืนยัน concurrency ด้วย test หรือ If-Match; นำแพตช์ไปใช้กับสำเนา เพื่อให้เมื่อล้มเหลวจะไม่เหลืออะไรที่เขียนค้างไว้

อันตรายจริงของคำขอ PATCH ไม่ใช่การเขียนฟิลด์ผิด แต่คือการเชื่อว่าการเขียนสำเร็จทั้งที่เขียนทับการเปลี่ยนแปลงของคนอื่นอย่างเงียบ ๆ JSON Patch ให้เครื่องมือมา แต่การออกแบบความปลอดภัยยังเป็นหน้าที่ของคุณ

1. ทบทวนชุด operation ของ RFC 6902 อย่างรวดเร็ว

JSON Patch อธิบายการเปลี่ยนแปลงเป็นอาร์เรย์ของ operation โดยมี media type application/json-patch+json operation หกตัวทำงานตามลำดับในอาร์เรย์:

Operation ผลลัพธ์ ข้อจำกัดสำคัญ
add เพิ่มสมาชิกหรือแทรกสมาชิกใน array ดัชนี array อาจเท่ากับความยาวได้; - หมายถึงตำแหน่งท้าย
remove ลบสมาชิกหรือ element พาธเป้าหมายต้องมีอยู่
replace แทนที่ค่าที่มีอยู่ พาธเป้าหมายต้องมีอยู่
move ย้ายโหนด ไม่สามารถย้ายโหนดเข้าไปในพาธลูกของตัวเอง
copy คัดลอกโหนด พาธต้นทางต้องมีอยู่
test ยืนยันความเท่ากันของค่า ถ้าล้มเหลว แพตช์ทั้งหมดจะไม่ถูกนำไปใช้

หลายคนจำได้เพียงสามตัวแรกและลืมว่า test คือสิ่งที่เปลี่ยนการเขียนแบบไม่ตรวจสอบให้เป็นการเขียนแบบมีเงื่อนไข ความหมายของ operation ทั้งหมดและขอบเขตของ array อธิบายไว้ใน JSON Pointer, Patch และ Merge Patch

2. ใช้ test เพื่อเปลี่ยนแพตช์ให้เป็นการอัปเดตแบบมีเงื่อนไข

แพตช์สำหรับ optimistic locking แบบฉบับเริ่มต้นไม่ใช่การเขียน แต่เป็นการยืนยันเวอร์ชัน:

{"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 ตัวแรกจะล้มเหลว แพตช์ทั้งหมดจะไม่ถูกนำไปใช้ และเซิร์ฟเวอร์จะคืนค่าความขัดแย้ง วิธีนี้ป้องกัน lost update โดยไม่ต้องใช้ distributed lock

จุดออกแบบหลายประการ:

  • วาง test ไว้ก่อนการเขียนทุกครั้ง ถ้าการเขียนก่อนหน้าเกิดขึ้นแล้ว การยืนยันก็ไม่มีความหมาย;
  • ยืนยันบนเลขเวอร์ชันหรือ digest ที่เซิร์ฟเวอร์สร้างขึ้น ไม่ใช่บนฟิลด์ธรรมดาที่ไคลเอนต์แก้ไขได้อิสระ;
  • เพิ่มเวอร์ชันเมื่อการเขียนสำเร็จทุกครั้ง มิฉะนั้นสองคำขอที่ใช้เวอร์ชันเก่าเดียวกันจะผ่านทั้งคู่;
  • value ของ test ต้องมีชนิด JSON ตรงกับค่าในเอกสารทุกประการ 1 กับ "1" ไม่เท่ากัน

3. การ escape พาธและ Prototype Pollution

กฎการ escape ของ JSON Pointer นั้นง่าย: เขียน ~ เป็น ~0 และ / เป็น ~1 และ แทนที่ ~ ก่อน / มิฉะนั้นจะเกิดการ escape ซ้อน

Key "a/b"    → /a~1b
Key "m~n"    → /m~0n
Key "a~/b"   → /a~0~1b

ความเสี่ยงของแพตช์จากภายนอกมักไม่ใช่การ escape แต่คือคีย์ที่อันตราย ใน JavaScript คีย์อย่าง __proto__, constructor และ prototype สามารถกระตุ้น prototype pollution ได้ แม้ผ่าน add ธรรมดา:

[
  { "op": "add", "path": "/__proto__/polluted", "value": true }
]

ใช้ allowlist แทน denylist:

  1. อนุญาตให้แพตช์แก้ไขได้เฉพาะฟิลด์ทางธุรกิจ เช่น /status หรือ /items/0/quantity;
  2. ตรวจสอบ path และ from ทุกตัวเทียบกับ regular expression หรือ prefix ของพาธ และปฏิเสธระดับที่ไม่คาดคิดนอกเหนือจากดัชนี array;
  3. แปลง JSON ลงในคอนเทนเนอร์ Object.create(null) หรือ Map เพื่อลดผลกระทบจาก prototype chain ที่ถูกปนเปื้อน;
  4. จำกัดจำนวน operation และขนาดไบต์ของ value แต่ละตัว

4. การนำไปใช้แบบ atomic และ rollback

RFC 6902 กำหนดให้ทำงานแบบ atomic แต่ atomicity เป็นข้อกำหนดเชิงความหมาย ไม่ใช่ค่าเริ่มต้นของทุกไลบรารี ไลบรารีอย่าง fast-json-patch จะแก้ไขเอกสารในตำแหน่งเดิมเมื่อ mutateDocument เป็น true และความล้มเหลวอาจทิ้งการเปลี่ยนแปลงบางส่วนไว้

import { applyPatch, validate } from 'fast-json-patch';

/**
 * นำแพตช์ไปใช้แบบ immutable และคืนเอกสารเดิมเมื่อล้มเหลว
 * @param document เอกสารเดิม ซึ่งผู้เรียกต้องไม่แก้ไข
 * @param patch อาร์เรย์ operation ของ 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 };
  }
}

คำแนะนำในการ implement สามข้อ:

  • deep copy หรือ structured clone ก่อน นำแพตช์ไปใช้กับสำเนา แล้วสลับ reference หลังสำเร็จเท่านั้น;
  • อย่าพึ่งพา rollback อัตโนมัติของไลบรารี พฤติกรรมเมื่อเกิด exception แตกต่างกันในแต่ละ implementation;
  • ตรวจสอบก่อนดำเนินการ: validate ตรวจเพียงโครงสร้าง ขณะที่ความขัดแย้งเชิงความหมายยังต้องใช้ test; ทั้งสองไม่ได้แทนที่กัน

ถ้าแพตช์ครอบคลุมหลาย resource เช่นแก้ไขคำสั่งซื้อและสต็อกพร้อมกัน atomicity ของ JSON Patch จะใช้ได้เฉพาะภายในเอกสารเดียว ข้ามเอกสารต้องใช้ database transaction หรือ compensating action

5. Idempotency: PATCH ไม่ได้ idempotent โดยธรรมชาติ

ความหมายของ HTTP กำหนดให้ PUT และ DELETE เป็น idempotent แต่ PATCH ไม่จำเป็นเสมอ การทำ JSON Patch เดิมซ้ำอาจให้ผลลัพธ์ที่ต่างกันโดยสิ้นเชิง:

  • add /items/- ต่อท้าย array ทุกครั้ง การ retry จึงสร้างข้อมูลซ้ำ;
  • replace /version เปลี่ยนเวอร์ชัน 1 เป็น 2 และสำเร็จอีกครั้งเมื่อ retry แม้ operation ถูกนำไปใช้แล้ว;
  • remove /temp ล้มเหลวในรอบที่สองเพราะพาธไม่มีอยู่แล้ว

วิธีทำให้ idempotent มีสามแบบที่พบบ่อย:

แนวทาง กลไก เหมาะกับ
Precondition ด้วยเวอร์ชัน operation แรกเป็น test /version แล้วเพิ่มเวอร์ชัน resource ที่มีเวอร์ชันชัดเจน
Idempotency key ไคลเอนต์ส่ง Idempotency-Key; เซิร์ฟเวอร์แคชผลลัพธ์ การชำระเงิน คำสั่งซื้อ และการเขียนอื่น ๆ
แพตช์แบบ target state ใช้ replace แทน add ผลลัพธ์จึงขึ้นกับค่าสุดท้ายเท่านั้น ฟิลด์การตั้งค่าและสถานะ

รูปแบบที่แย่ที่สุดคือแพตช์ที่ผสม add กับ remove แล้วถือว่า retry ปลอดภัย ถ้าแพตช์ต่อท้ายหรือลบสิ่งใด ต้องจับคู่กับการยืนยันเวอร์ชันหรือ idempotency key

6. ความขัดแย้งจาก Concurrency: 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"}]

ถ้า resource เปลี่ยนไปแล้ว เซิร์ฟเวอร์จะคืน 412 Precondition Failed และไคลเอนต์ควรดึงข้อมูลใหม่ก่อน retry If-Match: * หมายถึง "ดำเนินการตราบใดที่ resource ยังมีอยู่" ซึ่งไม่เหมาะเมื่อต้องป้องกันการเขียนทับ

ความต่างระหว่าง test กับ If-Match:

  • If-Match ถูกตรวจที่ชั้น HTTP ก่อน parse body จึงมีต้นทุนต่ำกว่า;
  • test ถูกตรวจที่ชั้นความหมายของแพตช์ และยืนยันฟิลด์ใดก็ได้โดยไม่ต้องพึ่งการรองรับ ETag ฝั่งเซิร์ฟเวอร์;
  • ทั้งสองใช้ร่วมกันได้ แต่อย่า implement เพียงอย่างเดียวแล้วอ้างว่า endpoint ปลอดภัยภายใต้ concurrency

7. ความแตกต่างระหว่างไลบรารีและการเลือกใช้

Implementation ภาษา Atomicity รองรับ test หมายเหตุ
fast-json-patch JavaScript ทางเลือก (คืนเอกสารใหม่โดยค่าเริ่มต้น) ใช่ ระบบนิเวศกว้าง; ระวัง mutateDocument
jsonpatch Python rollback เมื่อเกิด exception ใช่ ใกล้มาตรฐาน พร้อม error ที่ชัดเจน
json-patch Go ทำงานบนสำเนา ใช่ มักใช้ร่วมกับ JSON Merge Patch
zjsonpatch Java ใช่ ใช่ เชื่อมกับ Jackson ได้สะดวก

อย่าเลือกไลบรารีเพียงเพราะ "รองรับทั้งหก operation" ให้ยืนยันสามเรื่อง: ความล้มเหลว rollback หรือไม่, test เปรียบเทียบแบบเข้มงวดหรือไม่ และ add ที่ดัชนี array เกินขอบเขตก่อ error หรือไม่ implementation ที่หลวมจะทำให้แพตช์เดียวกันทำงานต่างกันในแต่ละสภาพแวดล้อม

8. รายการตรวจสอบความปลอดภัยก่อนขึ้นระบบ

  • [ ] endpoint ประกาศ application/json-patch+json;
  • [ ] แพตช์ถูกนำไปใช้กับสำเนา และความล้มเหลวไม่ปนเปื้อนเอกสารเดิม;
  • [ ] path และ from ทุกตัวในแพตช์ภายนอกผ่านการตรวจ allowlist;
  • [ ] คีย์อันตรายอย่าง __proto__, constructor และ prototype ถูกปฏิเสธ;
  • [ ] จำนวน operation และขนาดของค่าเดี่ยวถูกจำกัด;
  • [ ] test หรือ If-Match ให้การควบคุม concurrency;
  • [ ] แพตช์ถูกเขียนลง audit log พร้อมผู้กระทำ เวลา และอาร์เรย์ operation ทั้งหมด;
  • [ ] ขอบเขตธุรกรรมชัดเจนเมื่อแพตช์ครอบคลุมหลายเอกสาร

แพตช์เป็นคำอธิบายที่สมบูรณ์ของ "อะไรเปลี่ยนไป" โดยธรรมชาติ ซึ่งทำให้มันเป็นบันทึกการตรวจสอบตามธรรมชาติ สำหรับรหัสข้อผิดพลาดและ idempotency ที่ชั้น API ต่อด้วย การออกแบบ JSON API ถ้าแพตช์มาจากการ merge สองสาขา คุณยังต้องเข้าใจความหมายของความขัดแย้ง ซึ่งอธิบายไว้ใน การ merge ไฟล์ JSON ด้วย Git

คำถามที่พบบ่อย

operation test ที่ล้มเหลวควรคืนสถานะใด

ถ้าความขัดแย้งมาจากการยืนยันเวอร์ชัน ทั้ง 409 Conflict และ 412 Precondition Failed ล้วนมีเหตุผลรองรับ แนะนำ 412 เพื่อความสอดคล้องกับ If-Match และใส่เวอร์ชันปัจจุบันใน body ของการตอบกลับ เพื่อให้ไคลเอนต์ retry ได้

แพตช์ที่มี add และ remove retry ได้อย่างปลอดภัยหรือไม่

ไม่ได้โดยตรง add ต่อท้าย array จะทำข้อมูลซ้ำ และ remove จะล้มเหลวในรอบที่สองเพราะพาธหายไป ให้เพิ่ม precondition ด้วยเวอร์ชันหรือ idempotency key เพื่อให้ retry ถูกมองว่าเป็น operation เดียวกัน

JSON Patch รับประกันธุรกรรมข้ามเอกสารได้ไหม

ไม่ได้ atomicity ของ RFC 6902 ใช้กับเอกสาร JSON เดียว เมื่อเกี่ยวข้องกับคำสั่งซื้อ สต็อก หรือ resource อื่น ให้ใช้ database transaction, Saga compensation หรือ message queue เพื่อความสอดคล้อง

ทำไมแพตช์ต้องไม่เขียนลง __proto__

เพราะ implementation ของ JavaScript ส่วนใหญ่เก็บเอกสารในออบเจกต์ธรรมดา การเขียน __proto__ อาจเปลี่ยน prototype chain และกระทบทุกออบเจกต์หลังจากนั้น allowlist ของพาธคือการป้องกันที่เชื่อถือได้ที่สุด; อย่าพึ่งเพียงการกรองภายในของไลบรารี

ทำคำขอ PATCH ให้ idempotent ได้อย่างไร

มีสามแนวทางที่พบบ่อย: เริ่มแพตช์ด้วย test /version แล้วเพิ่มเวอร์ชันเมื่อสำเร็จ, ให้เซิร์ฟเวอร์แคชผลลัพธ์ตาม Idempotency-Key, หรือใช้ replace แทน add ผลลัพธ์จึงขึ้นกับค่าสุดท้ายเท่านั้น ถ้าแพตช์ต่อท้ายหรือลบสิ่งใด ต้องจับคู่กับการยืนยันเวอร์ชันหรือ idempotency key; จะ retry เฉย ๆ ไม่ได้

ควรใช้ test และ If-Match พร้อมกันไหม

ไม่จำเป็นต้องใช้ทั้งคู่ แต่การ implement เพียงอย่างเดียวไม่ทำให้ endpoint ปลอดภัยภายใต้ concurrency If-Match ถูกตรวจที่ชั้น HTTP ก่อน parse body จึงมีต้นทุนต่ำกว่า; test ถูกตรวจที่ชั้นความหมายของแพตช์ และยืนยันฟิลด์ใดก็ได้โดยไม่ต้องพึ่งการรองรับ ETag ฝั่งเซิร์ฟเวอร์