بازاستفاده از JSON Schema: $ref، $defs و ترکیب allOf/oneOf
جمعبندی: $ref بازاستفاده را حل میکند، allOf و oneOf ترکیب را، و رویهم گذاشتن هر دو جایی است که چیزها میشکنند. $id، $defs، شرطیها، بازگشت، بستهبندی و عیبیابی را پوشش میدهد.

جمعبندی: اول ساختارهای مشترک را با $ref در $defs استخراج کنید، سپس به allOf/oneOf بروید؛ وقتی شاخهها میتوانند همزمان تطبیق یابند از anyOf یا تگ const استفاده کنید، و oneOf را برای مواردی نگه دارید که واقعاً دقیقاً یک تطبیق میخواهند.
کپی-چسباندن ده دقیقه صرفهجویی میکند و سه ماه بعد یک انشعاب بیصدا هزینه دارد، وقتی هیچکس یادش نیست کدام نسخه درست است.
۱. سه لایه بازاستفاده — پیش از تقسیم فایلها یکی را انتخاب کنید
اولین غریزه، وقتی فیلدها زیاد میشوند، چپاندن هر قید در یک فایل است. سریعترین راه انتشار است، اما پس از چند صد خط هر تغییر فیلد باید در سه جا تکرار شود. یکی را جا بیندازید و «محلی پاس میشود، در تولید ۵۰۰ برمیگرداند» میگیرید. بازاستفاده یعنی یک واقعیت را یک بار بنویسید و هر جای دیگر ارجاع دهید.
JSON Schema سه لایه ارائه میدهد، هر یک با دامنه متفاوت:
| لایه | حامل | دامنه | استفاده معمول |
|---|---|---|---|
| زیرساختار | $defs + $ref |
یک فایل schema | آدرس، پول، صفحهبندی، شکل خطا |
| فایل | schema مستقل + $id |
چند فایل در یک سرویس | موجودیتهای کاربر، سفارش، محصول |
| کتابخانه قرارداد | مخزن schema + محصول بستهبندیشده | چند سرویس، چند زبان | کامپوننتهای OpenAPI، قراردادهای منتشرشده |
برای بیشتر پروژهها لایه اول کافی است. تقسیم به فایل فقط وقتی بهره میدهد که دو سرویس واقعاً همان قرارداد را شریکاند، یا schema محصول منتشرشدهای است که زبان دیگری مصرف میکند. تقسیم زودهنگام حل $id و مسیرهای نسبی را سختتر عیبیابی میکند و آن هزینه معمولاً از صرفهجویی دوری از تکرار بیشتر است.
قاعده سرانگشتی ساده: اگر ساختاری در دو جا ظاهر میشود، یک نسخه قابل تحمل است؛ وقتی در جای سوم ظاهر شد، به $defs ارتقایش دهید؛ فقط وقتی زبان یا مخزن دیگری باید بخواندش، شایسته فایل مستقل با $id خودش است.
۲. $id هویت میسازد، $ref پرش انجام میدهد
$id کامنت نیست. URI پایه schema را اعلام میکند. مقدار $ref یک ارجاع URI است: در برابر URI پایه جاری به URI مطلق حل میشود، و سپس fragment پس از # گرهای را مکانیابی میکند. اگر 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 از anchor؛ هر دو به همان گره مینشینند. جدول زیر هر شکل را به حلش نگاشت میکند:
| شکل | به چه اشاره میکند | Draft | یادداشت |
|---|---|---|---|
# |
ریشه schema جاری | همه | رایج برای بازگشت |
#/$defs/address |
گره زیر $defs |
2019-09 و بعد | واضحترین؛ ترجیحشده |
#address |
anchor نامدار | $anchor در 2020-12 |
draft-07 $id: "#address" مینوشت |
common.json |
فایل نسبت به URI پایه | همه | باید ثبت یا بستهبندی شود |
https://example.com/x.json |
URI مطلق | همه | از واکشی زمان اجرا در تولید پرهیز کنید |
یک تله ظریف: اگر زیرschemaای داخل $defs $id خودش را اعلام کند، آن URI پایه جدید برای هر چیزی زیرش میشود. schemaی کپیشده از فایل دیگر را جایگذاری کنید و $id باقیمانده ارجاعهای نسبیاش را به میزبان اصلی هدایت میکند — و اعتبارسنج فقط وقتی متوجه میشود که واقعاً بخواهد حلشان کند.
۳. $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میتواند هر schemaای داشته باشد، شامل قطعهallOfیا زیرschema مستقل با$idخودش.$defsرا وراثت قالب ندانید. فقط تعریفها را ذخیره میکند؛ معنا همچنان از بافت ارجاعدهنده میآید.
۴. allOf، anyOf و oneOf قابل تعویض نیستند
هر سه چند شاخه را در بر میگیرند، اما قواعد پذیرششان متفاوت است. وقتی داده دو شاخه را تطبیق میدهد، allOf و anyOf پاس میشوند در حالی که oneOf شکست میخورد — همان رایجترین دلیلی که مردم نتیجه میگیرند «schema خراب است».
| کلیدواژه | حداقل تطبیق | حداکثر تطبیق | داده منطبق با دو شاخه | کیفیت خطا | استفاده معمول |
|---|---|---|---|---|---|
allOf |
همه | نامحدود | پاس | خوب، یک قید در یک زمان | ترکیب قطعههای قید |
anyOf |
۱ | نامحدود | پاس | ضعیف، شاخهها انباشته میشوند | «ایمیل یا تلفن، هر یک» |
oneOf |
۱ | ۱ | شکست | ضعیف | unionهای discriminantدار |
not |
۰ | ۰ | شکست | بدترین | حذف چند مقدار؛ کم استفاده |
{
"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 را تولید میکند.
۵. if/then/else و discriminator: شرطها را قابل تصمیم کنید
قیدهای شرطی معمولاً خطاهای بهتری از 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"] }
}
سه جزئیات تعیین میکند آیا آنگونه که مقصود است رفتار میکند:
- ورودی
propertiesداخلifوقتی کلید گم است شکست نمیخورد. باrequiredجفتش کنید تا «kind وجود دارد و برابر image است» بیان شود. - خود
ifهرگز خطای اعتبارسنجی تولید نمیکند. شکستش صرفاً بهelseمسیر میدهد، پس بهتنهایی نمیتواند داده بد را مسدود کند. thenیاelseتنها بیاثر است. فقط کنارifاعمال میشوند.
هر چه شاخههای union رشد میکنند، زنجیره شرطی به تودرتویی غیرقابل نگهداری تبدیل میشود. الگوی مقاومتر قفل کردن یک فیلد تگ داخل هر شاخه است تا شاخهها بهطراحی منحصراً متقابل شوند:
{
"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 زندگی میکند و بین زنجیرههای ابزار قابل انتقال نیست. اگر schema باید در چند اعتبارسنج کار کند، الگوی تگ const را ترجیح دهید که به هیچ افزونهای وابسته نیست.
۶. ارجاعهای بازگشتی: درختها، لیستهای پیوندی و فایلهایی که به هم ارجاع میدهند
ساختارهای بازگشتی به نحو ویژه نیاز ندارند — یک $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"
}
سه مرز شایسته تصمیم از پیش:
- عمق داده: schema محدودیتی نمیگذارد، اما اعتبارسنجی و سریالایز هر دو پشته متناسب با عمق مصرف میکنند. APIهای عمومی باید محدودیت کسبوکار بگذارند، مثلاً هشت سطح، وگرنه payload عمیقاً مخرب میتواند پشته را سرریز کند.
- ارجاع متقابل: A به B و B به A ارجاع دهد قانونی است، اما هر دو schema باید اول ثبت شوند؛ وگرنه ترتیب حل نتیجه را تعیین میکند.
- شاخههای nullable: اگر فیلد بازگشتی میتواند
nullباشد،"type": ["object", "null"]بنویسید یا درoneOfبپیچید.$refnullability را به ارث نمیبرد.
۷. ارجاعهای بینفایلی و بستهبندی: هرگز schemaها را در تولید واکشی نکنید
ارجاعهای بینفایلی غالباً در توسعه «فقط کار میکنند» چون اعتبارسنج هدف را با $id روی شبکه واکشی میکند. سیاست شبکه را هنگام استقرار تغییر دهید و کل زنجیره اعتبارسنجی میشکند. قاعده تولید ساده است: هر schema ارجاعشده باید درونفرایند ثبت یا از قبل بستهبندی شده باشد.
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 });
// Register the referenced file first; the key must equal its $id, or relative refs won't resolve
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');
// Compile the entry schema once, at module scope
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 به ازای هر فایل |
بدون گام ساخت، فایلها مستقل میمانند | ترتیب و کلیدهای ثبت دستی |
| بستهبندی در زمان ساخت | ادغام با ref parser در یک فایل | صفر وابستگی زمان اجرا | محصول بزرگتر، diffهای پرنویز |
| بارگذاری async | loadSchema + compileAsync |
schemaهای مکرراً متغیر را مدیریت میکند | نگرانی شبکه و کش؛ در مسیرهای حیاتی پرهیز کنید |
برای پروژههای فرانتاند، بستهبندی در زمان ساخت امنتر است. روی سرور، ثبت کافی است به شرط اینکه آزمونی ثابت کند هر ارجاع حل میشود. در هر حالت، compile را در CI اجرا کنید تا خطاهای ارجاع پیش از ادغام آشکار شوند نه در نخستین درخواست تولید.
۸. ترتیب عیبیابی: حل، کامپایل، سپس بازرسی شاخهها
تقریباً هر مشکل $ref در سه مرحله مکانیابی میشود و ترتیب مهم است:
| نشانه | علت محتمل | اصلاح |
|---|---|---|
can't resolve reference ... from id ... |
غلط تایپی در $id، پایه نسبی اشتباه، هدف ثبتنشده |
$id و URI مطلق حلشده را چاپ و با کلیدهای ثبتشده مقایسه کنید |
schema with key or id already exists |
همان $id دو بار ثبت شده، یا compile مکرر صدا زده شده |
ثبت را به init ماژول ببرید و یک بار اجرا کنید |
strict mode: unknown keyword: "definitions" |
مخلوط کردن نحو draft-07 | به $defs تغییر نام دهید؛ حالت سختگیر را غیرفعال نکنید |
داده دو شاخه oneOf را تطبیق میدهد |
قیدهای شاخه همپوشانی دارند، فیلد تگ ندارد | به هر شاخه const اضافه کنید، یا به anyOf بروید |
قیدهای داخل $defs اثری ندارند |
هیچ چیز ارجاعشان نمیدهد | مسیر ارجاع را بررسی کنید؛ $defs بهتنهایی هیچ چیز را اعتبارسنجی نمیکند |
هنگام عیبیابی، schemaPath را قبل از message بخوانید: شاخه و کلیدواژه شکستخورده را نام میبرد و بسیار قابل اعتمادتر از نثر انگلیسی است. اعتبارسنج کامپایلشده را در سطح ماژول کش کنید نه اینکه هر درخواست دوباره کامپایل کنید. برای هر تغییر schema، هم حالت پاس و هم شکست را پوشش دهید — آزمون فقط مسیر خوشحال هیچ چیز را نمیآزماید. برای انتخاب اعتبارسنج و طراحی پیام خطا، JSON Schema: از مبانی تا تولید را ببینید؛ برای سیمکشی schema به تولید نوع، همگامسازی دوطرفه بین Schema و نوعهای TypeScript.
۹. پرسشهای متداول
آیا $ref میتواند کنار type یا description باشد؟
2020-12 $ref را کنار کلیدواژههای همسایه مجاز میداند و آن همسایهها بهطور عادی ارزیابی میشوند. در draft-07 همسایهها نادیده گرفته میشوند و فقط $ref اعمال میشود. این بزرگترین تفاوت رفتاری هنگام استفاده همان schema در draftهای مختلف است، پس پیش از مهاجرت تأیید کنید اعتبارسنجتان از کدام draft پشتیبانی میکند.
از $defs استفاده کنم یا definitions؟
برای کار جدید از $defs استفاده کنید؛ با 2019-09 آمد. definitions در 2020-12 کلیدواژه نیست. pointerی مانند $ref: "#/definitions/foo" همچنان میتواند حل شود چون JSON Pointer ساده است، اما حالت سختگیر کلیدواژه ناشناخته گزارش میکند. مخلوط کردن هر دو سبک مین کارگذاریشدهای باقی میگذارد که فقط در CI منفجر میشود.
چرا oneOf داده بهظاهر معتبر را رد میکند؟
چون داده دو شاخه را همزمان برآورده میکند. oneOf دقیقاً یکی میخواهد و شاخهای که فقط بخشی از شیء را مقید میکند حتی وقتی فیلدهای شاخه دیگر غایباند برآورده میشود. به هر شاخه تگ const اضافه کنید تا منحصراً متقابل شوند.
آیا ارجاعهای بازگشتی تا بینهایت حلقه میزنند؟
نه. اعتبارسنجها در زمان کامپایل ارجاعها را باز نمیکنند؛ روی داده واقعی بازگشت میکنند. ریسکهای واقعی دادهای است که به عمق سرریز پشته میرسد و ترتیب حل اشتباه بین فایلهای ارجاعدهنده متقابل. محدودیت عمق در سطح کسبوکار برای فیلدهای بازگشتی از هر ترفند schema مؤثرتر است.
آیا تولید میتواند به $ref راه دور تکیه کند؟
نه. ارجاعهای راه دور در دسترس بودن اعتبارسنجی را به شبکه میبندند: تغییر دامنه، گواهی منقضی و انزوای شبکه داخلی همه زنجیره را میشکنند. schemaهای ارجاعشده را در محصول بستهبندی کنید یا هنگام راهاندازی ثبت کنید اگر رفتار قابل پیشبینی میخواهید.
چگونه بین $defs و فایلهای جدا تصمیم بگیرم؟
ساختاری که در دو جا ظاهر میشود را میتوان یک بار کپی کرد؛ وقتی در جای سوم ظاهر شد، به $defs ارتقایش دهید. فقط وقتی زبان یا مخزن دیگری باید بخواندش شایسته فایل مستقل با $id خودش است.
چرا قیدهای داخل $defs اثری ندارند؟
$defs فقط یک ظرف است و گرههایی که هیچ $refی به آنها اشاره نمیکند هرگز در اعتبارسنجی شرکت نمیکنند. فهرست required بالای $defs بدون ارجاع ورودی همچنان داده را پاس میدهد — این مشخصات است، نه باگ اعتبارسنج.
آیا additionalProperties: false را داخل شاخههای allOf بگذارم؟
نه. هر شاخه مستقل ارزیابی میشود، پس یک شاخه فیلدهای اعلامشده توسط شاخه دیگر را اضافه میداند. رویکرد 2020-12 سیاست فیلد ناشناخته را به سطح بالا میبرد و از unevaluatedProperties: false استفاده میکند، که یک بار پس از همه شاخهها ارزیابی میشود.