JSON Toolkit

إعادة استخدام JSON Schema: $ref و$defs وتركيب allOf/oneOf

الخلاصة: $ref يحل إعادة الاستخدام، وallOf وoneOf يحلان التركيب، وتكديس الاثنين معًا هو حيث تنكسر الأمور. يغطي المقال $id و$defs والشرطيات والعودية والجمع والتصحيح.

متوسطقراءة نحو 11 دقيقةنُشر في 2026-10-31حُدِّث في 2026-10-31
رسم توضيحي لإعادة استخدام JSON Schema وتركيبها بـ $ref و$defs وallOf/oneOf

الخلاصة: استخرج البنى المشتركة في $defs مع $ref أولًا، ثم اتجه إلى allOf/oneOf؛ وعندما يمكن أن تتطابق الفروع معًا، استخدم anyOf أو وسمًا من نوع const، واحفظ oneOf للحالات التي تشترط مطابقة واحدة بالضبط فعلًا.

النسخ واللصق يوفر عشر دقائق ويكلفك تفرعًا صامتًا بعد ثلاثة أشهر، حين لا يتذكر أحد أي نسخة صحيحة.

1. ثلاث طبقات للإعادة — اختر واحدة قبل فصل الملفات

أول ما يخطر في البال، حين تبدأ الحقول في التضاعف، هو حشر كل قيد في ملف واحد. وهو أسرع طريق للإطلاق، لكن بعد مئات الأسطر يصبح كل تغيير حقل واجب التكرار في ثلاثة مواضع. وإن أفلت واحد منها حصلت على "ينجح محليًا، ويعيد 500 في الإنتاج". أما إعادة الاستخدام فتعني كتابة الحقيقة مرة واحدة والإشارة إليها في كل مكان آخر.

تقدم JSON Schema ثلاث طبقات، ولكل منها نطاق مختلف:

الطبقة الحامل النطاق الاستخدام النمطي
البنية الفرعية $defs + $ref ملف مخطط واحد العنوان، والمال، والترقيم، وأشكال الأخطاء
الملف مخطط مستقل + $id عدة ملفات في خدمة واحدة كيانات المستخدم والطلب والمنتج
مكتبة العقود مستودع مخططات + حزمة مجمّعة خدمات متعددة، ولغات متعددة مكونات OpenAPI، والعقود المنشورة

تكفي الطبقة الأولى معظم المشاريع. ولا يجري فصل الملفات إلا عندما تتشارك خدمتان عقدًا واحدًا فعلًا، أو عندما يكون المخطط أصلًا منشورًا تستهلكه لغة أخرى. والفصل المبكر جدًا يجعل تصحيح دقة $id والمسارات النسبية أصعب، وتتجاوز تلك التكلفة عادةً ما وفّرته بتجنب التكرار.

وقاعدة بسيطة: إذا ظهرت بنية في موضعين، فنسخة واحدة مقبولة؛ وعندما تظهر في موضع ثالث، رقِّها إلى $defs؛ ولا تستحق ملفًا مستقلًا بـ $id خاص إلا عندما يجب أن تقرأها لغة أخرى أو مستودع آخر.

2. $id تؤسس الهوية، و$ref تنفذ القفز

$id ليست تعليقًا. فهي تعلن URI الأساس للمخطط. وقيمة $ref مرجع URI: يُحل مقابل URI الأساس الحالي إلى URI مطلق، ثم يحدد المقطع بعد # عقدة. وإن كان URI الأساس خاطئًا، فكل إحالة نسبية تشير إلى مكان آخر، ومع ذلك لا يقول الخطأ عادةً إلا "can't resolve reference" دون تسمية المضيف الخطأ.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "address": {
      "$anchor": "address",
      "type": "object",
      "required": ["city"],
      "properties": {
        "city": { "type": "string", "minLength": 1 },
        "detail": { "type": "string" }
      }
    }
  },
  "type": "object",
  "required": ["shipTo"],
  "properties": {
    "shipTo": { "$ref": "#/$defs/address" },
    "billTo": { "$ref": "#address" }
  }
}

يستخدم shipTo مؤشر JSON Pointer بينما يستخدم billTo مرساة؛ وكلاهما يهبط على العقدة نفسها. ويصف الجدول أدناه كل صيغة ودقة تحليلها:

الصيغة تشير إلى الإصدار ملاحظات
# جذر المخطط الحالي الكل شائعة في العودية
#/$defs/address عقدة تحت $defs 2019-09 وما بعده الأوضح؛ والموصي به
#address مرساة مسماة $anchor في 2020-12 كتب draft-07 $id: "#address"
common.json ملف نسبةً إلى URI الأساس الكل يجب تسجيله أو تجميله
https://example.com/x.json URI مطلق الكل تجنب الجلب في وقت التشغيل في الإنتاج

وثمة فخ دقيق: إذا أعلن مخطط فرعي داخل $defs عن $id خاص به، فيصبح ذلك هو URI الأساس الجديد لكل ما تحته. فألصق مخططًا منسوخًا من ملف آخر وسيعيد $id المتبقي توجيه إحالاته النسبية إلى المضيف الأصلي — ولن يلاحظ المدقق ذلك إلا عند محاولة تحليلها فعلًا.

3. $defs حاوية، لا موقع تحقق

$defs خريطة عادية. ولا شيء داخلها يسري حتى تشير إليه $ref. فقائمة required في أعلى $defs دون أي إحالة واردة لا تحقق شيئًا على الإطلاق، مهما بدت صارمة. وهذا عمل المواصفة كما هو مقصود، لا خطأ في المدقق.

{
  "$defs": {
    "money": {
      "type": "string",
      "pattern": "^[0-9]+\\.[0-9]{2}$",
      "description": "سلسلة نقدية بمنزلتين عشريتين تتجنب انحراف الفاصلة العائمة"
    },
    "page": { "type": "integer", "minimum": 1 }
  },
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "page": { "$ref": "#/$defs/page" }
  }
}

وثمة عادات يجدر تثبيتها في الفريق:

  • مفاتيح $defs لا تحمل دلالة. فـaddress وAddress وaddr كلها صالحة، فاتفقوا على نمط واحد.
  • قبل 2019-09 كانت الكلمة المفتاحية definitions. ويمكن استهداف الاثنين بـ $ref، لكن الوضع الصارم قد يبلّغ عن definitions ككلمة مفتاحية غير معروفة.
  • يمكن أن يحتوي $defs أي مخطط، بما في ذلك مقتطف allOf أو مخطط فرعي مستقل بـ $id خاص به.
  • لا تعامل $defs كتوريث قوالب. فهي تخزن التعريفات فقط؛ والمعنى يأتي من سياق الإحالة.

4. allOf وanyOf وoneOf ليست قابلة للتبادل

تتضمن الثلاثة فروعًا متعددة، لكن قواعد قبولها تختلف. فعندما تطابق البيانات فرعين، ينجح allOf وanyOf ويفشل oneOf — وهو أكثر سبب شائع لاستنتاج الناس أن "المخطط مكسور".

الكلمة المفتاحية أدنى مطابقات أقصى مطابقات بيانات تطابق فرعين جودة الخطأ الاستخدام النمطي
allOf الكل غير محدود تنجح جيدة، قيد واحد في كل مرة دمج مقتطفات القيود
anyOf 1 غير محدود تنجح ضعيفة، تتراكم الفروع "بريد أو هاتف، أيٌّ منهما"
oneOf 1 1 تفشل ضعيفة الاتحادات المميزة
not 0 0 تفشل الأسوأ استبعاد قيم قليلة؛ نادر الاستخدام
{
  "allOf": [
    { "$ref": "#/$defs/auditable" },
    { "$ref": "#/$defs/softDeletable" },
    { "required": ["email"] }
  ],
  "unevaluatedProperties": false
}

لا تدمج allOf تعريفات الكائنات؛ بل تشترط تحقق كل فرع. فكل فرع يُقيَّم مستقلًا، لذا فـadditionalProperties: false داخل فرع واحد لا يعرف إلا المفاتيح التي أعلنها ذلك الفرع، وسيعدّ الحقول التي أعلنها فرع آخر إضافية. وفي 2020-12 يكون الحل بوضع سياسة الخصائص غير المعروفة في المستوى الأعلى واستخدام unevaluatedProperties: false، التي تُقيَّم مرة واحدة بعد كل الفروع.

استخدم anyOf لـ"عدة أشكال قانونية"، لا لـ"عدة أشكال متبادلة الاستبعاد". فإذا كان بإمكان فرعين المطابقة معًا، فسينتج oneOf الرسالة غير المفيدة سيئة السمعة must match exactly one schema in oneOf.

5. if/then/else والمميّز: اجعل الشروط قابلة للحسم

تميل القيود الشرطية إلى إنتاج أخطاء أفضل من oneOf، لأن مسار الحسم صريح: ينجح if فيسري then؛ وإلا فيسري else.

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": {
    "properties": { "kind": { "const": "image" } },
    "required": ["kind"]
  },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

وثمة ثلاث تفاصيل تحدد هل يتصرف كما هو مقصود:

  1. لا يفشل مدخل properties داخل if عند غياب المفتاح. اقترنه بـ required للتعبير عن "kind موجود ويساوي image".
  2. لا ينتج if نفسه خطأ تحقق أبدًا. فالفشل فيه يوجّه ببساطة إلى else، لذا لا يمكنه منع البيانات السيئة وحده.
  3. then أو else منفردًا خامل. فلا يسريان إلا بجوار if.

وكلما نمت فروع الاتحاد، تحوّلت سلسلة الشروط إلى تداخل غير قابل للصيانة. والنمط الأرسخ هو تثبيت حقل وسم داخل كل فرع بحيث تكون الفروع متبادلة الاستبعاد بالبناء:

{
  "oneOf": [
    {
      "properties": { "kind": { "const": "card" } },
      "required": ["kind", "cardNumber"]
    },
    {
      "properties": { "kind": { "const": "account" } },
      "required": ["kind", "accountId"]
    }
  ]
}

discriminator امتداد في OpenAPI 3.x، وليست كلمة مفتاحية في JSON Schema. يدعمها Ajv عند تفعيل خيار discriminator: true، لكن ربطها يسكن في mapping وهو غير قابل للنقل بين سلاسل الأدوات. وإذا كان على المخطط أن يعمل في عدة مدققين، ففضّل نمط وسم const، الذي لا يعتمد على أي امتداد إطلاقًا.

6. الإحالات العودية: الأشجار والقوائم المترابطة والملفات التي تستشهد ببعضها

لا تحتاج البنى العودية إلى صياغة خاصة — يكفي $ref يعود إلى نفسه. ولا يوسّع المدققون الإحالات بلا نهاية وقت الترجمة؛ بل يعودون وفق العمق الفعلي للبيانات، فتوصف بضع عشرات من الأسطر شجرة بأي ارتفاع.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/tree-node.json",
  "$defs": {
    "node": {
      "type": "object",
      "required": ["name"],
      "properties": {
        "name": { "type": "string", "minLength": 1 },
        "children": {
          "type": "array",
          "items": { "$ref": "#/$defs/node" }
        }
      }
    }
  },
  "$ref": "#/$defs/node"
}

وثمة ثلاثة حدود يجدر حسمها مسبقًا:

  • عمق البيانات: لا يفرض المخطط حدًّا، لكن التحقق والتسلسل يستهلكان مكدسًا يتناسب مع العمق. ويجب أن تضيف واجهات API العامة حدًّا تجاريًا، ولنقل ثمانية مستويات، وإلا فحولة عميقة خبيثة قد تفيض المكدس.
  • الإحالات المتبادلة: A يشير إلى B وB يشير إلى A أمر قانوني، لكن يجب تسجيل المخططين أولًا؛ وإلا فترتيب التحليل يحدد النتيجة.
  • الفروع القابلة لـ null: إذا كان حقل عودي قد يكون null، فاكتب "type": ["object", "null"] أو غلّفه في oneOf. فـ$ref لا يورث قابلية null.

7. الإحالات عبر الملفات والجمع: لا تجلب المخططات في الإنتاج أبدًا

غالبًا ما "تعمل" الإحالات عبر الملفات في التطوير لأن المدقق يجلب الهدف بـ $id عبر الشبكة. وغيّر سياسة الشبكة وقت النشر فينكسر سلسلة التحقق بأكملها. والقاعدة الإنتاجية بسيطة: يجب أن يُسجَّل كل مخطط مُحال في العملية أو أن يُجمَّع مسبقًا.

import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({ allErrors: true, strict: true });

// سجّل الملف المُحال أولًا؛ ويجب أن يساوي المفتاح قيمة $id وإلا فلن تُحل الإحالات النسبية
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');

// اترجم مخطط الدخول مرة واحدة، في نطاق الوحدة
export const validateOrder = ajv.compile(orderSchema);

إذا كان في order.json قيمة $id: "https://bbdrug.com/schemas/order.json"، فستُحل "$ref": "address.json" إلى https://bbdrug.com/schemas/address.json، وهو ما يطابق المفتاح المسجل. وحرف واحد مختلف ينتج can't resolve reference.

وتقارن الأساليب العملية الثلاثة هكذا:

النهج الطريقة القوة التكلفة
التسجيل في وقت التشغيل addSchema لكل ملف بلا خطوة بناء، وتبقى الملفات مستقلة ترتيب التسجيل والمفاتيح يدويان
الجمع وقت البناء الدمج بمحلل إحالات في ملف واحد صفر اعتمادات وقت التشغيل أصل أكبر، وفروق صاخبة
التحميل غير المتزامن loadSchema + compileAsync يتعامل مع مخططات تتغير كثيرًا قلق الشبكة والتخزين المؤقت؛ تجنبه في المسارات الحرجة

ولمشاريع الواجهة، الجمع وقت البناء أكثر أمانًا. وعلى الخادم، يكفي التسجيل بشرط أن يثبت اختبار أن كل إحالة تُحل. وفي كلتا الحالتين، شغّل compile في CI حتى تظهر أخطاء الإحالة قبل الدمج لا عند أول طلب إنتاجي.

8. ترتيب التصحيح: حلّل، ثم اترجم، ثم افحص الفروع

يمكن تحديد كل مشكلة $ref تقريبًا في ثلاث مراحل، والترتيب مهم:

العَرَض السبب المرجح الإصلاح
can't resolve reference ... from id ... خطأ كتابي في $id، أو أساس نسبي خاطئ، أو هدف غير مسجل اطبع $id وURI المطلق المحلول، ثم قارنهما بالمفاتيح المسجلة
schema with key or id already exists تسجيل $id نفسه مرتين، أو استدعاء compile مرارًا انقل التسجيل إلى تهيئة الوحدة وشغّله مرة واحدة
strict mode: unknown keyword: "definitions" خلط صياغة draft-07 أعِد التسمية إلى $defs؛ ولا تعطّل الوضع الصارم
البيانات تطابق فرعي oneOf قيود فروع متداخلة، وبلا حقل وسم أضف const لكل فرع، أو انتقل إلى anyOf
القيود داخل $defs بلا أثر لا شيء يشير إليها افحص مسار الإحالة؛ فـ$defs لا تحقق شيئًا بذاتها

وعند التصحيح، اقرأ schemaPath قبل message: فهو يسمّي الفرع والكلمة المفتاحية الفاشلة، وهو أكثر موثوقية بكثير من النص الإنجليزي. وخزّن المدقق المترجم مؤقتًا في نطاق الوحدة بدلًا من إعادة الترجمة في كل طلب. وغطِّ لكل تغيير في المخطط حالة ناجحة وحالة فاشلة — فالاختبار بمسار النجاح فقط لا يختبر شيئًا. ولمعرفة اختيار المدقق وتصميم رسائل الخطأ، انظر JSON Schema: من الأساسيات إلى الإنتاج؛ ولربط المخطط بتوليد الأنواع، انظر مزامنة ثنائية الاتجاه بين المخطط وأنواع TypeScript.

9. الأسئلة المتكررة

هل يمكن أن تسكن $ref بجوار type أو description؟

يسمح 2020-12 بـ $ref بجوار الكلمات الشقيقة، وتُقيَّم تلك الشقيقات عادةً. أما في draft-07 فتُتجاهل الشقيقات ويسري $ref وحده. وهذا أكبر فرق سلوكي عند استخدام المخطط نفسه عبر إصدارات مختلفة، فتأكد من الإصدار الذي يدعمه مدققك قبل الترحيل.

هل أستخدم $defs أم definitions؟

استخدم $defs للأعمال الجديدة؛ فقد جاءت مع 2019-09. وdefinitions ليست كلمة مفتاحية في 2020-12. ويمكن أن يُحل مؤشر مثل $ref: "#/definitions/foo" لأنه JSON Pointer عادي، لكن الوضع الصارم سيبلّغ عن كلمة مفتاحية غير معروفة. وخلط النمطين يترك لغمًا لا ينفجر إلا في CI.

لماذا يرفض oneOf بيانات تبدو صالحة؟

لأن البيانات تحقق فرعين معًا. فـoneOf يطالب بواحد بالضبط، والفرع الذي يقيّد جزءًا من الكائن فقط يبقى متحققًا حتى عند غياب حقول الفرع الآخر. أضف وسم const لكل فرع لجعلها متبادلة الاستبعاد.

هل ستكرر الإحالات العودية للأبد؟

لا. فالمدققون لا يوسّعون الإحالات وقت الترجمة؛ بل يعودون على البيانات الفعلية. أما المخاطر الحقيقية فهي بيانات عميقة بما يكفي لفيض المكدس، وترتيب تحليل خاطئ بين ملفات متبادلة الإحالة. وحد عمق على مستوى العمل في الحقول العودية أكثر فعالية من أي حيلة في المخطط.

هل يمكن أن تعتمد الإنتاج على $ref البعيدة؟

لا. فالإحالات البعيدة تربط توفر التحقق بالشبكة: تغييرات النطاق، والشهادات المنتهية، والعزل الشبكي الداخلي، كلها تكسر السلسلة. جمّع المخططات المُحالة في الأصل أو سجّلها عند بدء التشغيل إذا أردت سلوكًا يمكن توقعه.

كيف أقرر بين $defs والملفات المنفصلة؟

البنية التي تظهر في موضعين يمكن نسخها مرة واحدة؛ وعندما تظهر في موضع ثالث، رقِّها إلى $defs. ولا تستحق ملفًا مستقلًا بـ $id خاص إلا عندما يجب أن تقرأها لغة أخرى أو مستودع آخر.

لماذا لا يكون لقيود داخل $defs أي أثر؟

$defs مجرد حاوية، والعقد التي لا تشير إليها أي $ref لا تشارك في التحقق أبدًا. فقائمة required في أعلى $defs دون إحالة واردة تظل تسمح بمرور البيانات — وهذا عمل المواصفة، لا خطأ في المدقق.

هل أضع additionalProperties: false داخل فروع allOf؟

لا. فكل فرع يُقيَّم مستقلًا، فيعدّ فرع واحد الحقول التي أعلنها فرع آخر إضافية. ونهج 2020-12 ينقل سياسة الحقول غير المعروفة إلى المستوى الأعلى ويستخدم unevaluatedProperties: false، التي تُقيَّم مرة واحدة بعد كل الفروع.