שימוש חוזר ב-JSON Schema: $ref, $defs והרכבה עם allOf/oneOf
שורה תחתונה: $ref פותר שימוש חוזר, allOf ו-oneOf פותרים הרכבה, וערימה של שניהם היא המקום שבו דברים נשברים. המאמר מכסה $id, $defs, תנאים, רקורסיה, ארזוף ודיבוג.

שורה תחתונה: חלצו קודם מבנים משותפים ל-$defs עם $ref, ואז פנו ל-allOf/oneOf; כשענפים יכולים להתאים בו-זמנית, השתמשו ב-anyOf או בתג const, ושמרו את oneOf למקרים שבהם באמת נדרשת התאמה אחת בדיוק.
העתקה-הדבקה חוסכת עשר דקות ועולה לכם במזלג שקט כעבור שלושה חודשים, כשאף אחד לא זוכר איזה עותק נכון.
1. שלוש שכבות של שימוש חוזר — בחרו אחת לפני שמפצלים קבצים
האינסטינקט הראשון, ברגע שהשדות מתחילים להתרבות, הוא לדחוס כל אילוץ לקובץ אחד. זו הדרך המהירה ביותר לשחרר, אבל אחרי כמה מאות שורות כל שינוי שדה חייב להיות משוכפל בשלושה מקומות. מפספסים אחד ומקבלים "עובר מקומית, מחזיר 500 בפרודקשן". שימוש חוזר פירושו לכתוב עובדה פעם אחת ולהפנות אליה בכל מקום אחר.
JSON Schema מציע שלוש שכבות, כל אחת עם היקף שונה:
| שכבה | נושא | היקף | שימוש טיפוסי |
|---|---|---|---|
| תת-מבנה | $defs + $ref |
קובץ schema אחד | כתובת, כסף, עימוד, צורות שגיאה |
| קובץ | schema עצמאי + $id |
כמה קבצים בשירות אחד | ישויות משתמש, הזמנה, מוצר |
| ספריית חוזים | מאגר schemas + ארטיפקט מאורז | כמה שירותים, כמה שפות | רכיבי OpenAPI, חוזים מפורסמים |
ברוב הפרויקטים השכבה הראשונה מספיקה. פיצול לקבצים משתלם רק כששני שירותים באמת חולקים את אותו חוזה, או כשה-schema הוא ארטיפקט מפורסם שנצרך על ידי שפה אחרת. פיצול מוקדם מדי מקשה על דיבוג של פתרון $id ונתיבים יחסיים, והעלות הזו בדרך כלל עולה על מה שחסכתם בהימנעות מכפילות.
כלל אצבע פשוט: אם מבנה מופיע בשני מקומות, עותק אחד סביר; כשהוא מופיע במקום שלישי, קדמו אותו ל-$defs; רק כששפה או מאגר אחר חייבים לקרוא אותו הוא ראוי לקובץ עצמאי עם $id משלו.
2. $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 מוחלט | הכל | הימנעו משליפה בזמן ריצה בפרודקשן |
מלכודת עדינה: אם sub-schema בתוך $defs מצהיר על $id משלו, הוא הופך ל-URI הבסיס החדש לכל מה שמתחתיו. הדביקו schema שהועתק מקובץ אחר וה-$id שנשאר יפנה את ההפניות היחסיות שלו למארח המקורי — והמאמת מבחין בכך רק כשהוא באמת מנסה לפתור אותן.
3. $defs הוא מכל, לא אתר אימות
$defs הוא מפה רגילה. שום דבר בתוכו לא נכנס לתוקף עד ש-$ref מצביע לשם. רשימת required שיושבת בראש $defs בלי הפניה נכנסת לא מאמתת דבר, לא משנה כמה נוקשה היא נראית. זה המפרט פועל כמתוכנן, לא באג של מאמת.
{
"$defs": {
"money": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]{2}$",
"description": "A two-decimal money string that avoids floating-point drift"
},
"page": { "type": "integer", "minimum": 1 }
},
"properties": {
"total": { "$ref": "#/$defs/money" },
"page": { "$ref": "#/$defs/page" }
}
}
כמה מוסכמות שכדאי לקבוע בצוות:
- מפתחות בתוך
$defsלא נושאים סמנטיקה.address,Addressו-addrכולם חוקיים, ולכן הסכימו על סגנון. - לפני 2019-09 מילת המפתח הייתה
definitions. שניהם יכולים להיות יעד ל-$ref, אבל מצב strict עשוי לדווח עלdefinitionsכמילת מפתח לא מוכרת. $defsיכול להחזיק כל schema, כולל קטעallOfאו sub-schema עצמאי עם$idמשלו.- אל תתייחסו ל-
$defsכהורשת תבניות. הוא רק מאחסן הגדרות; המשמעות עדיין מגיעה מההקשר המפנה.
4. allOf, anyOf ו-oneOf אינם מחליפים אחד את השני
שלושתם מעורבים כמה ענפים, אבל כללי הקבלה שלהם שונים. כשהנתונים מתאימים לשני ענפים, allOf ו-anyOf עוברים בעוד ש-oneOf נכשל — הסיבה הנפוצה ביותר להסקה "ה-schema שבור".
| מילת מפתח | התאמות מינימום | התאמות מקסימום | נתונים שמתאימים לשני ענפים | איכות שגיאה | שימוש טיפוסי |
|---|---|---|---|---|---|
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 ו-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.
כשענפי האיחוד גדלים, שרשרת התנאים הופכת לקן שאי אפשר לתחזק. תבנית חזקה יותר היא לנעול שדה תג בתוך כל ענף כך שהענפים יהיו בלעדיים זה לזה מבנית:
{
"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, שתלויה באף הרחבה.
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"
}
שלושה גבולות שכדאי לסכם מראש:
- עומק נתונים: ה-schema לא מטיל מגבלה, אבל גם אימות וגם סריאליזציה צורכים מחסנית פרופורציונית לעומק. APIs ציבוריים צריכים להוסיף מגבלה עסקית, נניח שמונה רמות, אחרת מטען עמוק בזדון יכול להציף את המחסנית.
- הפניות הדדיות: A מפנה ל-B ו-B מפנה ל-A זה חוקי, אבל שני ה-schemas חייבים להיות רשומים קודם; אחרת סדר הפתרון קובע את התוצאה.
- ענפים ניתנים ל-null: אם שדה רקורסיבי יכול להיות
null, כתבו"type": ["object", "null"]או עטפו אותו ב-oneOf.$refלא יורש יכולת null.
7. הפניות בין קבצים וארזוף: לעולם לא שליפת schemas בפרודקשן
הפניות בין קבצים לעיתים קרובות "פשוט עובדות" בפיתוח כי המאמת שולף את היעד לפי $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 לקובץ אחד | אפס תלויות בזמן ריצה | ארטיפקט גדול יותר, diffים רועשים |
| טעינה אסינכרונית | loadSchema + compileAsync |
מטפל ב-schemas שמשתנים לעיתים קרובות | דאגות רשת ומטמון; הימנעו במסלולים קריטיים |
בפרויקטי פרונט, ארזוף בזמן בנייה בטוח יותר. בשרת, רישום מספיק, בתנאי שמבחן מוכיח שכל הפניה נפתרת. בכל מקרה, הריצו 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; אל תכבו את מצב strict |
נתונים מתאימים לשני ענפי oneOf |
אילוצי ענפים חופפים, אין שדה תג | הוסיפו const לכל ענף, או עברו ל-anyOf |
אילוצים בתוך $defs לא משפיעים |
שום דבר לא מפנה אליהם | בדקו את נתיב ההפניה; $defs לא מאמת דבר בעצמו |
בדיבוג, קראו schemaPath לפני message: הוא מציין את הענף ומילת המפתח שנכשלו והוא אמין הרבה יותר מפרוזה באנגלית. שמרו את המאמת המקומפל במטמון ברמת המודול במקום לקמפל מחדש בכל בקשה. בכל שינוי schema, כסו גם מקרה עובר וגם מקרה נכשל — בדיקה של המסלול המאושר בלבד לא בודקת דבר. לבחירת מאמת ועיצוב הודעות שגיאה, ראו JSON Schema: מהיסודות לפרודקשן; לחיבור schema ליצירת טיפוסים, ראו סנכרון דו-כיווני בין Schema לטיפוסי TypeScript.
9. שאלות נפוצות
$ref יכול לשבת לצד type או description?
2020-12 מתיר ל-$ref לשבת לצד מילות מפתח אחים, והאחים האלה מוערכים כרגיל. ב-draft-07 האחים מתעלמים ורק $ref חל. זהו ההבדל ההתנהגותי הגדול ביותר כשאותו schema משמש בין drafts, ולכן אשרו איזה draft המאמת שלכם תומך לפני הגירה.
כדאי להשתמש ב-$defs או ב-definitions?
השתמשו ב-$defs בעבודה חדשה; הוא הגיע עם 2019-09. definitions אינו מילת מפתח ב-2020-12. pointer כמו $ref: "#/definitions/foo" עדיין יכול להיפתר כי הוא JSON Pointer רגיל, אבל מצב strict ידווח על מילת מפתח לא מוכרת. ערבוב שני הסגנונות משאיר מוקש שמתפוצץ רק ב-CI.
למה oneOf דוחה נתונים שנראים תקינים?
כי הנתונים מספקים שני ענפים בו-זמנית. oneOf דורש בדיוק אחד, וענף שמגביל רק חלק מהאובייקט מסופק גם כשהשדות של הענף האחר חסרים. הוסיפו תג const לכל ענף כדי להפוך אותם לבלעדיים זה לזה.
הפניות רקורסיביות ייכנסו ללולואה אינסופית?
לא. מאמתים לא מרחיבים הפניות בזמן קומפילציה; הם חוזרים על הנתונים בפועל. הסיכונים האמיתיים הם נתונים עמוקים מספיק כדי להציף את המחסנית וסדר פתרון שגוי בין קבצים שמפנים זה לזה. מגבלת עומק ברמת העסקים על שדות רקורסיביים יעילה יותר מכל תחבולת schema.
אפשר להסתמך על $ref מרוחק בפרודקשן?
לא. הפניות מרוחקות קושרות את זמינות האימות לרשת: שינויי דומיין, תעודות שפגו ובידוד רשת פנימית — כולם שוברים את השרשרת. ארזו את ה-schemas המופנים בארטיפקט או רשמו אותם בזמן אתחול אם רוצים התנהגות צפויה.
איך מחליטים בין $defs לקבצים נפרדים?
מבנה שמופיע בשני מקומות יכול להיות מועתק פעם אחת; כשהוא מופיע במקום שלישי, קדמו אותו ל-$defs. רק כששפה אחרת או מאגר אחר חייבים לקרוא אותו הוא ראוי לקובץ עצמאי עם $id משלו.
למה אילוצים בתוך $defs לא משפיעים?
$defs הוא רק מכל, וצמתים שאף $ref לא מפנה אליהם לא משתתפים באימות. רשימת required בראש $defs בלי הפניה נכנסת עדיין נותנת לנתונים לעבור — זה המפרט, לא באג של מאמת.
כדאי לשים additionalProperties: false בתוך ענפי allOf?
לא. כל ענף מוערך בנפרד, כך שענף אחד מתייחס לשדות שהצהיר ענף אחר כמיותרים. גישת 2020-12 מעבירה את מדיניות השדות הלא מוכרים לרמה העליונה ומשתמשת ב-unevaluatedProperties: false, שמוערך פעם אחת אחרי כל הענפים.