عندما تختلف وثيقتا JSON: diff حسب الموضع، أو diff حسب id، أو التطبيع أولاً
الخلاصة: خمسون سجلاً تصبح واحداً وخمسين فيبلّغ الـdiff عن خمسين تغييراً. يغطي الإيجابيات الكاذبة في diff النصوص، ومطابقة المصفوفات بـid، وتطبيع jq والـpatches القابلة للتطبيق.

الخلاصة: طبّع أولاً ثم قارن — استخدم jq -S أو تسلسلاً مستقراً لمحو ترتيب المفاتيح والإزاحة وتهجئة الأرقام، ورتّب المصفوفات أو فهرسها بمفتاح أعمال عندما يكون الترتيب بلا معنى، وبعد ذلك فقط حوّل الـdiff إلى patch.
فرق JSON ليس فرق نص. قارن الملفين كنصوص فيضيء إعادة ترتيب مفتاح الصفحة كلها؛ وقارن المصفوفات بالفهرس فيُعلَّم الخمسون الآخرون كمتغيرين عند إدراج سجل واحد.
شُحن patch إلى خدمة الطلبات، وكل ما لديك استجابتان: خمسون سجلاً قبل، وواحد وخمسون بعد، مع سجل جديد مُدرج في المنتصف. يبلّغ أداة الـdiff عن واحد وخمسين تغييراً، جدار من الأحمر، وما زلت لا تستطيع تمييز السجل الذي تحرك فعلاً.
الأداة ليست معطلة. "المقارنة" تعني ثلاثة أشياء مختلفة في JSON، ومعظم الناس يمدون يدهم إلى الأقل فائدة افتراضياً.
إعادة إنتاج الإنذار الكاذب: إدراج واحد، واحد وخمسون تغييراً
ثلاثون سطراً من Node 22: ابنِ خمسين طلباً ({id, sku, qty})، ثم ألحق واحداً في الذيل وأدرج واحداً في الرأس، وحصِ الفروق بمحاذاة فهرس بفهرس.
| العملية | السجلات | التغييرات المبلّغ عنها حسب الموضع |
|---|---|---|
| إلحاق واحد في الذيل | 50 → 51 | 1 |
| إدراج واحد في الرأس | 50 → 51 | 51 |
نفس "سجل إضافي واحد"، واحد وخمسون ضعفاً من الضوضاء. السبب عادي: عند المقارنة بالفهرس، يستقر العنصر الجديد في الموضع 0 وتنزلق كل سجل موجود خانة واحدة. الموضع 0 مقابل الموضع 0، والموضع 1 مقابل الموضع 1 — لا يوجد زوج متساوٍ.
ابنِ Map مفتاحه id بدلاً من ذلك والبيانات نفسها تبلّغ عن مدخل واحد بالضبط: $[id=ORD-999] added.
تلك التجربة تمنحك القاعدة الأهم من أي توصية أداة: قرار ما إذا كانت المصفوفة تسلسلاً مرتباً أو مجموعة غير مرتبة يحسم أي مقارنة يجب أن تستخدم.
ثلاث طرق يكذب بها diff النصوص عن JSON
diff a.json b.json يقارن المحارف. وJSON يحمل بنية بيانات. هذا عدم التطابق يُنتج ثلاثة أنواع موثوقة من الإيجابيات الكاذبة.
1. مفاتيح كائن معاد ترتيبها
القسم 4 من RFC 8259 صريح: الكائن "مجموعة غير مرتبة من أزواج الاسم/القيمة". هاتان الوثيقتان القيمة نفسها:
{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }
diff سطري يسمّي الأسطر الثلاثة كلها متغيرة. عكست ترتيب مفاتيح كائن بعشرة مفاتيح، ونسّقت كلاً منهما، وقارنت سطراً بسطر: الأسطر العشرة كلها مُعلَّمة، بينما تقول مقارنة بنيوية إنهما متساويتان. نسبة الضوضاء 10:0.
لماذا يتغير ترتيب المفاتيح أصلاً؟ تبديل المسلسلات، أو تشغيل منسّق بمفاتيح مرتبة، أو إعادة بناء الكائن من قاموس أو hash map، أو الانتقال بين بيئات لغات — أي واحد من ذلك يكفي لجعل بيانات متطابقة تبدو مختلفة على القرص.
2. المسافات والإزاحة
{"a":1} و{\n "a": 1\n} يُحلّلان إلى القيمة نفسها. يرى diff النصوص الأسطر الجديدة والمسافات ويعلّم كل شيء.
3. تهجئات مختلفة للأرقام والهروبات
1.0 و1، و1e2 و100، و"\u00e9" و"é" — القيمة نفسها بعد التحليل، ونصوص مختلفة بعد التسلسل. هذه أخف لأنها تمس أسطراً قليلة، لكنها في مراجعات الإعدادات تكفي لتجعلك تظن أن رقماً تغيّر.
الحالة العكسية هي ما لا يراه النص وتراه البنية: 5 مقابل "5". زوج اقتباسات إضافي واحد، لكن النوع انحرف — عادةً خلل لا تحديث. مقارنة بنيوية تبلّغ عنه كـmodify دون تردد.
ثلاثة أنماط مقارنة، ثلاث مهام
| النمط | ما يُقارَن | من أين تأتي الضوضاء | الخرج | يناسب |
|---|---|---|---|---|
| diff النصوص | أسطر المحارف | ترتيب المفاتيح، الإزاحة، تهجئة الأرقام | أرقام أسطر، أحمر/أخضر | حزم الترجمة، fixtures بترتيب مفاتيح مستقر |
| diff بنيوي | أشجار محللة، محاذاة بالمسار | مصفوفات محاذاة بالفهرس | مسارات مع add/remove/modify | استجابات API، إعدادات، webhooks |
| diff دلالي | أشجار زائد مفاتيح أعمال | اختيار مفتاح سيئ فقط | مسارات مع كشف move/insert | قوائم السجلات، الطلبات، المخزون |
diff البنيوي يحل طبقة "ترتيب المفاتيح والتنسيق". وdiff الدلالي يذهب خطوة أبعد ويحل "هوية عنصر المصفوفة". الفرق ليس تعقيد التنفيذ؛ بل هل أنت مستعد أن تخبر الأداة أي حقل هو جواز سجل.
المصفوفات هي خط الفصل
اسأل سؤالاً واحداً: هل ترتيب هذه المصفوفة بيانات بحد ذاته؟
- الترتيب بيانات: خطوات الترحيل، ترتيب البحث، نقاط المخططات، سلاسل الموافقة. تغيّر الموضع تغيّر سلوك، لذا فالمقارنة الموضعية صحيحة.
- الترتيب بلا معنى: الوسوم، مجموعات معرفات الصلاحيات، بنود الطلب، قوائم المخزون. الإدراج لا ينبغي أن يؤثر على حكم العناصر الأخرى.
عندما يكون الترتيب بلا معنى، فهرس السجلات بمفتاح بدلاً من طرقها بالموضع:
// المطابقة بـ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 يعمل فقط عندما تكون SKUs فريدة. وتجزئة الكائن كاملاً تحوّل "حقل واحد تغيّر" إلى "واحد حُذف زائد واحد أُضيف"، وهو أسوأ من المقارنة بالموضع.
أسرع حيلة في الطرفية: طبّع باستخدام jq
إذا كنت لا تريد مكتبة وتحتاج نظرة فقط، فطبّع أولاً وقارن ثانياً. jq -S يرتب مفاتيح الكائن عودياً ويطبّق إزاحة متسقة:
# طبّع، ثم قارن سطراً بسطر؛ تختفي ضوضاء ترتيب المفاتيح والإزاحة
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لا يمكنه التحرير في مكانه؛ إنه يكتب إلى الخرج القياسي. استخدم استبدال العمليات (<(...))، أو اكتب ملفاً مؤقتاً، وتحقق، ثم استبدل؛-rيزيل الاقتباسات من النصوص، لذا قد يحتوي الخرج على أسطر جديدة أو محارف ذات دلالة في shell. اقتبس البيانات غير الموثوقة قبل تمريرها.
ما تفعله /diff في هذا الموقع، وما لا تفعله
تتبع أداة مقارنة JSON المدمجة النهج البنيوي نفسه: يُحلل الطرفان إلى أشجار وتُحاذى عودياً بالمسار، منتجةً path + type(add/remove/modify) + before/after. ترتيب مفاتيح الكائن لا يؤثر على النتيجة، وكل شيء يعمل في المتصفح — الحمولات الملصقة لا تُرفع أبداً.
لكن مصفوفاتها محاذاة بالفهرس. تلك مقايضة متعمدة لا عيب:
- الإلحاق في الذيل، أو تعديل الموضع نفسه، أو الاستبدال الكامل يمنحك خرجاً نظيفاً؛
- إدراج سجل في الرأس أو المنتصف يعلّم كل ما بعده كـ
modify، ويجب أن تلتقط أن "سجلاً واحداً فقط جديد فعلاً".
لذا عندما تقارن قوائم طلبات، الخطوة الصحيحة هي ترتيب الطرفين بـid قبل اللصق. الإدخال المرتب يثبّت نقطة الإدراج ويطوي الضوضاء إلى مدخل واحد. لا حاجة إلى تثبيت، ويغطي معظم الحالات اليومية.
إذا كنت تحتاج إلى "توليد patch قابل للتطبيق" أو "كشف العناصر المتحركة"، فالأداة داخل الصفحة لا تفعل ذلك. ذلك هو القسم التالي.
تحويل diff إلى patch: تنسيقان، لا تخلطهما
يقرأ البشر الـdiffs؛ وتطبّق البرامج الـpatches. ولـJSON تنسيقا patch بدلالات مختلفة تماماً.
| البُعد | JSON Patch (RFC 6902) | JSON Merge Patch (RFC 7396) |
|---|---|---|
| الشكل | مصفوفة عمليات بها op وpath وvalue |
كائن JSON بشكل الوثيقة الهدف |
| المصفوفات | نعم، بالفهرس، /items/0، و/- للإلحاق |
لا، المصفوفات تُستبدل كاملة |
| حذف مفتاح | remove |
فقط عبر null، الذي لا يمكنه حذف حقل قيمته null بصورة مشروعة |
| التعبير عن move | move / copy |
لا |
| يناسب | موارد تحتوي مصفوفات، سجلات تدقيق، تحديثات ذرية | تحديثات جزئية لكائنات عادية، تسليم الإعدادات |
لـJSON Patch عملية واحدة يتخطاها الناس: test.
[
{ "op": "test", "path": "/version", "value": 3 },
{ "op": "replace", "path": "/status", "value": "shipped" }
]
إذا فشل test، يُرفض الـpatch كله. تلك تزامن تفاؤلي مدمج في البروتوكول — لا حاجة إلى ترويسة If-Match إضافية لضمان "كتبت فقط لأنني رأيت الإصدار 3". لـendpoint من نوع PATCH، هذا يستحق أكثر من أي اتفاقية وثائق.
تستخدم المسارات JSON Pointer (RFC 6901): ~0 يهرب ~، و~1 يهرب /. المفاتيح التي تحتوي على شرطات مائلة (أنواع MIME كمفاتيح، مثلاً) يجب تهريبها، وإلا استقر المؤشر في مكان آخر. التفاصيل في JSON Pointer وPatch.
تبقى مشكلة عملية واحدة: الـpatches المولّدة تلقائياً من diff تعالج المصفوفات بالفهرس عادةً. الفهارس هشة في الإنتاج — إدراج متزامن واحد و/items/3 لم يعد يشير إلى السجل نفسه. إما أن تقصر الـpatches المولّدة على حقول الكائنات، أو تتحقق من الحقول الحرجة بـtest قبل التطبيق.
ضع المقارنة في CI: أكّد القيم لا النص
ثلاث قواعد تكفي:
- أكّد القيم المحللة في الاختبارات، لا خرج
JSON.stringifyأبداً. في Jest استخدمtoEqual؛ وفي Python استخدمassert a == bبعدjson.loads. ترتيب المفاتيح والإزاحة لا يشاركان. - ضع الحقول الديناميكية في قائمة بيضاء.
requestIdوtimestampوtraceIdتختلف في كل تشغيل؛ وبلا الاستبعادات يصبح الاختبار أحمر دائماً.propertyFilterفي jsondiffpatch وexclude_pathsفي deepdiff موجودان لهذا. - اطبع المسارات عند الفشل، لا الوثيقة كاملة. لا أحد يقرأ 5 MB من الحمولة في سجل؛
$.data.items[id=ORD-042].qty: 2 → 3يحدد المشكلة فوراً.
انحراف الإعدادات يعمل بالطريقة نفسها: jq -S لإعدادَي staging والإنتاج، ورتّب المصفوفات بمفتاح أعمال، وما يبقى هو فرق حقيقي. هذا النوع من المشكلات — نفس البيانات تبدو مختلفة عبر البيئات — عادةً عقد غير معرّف، وهو موضوع تصميم عقد API.
FAQ
هل يُعد ترتيب المفاتيح فرقاً؟
لا. يعرّف RFC 8259 الكائنات كمجموعات غير مرتبة، لذا {"a":1,"b":2} و{"b":2,"a":1} القيمة نفسها. أي أداة تبلّغ عن إعادة الترتيب كتغيير إنما تقارن نصاً لا JSON.
لماذا يُعلَّم كل مدخل كمتغيّر عند إدراج سجل واحد؟
لأن الأداة تحاذي المصفوفات فهرساً بفهرس. يجلس العنصر الجديد في الموضع 0، وينزلق كل سجل موجود واحداً، ولا يتطابق أي موضعين. رتّب أو فهرس بمفتاح مثل id قبل المقارنة بدلاً من المقارنة الموضعية.
هل ترفع أدوات المقارنة عبر الإنترنت بياناتي؟
يعتمد على التنفيذ. الأدوات المقدَّمة من الخادم ترسل الحمولة إلى خلفية حيث قد تُسجَّل؛ وأدوات جانب العميل تحلل في المتصفح ولا ترفع شيئاً. قبل مقارنة إعدادات الإنتاج أو حمولات المستخدمين، تأكد من أن الصفحة تحسب محلياً، أو افصل الشبكة وحاول مرة أخرى.
هل يمكن استخدام نتيجة diff كـpatch مباشرة؟
ليس مباشرة. يحدد الـdiff أين تختلف الأشياء؛ ويحدد JSON Patch العمليات التي تُنفّذ بالترتيب. طبقة تحويل تقع بينهما. والـpatches المولّدة تميل أيضاً إلى معالجة المصفوفات بالفهرس، وهو ما ينكسر تحت التزامن — أضف عمليات test قبل التطبيق.
كيف أقارن ملفات بعشرات الميغابايت؟
دفّق أولاً (jq -c يكتب سجلاً واحداً في كل سطر)، أو استخرج الشجرة الفرعية التي تهمك فقط، مثل jq '.data.items'. المقارنة العودية الكاملة لعشرات الميغابايت ما زالت تتسع في الذاكرة؛ وبعد مئة ميغابايت، جزّئها أو فهرسها بمفتاح. للأرقام الملموسة عن الذاكرة والتوقيت، انظر أداء JSON الكبير.
هل يجب ترتيب المصفوفات بـid قبل المقارنة؟
نعم. أداة مقارنة JSON المدمجة تحاذي المصفوفات بالفهرس، لذا إدراج سجل واحد في الرأس أو المنتصف يعلّم كل ما بعده كـmodify؛ وترتيب الطرفين بـid قبل اللصق يثبّت نقطة الإدراج ويطوي الضوضاء إلى مدخل واحد. الاستثناء هو عندما يكون ترتيب المصفوفة بيانات بحد ذاته — خطوات الترحيل، ترتيب البحث، نقاط المخططات — حيث المقارنة الموضعية صحيحة والترتيب خطأ.