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 קובץ 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"] }
}

שלושה פרטים קובעים האם הוא מתנהג כמתוכנן:

  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 ואינו נייד בין כלי שרשרת. אם ה-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, שמוערך פעם אחת אחרי כל הענפים.