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

สรุปสั้น: 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 ค่า ไม่ใช่ข้อความ
กฎสามข้อก็พอ:
- Assert ค่าที่แยกวิเคราะห์แล้วในการทดสอบ ไม่ใช่เอาต์พุตของ
JSON.stringifyใน Jest ใช้toEqual; ใน Python ใช้assert a == bหลังjson.loadsลำดับคีย์และการย่อหน้าไม่เข้าร่วม - Whitelist ฟิลด์ไดนามิก
requestId,timestamp,traceIdต่างกันทุกครั้ง; หากไม่ยกเว้น การทดสอบจะแดงตลอดpropertyFilterใน jsondiffpatch และexclude_pathsใน deepdiff มีไว้เพื่อสิ่งนี้ - พิมพ์ 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 ก่อนวางจะตรึงจุดแทรกและยุบเสียงรบกวนเหลือหนึ่งรายการ ข้อยกเว้นคือเมื่อลำดับอาร์เรย์เป็นข้อมูลเอง — ขั้นตอนการย้ายข้อมูล อันดับการค้นหา จุดบนกราฟ — ซึ่งการเปรียบเทียบตามตำแหน่งถูกต้องและการเรียงผิด