JSON Toolkit

เมื่อเอกสาร JSON สองฉบับต่างกัน: positional diff, id-based diff หรือ normalize ก่อน

สรุปสำคัญ: ห้าสิบเรกคอร์ดกลายเป็นห้าสิบเอ็ดและ diff รายงานการเปลี่ยนแปลงห้าสิบเอ็ดครั้ง ครอบคลุม false positive ของ text diff, การจับคู่อาร์เรย์ด้วย id, การ normalize ด้วย jq และ patch ที่ใช้งานได้

ผู้เริ่มต้นอ่านประมาณ 6 นาทีเผยแพร่ 2026-10-30อัปเดต 2026-10-30
ภาพปกการเปรียบเทียบเอกสาร JSON สองฉบับ

สรุปสั้น: normalize ก่อน แล้วจึงเปรียบเทียบ — ใช้ jq -S หรือการทำให้เป็นอนุกรมแบบมั่นคงเพื่อกำจัดลำดับคีย์ การย่อหน้า และรูปแบบการเขียนตัวเลข เรียงหรือทำดัชนีอาร์เรย์ด้วยคีย์ธุรกิจเมื่อลำดับไม่มีความหมาย และจากนั้นจึงเปลี่ยน diff เป็น patch

ความต่างของ JSON ไม่ใช่ความต่างของข้อความ เปรียบเทียบไฟล์เป็นสตริงแล้วการสลับคีย์จะทำให้ทั้งหน้าสว่าง; เปรียบเทียบอาร์เรย์ตามดัชนีแล้วการแทรกเรกคอร์ดหนึ่งรายการจะทำเครื่องหมายอีกห้าสิบรายการว่าเปลี่ยน

Patch ถูกส่งไปยังบริการคำสั่งซื้อ และสิ่งที่คุณมีคือ response สองฉบับ: ห้าสิบเรกคอร์ดก่อนหน้า ห้าสิบเอ็ดหลังจากนั้น โดยมีเรกคอร์ดใหม่หนึ่งรายการถูกแทรกไว้ตรงกลาง เครื่องมือ diff รายงานการเปลี่ยนแปลงห้าสิบเอ็ดครั้ง เป็นกำแพงสีแดง และคุณยังบอกไม่ได้ว่าเรกคอร์ดไหนเคลื่อนไปจริง

เครื่องมือไม่ได้พัง คำว่า "เปรียบเทียบ" มีความหมายต่างกันสามอย่างบน JSON และคนส่วนใหญ่เลือกใช้อย่างที่ไร้ประโยชน์ที่สุดเป็นค่าเริ่มต้น

การจำลองสัญญาณเตือนผิด: แทรกหนึ่งครั้ง เปลี่ยนแปลงห้าสิบเอ็ดครั้ง

Node 22 สามสิบบรรทัด: สร้างคำสั่งซื้อห้าสิบรายการ ({id, sku, qty}) จากนั้นต่อเพิ่มหนึ่งรายการที่ท้ายและแทรกหนึ่งรายการที่หัว นับความต่างด้วยการจับคู่ตามดัชนี

การดำเนินการ เรกคอร์ด การเปลี่ยนแปลงที่รายงานตามตำแหน่ง
ต่อเพิ่มหนึ่งรายการที่ท้าย 50 → 51 1
แทรกหนึ่งรายการที่หัว 50 → 51 51

"เรกคอร์ดเกินมาหนึ่งรายการ" เหมือนกัน แต่เสียงรบกวนมากกว่าห้าสิบเอ็ดเท่า เหตุผลนั้นธรรมดา: เมื่อเปรียบเทียบตามดัชนี สมาชิกใหม่ลงที่ตำแหน่ง 0 และทุกเรกคอร์ดที่มีอยู่เลื่อนลงหนึ่งช่อง ตำแหน่ง 0 เทียบตำแหน่ง 0 ตำแหน่ง 1 เทียบตำแหน่ง 1 — ไม่มีคู่ใดเท่ากัน

สร้าง Map ที่ใช้ id เป็นคีย์แทน แล้วข้อมูลชุดเดียวกันจะรายงานเพียงหนึ่งรายการ: $[id=ORD-999] added

การทดลองนั้นให้กฎที่สำคัญกว่าคำแนะนำเครื่องมือใด ๆ: อาร์เรย์เป็นลำดับที่มีการเรียงหรือเป็นเซตที่ไม่มีการเรียง ตัดสินว่าคุณควรใช้การเปรียบเทียบแบบใด

สามวิธีที่ text diff โกหกเกี่ยวกับ JSON

diff a.json b.json เปรียบเทียบตัวอักษร JSON พาโครงสร้างข้อมูลมา ความไม่ตรงกันนี้สร้าง false positive สามชนิดที่แน่นอน

1. คีย์ของออบเจ็กต์ถูกสลับลำดับ

RFC 8259 ข้อ 4 ระบุชัดเจน: ออบเจ็กต์คือ "an unordered collection of name/value pairs" เอกสารสองฉบับนี้เป็นค่าเดียวกัน:

{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }

Line diff บอกว่าทั้งสามบรรทัดเปลี่ยน ผมกลับลำดับคีย์ของออบเจ็กต์สิบคีย์ จัดรูปแบบทั้งสองฉบับ และเปรียบเทียบทีละบรรทัด: ทั้งสิบบรรทัดถูกทำเครื่องหมาย ขณะที่การเปรียบเทียบเชิงโครงสร้างบอกว่าเท่ากัน อัตราส่วนเสียงรบกวน 10:0

ทำไมลำดับคีย์จึงเปลี่ยน? การสลับ serializer การรันฟอร์แมตเตอร์ที่เรียงคีย์ การสร้างออบเจ็กต์ใหม่จาก dictionary หรือ hash map การย้ายระหว่างรันไทม์ภาษา — อย่างใดอย่างหนึ่งก็พอทำให้ข้อมูลที่เหมือนกันดูต่างกันบนดิสก์

2. ช่องว่างและการย่อหน้า

{"a":1} และ {\n "a": 1\n} แยกวิเคราะห์เป็นค่าเดียวกัน text diff เห็นขึ้นบรรทัดใหม่และช่องว่างแล้วทำเครื่องหมายทุกอย่าง

3. การเขียนตัวเลขและ escape ที่ต่างกัน

1.0 กับ 1, 1e2 กับ 100, "\u00e9" กับ "é" — ค่าเดียวกันหลังแยกวิเคราะห์ ข้อความต่างกันหลังทำให้เป็นอนุกรม อันนี้รุนแรงน้อยกว่าเพราะแตะไม่กี่บรรทัด แต่ในการรีวิวคอนฟิกก็เพียงพอทำให้คุณคิดว่าตัวเลขเปลี่ยน

กรณีตรงกันข้ามคือสิ่งที่ข้อความมองไม่เห็นแต่โครงสร้างเห็น: 5 กับ "5" เครื่องหมายคำพูดเพิ่มคู่เดียว แต่ชนิดคลาดเคลื่อน — ปกติเป็นบั๊กไม่ใช่การอัปเดต การเปรียบเทียบเชิงโครงสร้างรายงานเป็น modify โดยไม่ลังเล

สามโหมดการเปรียบเทียบ สามงาน

โหมด เปรียบเทียบอะไร เสียงรบกวนมาจาก เอาต์พุต เหมาะกับ
Text diff บรรทัดตัวอักษร ลำดับคีย์ การย่อหน้า รูปแบบตัวเลข หมายเลขบรรทัด แดง/เขียว ชุดการแปล, fixture ที่ลำดับคีย์มั่นคง
Structural diff ทรีที่แยกวิเคราะห์แล้ว จับคู่ตาม path อาร์เรย์จับคู่ตามดัชนี path พร้อม add/remove/modify API response, คอนฟิก, webhook
Semantic diff ทรีบวกคีย์ธุรกิจ เพียงการเลือกคีย์ที่ไม่ดี path พร้อมตรวจจับ move/insert รายการเรกคอร์ด, คำสั่งซื้อ, สินค้าคงคลัง

Structural diff แก้ชั้น "ลำดับคีย์และการจัดรูปแบบ" Semantic diff ไปอีกขั้นและแก้ "ตัวตนของสมาชิกอาร์เรย์" ความต่างไม่ใช่ความซับซ้อนในการใช้งาน แต่คือคุณยินดีบอกเครื่องมือหรือไม่ว่าฟิลด์ใดเป็นหนังสือเดินทางของเรกคอร์ด

อาร์เรย์คือเส้นแบ่ง

ถามคำถามเดียว: ลำดับของอาร์เรย์นี้เป็นข้อมูลหรือไม่?

  • ลำดับเป็นข้อมูล: ขั้นตอนการย้ายข้อมูล อันดับการค้นหา จุดบนกราฟ ห่วงโซ่อนุมัติ การเปลี่ยนตำแหน่งคือการเปลี่ยนพฤติกรรม ดังนั้นการเปรียบเทียบตามตำแหน่งถูกต้อง
  • ลำดับไม่มีความหมาย: แท็ก ชุด ID สิทธิ์ รายการสินค้าในคำสั่งซื้อ รายการสินค้าคงคลัง การแทรกไม่ควรส่งผลต่อการตัดสินรายการอื่น

เมื่อลำดับไม่มีความหมาย ให้ทำดัชนีเรกคอร์ดด้วยคีย์แทนการตีตามตำแหน่ง:

// จับคู่ด้วย id: การแทรกหรือลบรายงานหนึ่งรายการ รายการอื่นอยู่กับที่
function diffById(before, after, path = '$') {
  const out = [];
  const beforeMap = new Map(before.map((item) => [item.id, item]));
  const afterMap = new Map(after.map((item) => [item.id, item]));

  for (const [id, value] of afterMap) {
    if (!beforeMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'added' });
  }
  for (const [id, value] of beforeMap) {
    if (!afterMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'removed' });
    else if (JSON.stringify(value) !== JSON.stringify(afterMap.get(id))) {
      out.push({ path: `${path}[id=${id}]`, type: 'changed' });
    }
  }
  return out;
}

ไลบรารีที่โตเต็มที่เปลี่ยนสิ่งนี้เป็นตัวเลือก jsondiffpatch ใช้ LCS เพื่อหาลำดับย่อยร่วมที่ยาวที่สุด แต่ สมาชิกที่เป็นออบเจ็กต์ในอาร์เรย์ถูกจับคู่ตามตำแหน่งเป็นค่าเริ่มต้น — เอกสารทางการเน้นว่าคุณต้องให้ฟังก์ชัน objectHash เพื่อจับคู่ตามเนื้อหา ผู้ใช้ครั้งแรกมักสรุปว่าไลบรารีโง่ แต่จริง ๆ แล้วพวกเขาไม่เคยส่งคีย์ให้

การเลือกคีย์ก็สำคัญ id เป็นตัวเลือกที่ปลอดภัย sku ใช้ได้ก็ต่อเมื่อ SKU ไม่ซ้ำ การแฮชทั้งออบเจ็กต์เปลี่ยน "ฟิลด์หนึ่งเปลี่ยน" เป็น "ลบหนึ่งบวกเพิ่มหนึ่ง" ซึ่งแย่กว่าการเปรียบเทียบตามตำแหน่ง

เคล็ดลับเทอร์มินัลที่เร็วที่สุด: normalize ด้วย jq

หากไม่ต้องการไลบรารีและแค่อยากดู ให้ normalize ก่อนแล้ว diff ทีหลัง jq -S เรียงคีย์ของออบเจ็กต์แบบเรียกซ้ำและใช้การย่อหน้าที่สม่ำเสมอ:

# Normalize แล้วเปรียบเทียบทีละบรรทัด; เสียงรบกวนจากลำดับคีย์และการย่อหน้าหายไป
diff -u <(jq -S . before.json) <(jq -S . after.json)

# เมื่ออาร์เรย์ไม่มีการเรียงจริง ๆ ให้เรียงตามคีย์ก่อนเปรียบเทียบ
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
        <(jq -S '.items |= sort_by(.id)' after.json)

ผมรัน printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S . แล้วได้ {"a":1,"arr":[1,2],"b":2} — คีย์เรียงแล้ว arr ไม่ถูกแตะ จดจำสิ่งนี้: -S เรียงเฉพาะคีย์ของออบเจ็กต์ ไม่เคยเรียงสมาชิกอาร์เรย์ ให้เรียงอาร์เรย์อย่างชัดเจนด้วย sort / sort_by หรือยอมรับเสียงรบกวนตามตำแหน่ง

กับดักสองข้อ:

  • jq แก้ไขในที่ไม่ได้; มันเขียนไปยัง standard output ใช้ process substitution (<(...)) หรือเขียนไฟล์ชั่วคราว ตรวจสอบ แล้วจึงแทนที่;
  • -r ตัดเครื่องหมายคำพูดออกจากสตริง เอาต์พุตจึงอาจมีขึ้นบรรทัดใหม่หรืออักขระที่ shell ตีความ ให้ใส่เครื่องหมายคำพูดกับข้อมูลที่ไม่น่าเชื่อถือก่อนส่งต่อไป

/diff ของไซต์นี้ทำอะไร และไม่ทำอะไร

เครื่องมือเปรียบเทียบ JSON ในตัวใช้แนวทางเชิงโครงสร้างเดียวกัน: ทั้งสองฝั่งถูกแยกวิเคราะห์เป็นทรีและจับคู่แบบเรียกซ้ำตาม path ให้ผลเป็น path + type(add/remove/modify) + before/after ลำดับคีย์ของออบเจ็กต์ไม่ส่งผลต่อผลลัพธ์ และทุกอย่างรันในเบราว์เซอร์ — payload ที่วางจะไม่ถูกอัปโหลด

อย่างไรก็ตามอาร์เรย์ของมันถูก จับคู่ตามดัชนี นั่นคือการแลกเปลี่ยนที่ตั้งใจ ไม่ใช่ข้อบกพร่อง:

  • ต่อท้าย แก้ตำแหน่งเดิม หรือแทนที่ทั้งหมดให้เอาต์พุตสะอาด;
  • แทรกเรกคอร์ดที่หัวหรือตรงกลางจะทำเครื่องหมายทุกอย่างหลังจากนั้นเป็น modify และคุณต้องสังเกตเองว่า "จริง ๆ แล้วมีเพียงเรกคอร์ดใหม่หนึ่งรายการ"

ดังนั้นเมื่อเปรียบเทียบรายการคำสั่งซื้อ การเคลื่อนที่ที่ถูกต้องคือ เรียงทั้งสองฝั่งตาม id ก่อนวาง อินพุตที่เรียงแล้วตรึงจุดแทรกและยุบเสียงรบกวนเหลือหนึ่งรายการ ไม่ต้องติดตั้งอะไร และครอบคลุมกรณีประจำวันส่วนใหญ่

หากคุณต้องการ "สร้าง patch ที่ใช้งานได้" หรือ "ตรวจจับสมาชิกที่ถูกย้าย" เครื่องมือในหน้านี้ทำไม่ได้ นั่นคือหัวข้อถัดไป

เปลี่ยน diff เป็น patch: สองรูปแบบ อย่าผสมกัน

มนุษย์อ่าน diff; โปรแกรมใช้ patch JSON มีรูปแบบ patch สองแบบที่มีความหมายต่างกันโดยสิ้นเชิง

มิติ JSON Patch (RFC 6902) JSON Merge Patch (RFC 7396)
รูปร่าง อาร์เรย์ของการดำเนินการที่มี op, path, value ออบเจ็กต์ JSON ที่มีรูปร่างเหมือนเอกสารเป้าหมาย
อาร์เรย์ ได้ ตามดัชนี /items/0, /- สำหรับต่อท้าย ไม่ได้ อาร์เรย์ถูกแทนที่ทั้งหมด
ลบคีย์ remove ผ่าน null เท่านั้น ซึ่งลบฟิลด์ที่ค่าเป็น null อย่างถูกต้องไม่ได้
แสดงการย้าย move / copy ไม่ได้
เหมาะกับ ทรัพยากรที่มีอาร์เรย์, audit log, การอัปเดตแบบอะตอมมิก การอัปเดตบางส่วนของออบเจ็กต์ plain, การส่งคอนฟิก

JSON Patch มีการดำเนินการหนึ่งที่ผู้คนข้ามไป: test

[
  { "op": "test", "path": "/version", "value": 3 },
  { "op": "replace", "path": "/status", "value": "shipped" }
]

หาก test ล้มเหลว patch ทั้งหมดถูกปฏิเสธ นั่นคือ optimistic concurrency ที่สร้างไว้ในโปรโตคอล — ไม่ต้องมี header If-Match เพิ่มเพื่อรับประกันว่า "ฉันเขียนเพราะเห็นเวอร์ชัน 3" สำหรับ endpoint PATCH สิ่งนี้มีค่ากว่าธรรมเนียมเอกสารใด ๆ

Path ใช้ JSON Pointer (RFC 6901): ~0 escape ~, ~1 escape / คีย์ที่มี slash (เช่น MIME type ที่ใช้เป็นคีย์) ต้อง escape มิฉะนั้น pointer จะไปลงที่อื่น รายละเอียดอยู่ใน JSON Pointer และ Patch

ปัญหาปฏิบัติยังเหลืออยู่หนึ่งข้อ: patch ที่สร้างอัตโนมัติจาก diff มักอ้างอิงอาร์เรย์ตามดัชนี ดัชนีเปราะบางใน production — มีการแทรกพร้อมกันครั้งเดียว /items/3 ก็ชี้เรกคอร์ดเดิมไม่ได้แล้ว ให้จำกัด patch ที่สร้างให้อยู่เฉพาะฟิลด์ออบเจ็กต์ หรือตรวจสอบฟิลด์สำคัญด้วย test ก่อนใช้

ใส่การเปรียบเทียบเข้า CI: assert ค่า ไม่ใช่ข้อความ

กฎสามข้อก็พอ:

  1. Assert ค่าที่แยกวิเคราะห์แล้วในการทดสอบ ไม่ใช่เอาต์พุตของ JSON.stringify ใน Jest ใช้ toEqual; ใน Python ใช้ assert a == b หลัง json.loads ลำดับคีย์และการย่อหน้าไม่เข้าร่วม
  2. Whitelist ฟิลด์ไดนามิก requestId, timestamp, traceId ต่างกันทุกครั้ง; หากไม่ยกเว้น การทดสอบจะแดงตลอด propertyFilter ใน jsondiffpatch และ exclude_paths ใน deepdiff มีไว้เพื่อสิ่งนี้
  3. พิมพ์ path เมื่อล้มเหลว ไม่ใช่ทั้งเอกสาร ไม่มีใครอ่าน payload 5 MB ใน log; $.data.items[id=ORD-042].qty: 2 → 3 หาปัญหาได้ทันที

Config drift ทำงานแบบเดียวกัน: jq -S ทั้งคอนฟิก staging และ production เรียงอาร์เรย์ตามคีย์ธุรกิจ และสิ่งที่เหลือคือความต่างจริง ปัญหาประเภทนี้ — ข้อมูลเดียวกันดูต่างกันข้ามสภาพแวดล้อม — ปกติคือ contract ที่ไม่ได้นิยาม ซึ่งเป็นเรื่องของ การออกแบบ API contract

FAQ

ลำดับคีย์นับเป็นความต่างหรือไม่?

ไม่ RFC 8259 นิยามออบเจ็กต์เป็นคอลเลกชันที่ไม่มีการเรียง ดังนั้น {"a":1,"b":2} และ {"b":2,"a":1} เป็นค่าเดียวกัน เครื่องมือใดที่รายงานการสลับลำดับเป็นการเปลี่ยนแปลงกำลังทำการเปรียบเทียบข้อความ ไม่ใช่การเปรียบเทียบ JSON

ทำไมการแทรกหนึ่งเรกคอร์ดจึงทำเครื่องหมายทุกรายการว่าเปลี่ยน?

เพราะเครื่องมือจับคู่อาร์เรย์ตามดัชนี สมาชิกใหม่อยู่ที่ตำแหน่ง 0 ทุกเรกคอร์ดที่มีอยู่เลื่อนลงหนึ่ง และไม่มีสองตำแหน่งที่ตรงกัน ให้เรียงหรือทำดัชนีตามคีย์เช่น id ก่อนเปรียบเทียบแทนการเปรียบเทียบตามตำแหน่ง

เครื่องมือเปรียบเทียบออนไลน์อัปโหลดข้อมูลของฉันหรือไม่?

ขึ้นกับการใช้งาน เครื่องมือที่เรนเดอร์ฝั่งเซิร์ฟเวอร์ส่ง payload ไปยัง backend ซึ่งอาจถูก log; เครื่องมือฝั่งไคลเอนต์แยกวิเคราะห์ในเบราว์เซอร์และไม่อัปโหลดอะไร ก่อนเปรียบเทียบคอนฟิก production หรือ payload ผู้ใช้ ให้ยืนยันว่าหน้าคำนวณในเครื่อง หรือตัดการเชื่อมต่อเครือข่ายแล้วลองอีกครั้ง

ผลลัพธ์ diff ใช้เป็น patch ได้โดยตรงหรือไม่?

ไม่ได้โดยตรง diff บอกว่าสิ่งใดต่าง; JSON Patch บอกว่าจะรันการดำเนินการใดตามลำดับ มีชั้นแปลงอยู่ระหว่างสองอย่างนั้น Patch ที่สร้างมักอ้างอิงอาร์เรย์ตามดัชนี ซึ่งพังภายใต้ concurrency — เพิ่มการดำเนินการ test ก่อนใช้

จะเปรียบเทียบไฟล์หลายสิบเมกะไบต์ได้อย่างไร?

สตรีมก่อน (jq -c เขียนหนึ่งเรกคอร์ดต่อบรรทัด) หรือดึงเฉพาะ subtree ที่สนใจ เช่น jq '.data.items' การเปรียบเทียบแบบเรียกซ้ำเต็มรูปแบบของหลายสิบเมกะไบต์ยังพอดีกับหน่วยความจำ; เกินหนึ่งร้อยเมกะไบต์ให้แบ่งชาร์ดหรือทำดัชนีตามคีย์ สำหรับตัวเลขหน่วยความจำและเวลาที่เป็นรูปธรรม ดู ประสิทธิภาพ JSON ขนาดใหญ่

ควรเรียงอาร์เรย์ตาม id ก่อนเปรียบเทียบหรือไม่?

ใช่ เครื่องมือเปรียบเทียบ JSON ในตัวจับคู่อาร์เรย์ตามดัชนี ดังนั้นการแทรกเรกคอร์ดหนึ่งที่หัวหรือตรงกลางจะทำเครื่องหมายทุกอย่างหลังจากนั้นเป็น modify; การเรียงทั้งสองฝั่งตาม id ก่อนวางจะตรึงจุดแทรกและยุบเสียงรบกวนเหลือหนึ่งรายการ ข้อยกเว้นคือเมื่อลำดับอาร์เรย์เป็นข้อมูลเอง — ขั้นตอนการย้ายข้อมูล อันดับการค้นหา จุดบนกราฟ — ซึ่งการเปรียบเทียบตามตำแหน่งถูกต้องและการเรียงผิด