JSON Toolkit

การใช้ JSON Schema ซ้ำ: $ref, $defs และการประกอบ allOf/oneOf

สรุปสำคัญ: $ref แก้ปัญหาการใช้ซ้ำ allOf และ oneOf แก้ปัญหาการประกอบ และการซ้อนทั้งสองเข้าด้วยกันคือจุดที่พัง ครอบคลุม $id, $defs, เงื่อนไข, การเรียกซ้ำ, bundling และการดีบัก

ระดับกลางอ่านประมาณ 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 เดียว ที่อยู่ เงิน การแบ่งหน้า รูปร่าง error
ไฟล์ schema แยก + $id หลายไฟล์ในบริการเดียว entity ของผู้ใช้ คำสั่งซื้อ สินค้า
ไลบรารีสัญญา repository ของ schema + artifact ที่ bundle แล้ว หลายบริการ หลายภาษา component ของ OpenAPI สัญญาที่เผยแพร่

สำหรับโปรเจกต์ส่วนใหญ่ ชั้นแรกก็พอ การแยกเป็นไฟล์คุ้มค่าก็ต่อเมื่อสองบริการใช้สัญญาเดียวกันจริง ๆ หรือ schema เป็น artifact ที่เผยแพร่และถูกใช้โดยภาษาอื่น การแยกเร็วเกินไปทำให้การ resolve $id และพาธสัมพัทธ์ดีบักยากขึ้น และต้นทุนนั้นมักเกินสิ่งที่ประหยัดได้จากการหลีกเลี่ยงการทำซ้ำ

กฎง่าย ๆ: ถ้าโครงสร้างปรากฏในสองที่ สำเนาหนึ่งชุดยอมรับได้; เมื่อปรากฏที่ที่สาม ให้ยกขึ้นเป็น $defs; เมื่อภาษาอื่นหรือ repository อื่นต้องอ่านมัน จึงคู่ควรกับไฟล์แยกที่มี $id ของตัวเอง

2. $id กำหนดตัวตน, $ref ทำการกระโดด

$id ไม่ใช่ comment มันประกาศ base URI ของ schema ค่า $ref เป็น URI reference: มันถูก resolve เทียบกับ base URI ปัจจุบันเป็น URI แบบสัมบูรณ์ แล้ว fragment หลัง # จึงระบุโหนด ถ้า base URI ผิด ทุกการอ้างอิงสัมพัทธ์จะชี้ไปที่อื่น แต่ error มักบอกเพียง "can't resolve reference" โดยไม่ระบุ host ที่ผิด

{
  "$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; ทั้งสองลงที่โหนดเดียวกัน ตารางด้านล่างแมปแต่ละรูปแบบกับการ resolve:

รูปแบบ ชี้ไปที่ Draft หมายเหตุ
# รากของ schema ปัจจุบัน ทั้งหมด ใช้บ่อยสำหรับการเรียกซ้ำ
#/$defs/address โหนดภายใต้ $defs 2019-09 ขึ้นไป ชัดเจนที่สุด; แนะนำ
#address anchor ที่มีชื่อ $anchor ใน 2020-12 draft-07 เขียน $id: "#address"
common.json ไฟล์สัมพัทธ์กับ base URI ทั้งหมด ต้องจดทะเบียนหรือ bundle
https://example.com/x.json URI แบบสัมบูรณ์ ทั้งหมด หลีกเลี่ยงการดึงตอนรันไทม์ในโปรดักชัน

กับดักที่ละเอียดอ่อนหนึ่ง: ถ้า subschema ภายใน $defs ประกาศ $id ของตัวเอง มันจะกลายเป็น base URI ใหม่สำหรับทุกอย่างที่อยู่ใต้มัน วาง schema ที่คัดลอกจากไฟล์อื่นแล้ว $id ที่ตกค้างจะเปลี่ยนเส้นทางการอ้างอิงสัมพัทธ์ไปยัง host เดิม — และ validator จะรู้ตัวก็ต่อเมื่อมันพยายาม resolve จริง

3. $defs เป็นคอนเทนเนอร์ ไม่ใช่จุดตรวจสอบ

$defs เป็น map ธรรมดา ไม่มีอะไรภายในมีผลจนกว่า $ref จะชี้ไปที่นั่น รายการ required ที่วางไว้บนสุดของ $defs โดยไม่มีการอ้างอิงเข้ามาจะตรวจสอบไม่ได้เลย ไม่ว่ามันจะดูเข้มงวดเพียงใด นั่นคือข้อกำหนดทำงานตามที่ตั้งใจ ไม่ใช่ bug ของ validator

{
  "$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 mode อาจรายงาน definitions ว่าเป็นคีย์เวิร์ดที่ไม่รู้จัก
  • $defs บรรจุ schema ใดก็ได้ รวมถึง fragment ของ allOf หรือ subschema อิสระที่มี $id ของตัวเอง
  • อย่าถือว่า $defs เป็นการสืบทอดเทมเพลต มันเก็บเพียงคำจำกัดความ; ความหมายยังมาจากบริบทที่อ้างอิง

4. allOf, anyOf และ oneOf ใช้แทนกันไม่ได้

ทั้งสามเกี่ยวข้องกับหลายสาขา แต่กฎการยอมรับต่างกัน เมื่อข้อมูลตรงกับสองสาขา allOf และ anyOf ผ่าน ขณะที่ oneOf ล้มเหลว — นี่คือสาเหตุที่พบบ่อยที่สุดที่ทำให้คนสรุปว่า "schema พัง"

คีย์เวิร์ด ตรงขั้นต่ำ ตรงสูงสุด ข้อมูลที่ตรงสองสาขา คุณภาพ error การใช้ทั่วไป
allOf ทั้งหมด ไม่จำกัด ผ่าน ดี ทีละข้อจำกัด รวม fragment ของข้อจำกัด
anyOf 1 ไม่จำกัด ผ่าน แย่ สาขากองทับกัน "email หรือ phone อย่างใดอย่างหนึ่ง"
oneOf 1 1 ล้มเหลว แย่ union ที่แยกด้วย discriminator
not 0 0 ล้มเหลว แย่ที่สุด การยกเว้นไม่กี่ค่า; ใช้น้อย
{
  "allOf": [
    { "$ref": "#/$defs/auditable" },
    { "$ref": "#/$defs/softDeletable" },
    { "required": ["email"] }
  ],
  "unevaluatedProperties": false
}

allOf ไม่ได้ merge คำจำกัดความของออบเจกต์; มันเพียงกำหนดให้ทุกสาขาเป็นจริง แต่ละสาขาถูกประเมินแยกกัน ดังนั้น additionalProperties: false ภายในสาขาหนึ่งรู้เพียงคีย์ที่สาขานั้นประกาศ และจะตีฟิลด์ที่สาขาอื่นประกาศว่าเกิน ใน 2020-12 ทางแก้คือวางนโยบายฟิลด์ที่ไม่รู้จักไว้ระดับบนสุดและใช้ unevaluatedProperties: false ซึ่งถูกประเมินครั้งเดียวหลังทุกสาขา

ใช้ anyOf สำหรับ "หลายรูปร่างที่ถูกกฎหมาย" ไม่ใช่ "หลายรูปร่างที่แยกจากกันโดยเด็ดขาด" ถ้าสองสาขาตรงกันได้พร้อมกัน oneOf จะสร้างข้อความอันโด่งดังว่าไม่มีประโยชน์ must match exactly one schema in oneOf

5. if/then/else และ discriminator: ทำให้เงื่อนไขตัดสินได้

ข้อจำกัดแบบมีเงื่อนไขมักให้ error ที่ดีกว่า 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 เองไม่เคยสร้าง error ของการตรวจสอบ การไม่ผ่านมันเพียงเปลี่ยนเส้นทางไป else จึงไม่สามารถบล็อกข้อมูลแย่ ๆ ได้ด้วยตัวเอง
  3. 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 และไม่พกพาข้าม toolchain ถ้า schema ต้องทำงานใน validator หลายตัว ให้เลือกแพตเทิร์นแท็ก const ซึ่งไม่พึ่งส่วนขยายใดเลย

6. การอ้างอิงแบบเรียกซ้ำ: ต้นไม้ linked list และไฟล์ที่อ้างถึงกัน

โครงสร้างแบบเรียกซ้ำไม่ต้องใช้ไวยากรณ์พิเศษ — $ref กลับไปหาตัวเองก็พอ validator ไม่ขยายการอ้างอิงไม่รู้จบตอน compile; มันเรียกซ้ำตามความลึกจริงของข้อมูล ดังนั้นไม่กี่สิบบรรทัดก็อธิบายต้นไม้สูงเท่าไรก็ได้

{
  "$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 ไม่กำหนดขีดจำกัด แต่การตรวจสอบและการ serialize ต่างใช้ stack ตามความลึก API สาธารณะควรเพิ่มขีดจำกัดทางธุรกิจ เช่นแปดระดับ มิฉะนั้น payload ที่ลึกโดยเจตนาร้ายอาจทำให้ stack overflow
  • การอ้างอิงซึ่งกันและกัน: A อ้างถึง B และ B อ้างถึง A ถูกกฎหมาย แต่ schema ทั้งสองต้องถูกจดทะเบียนก่อน; มิฉะนั้นลำดับการ resolve จะเป็นตัวตัดสินผล
  • สาขาที่เป็น null ได้: ถ้าฟิลด์แบบเรียกซ้ำอาจเป็น null ให้เขียน "type": ["object", "null"] หรือห่อด้วย oneOf $ref ไม่สืบทอด nullability

7. การอ้างอิงข้ามไฟล์และการ bundle: อย่าดึง schema ในโปรดักชัน

การอ้างอิงข้ามไฟล์มัก "ทำงานได้เลย" ในสภาพแวดล้อมพัฒนา เพราะ validator ดึงเป้าหมายตาม $id ผ่านเครือข่าย เปลี่ยนนโยบายเครือข่ายตอน deploy แล้วห่วงโซ่การตรวจสอบทั้งหมดพัง กฎในโปรดักชันง่ายมาก: schema ทุกตัวที่ถูกอ้างอิงต้องถูกจดทะเบียนในโปรเซสหรือ bundle ล่วงหน้า

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 ของมัน มิฉะนั้น ref สัมพัทธ์จะ resolve ไม่ได้
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');

// compile schema หลักครั้งเดียวที่ขอบเขตโมดูล
export const validateOrder = ajv.compile(orderSchema);

ถ้า order.json มี $id: "https://bbdrug.com/schemas/order.json" ค่า "$ref": "address.json" ของมันจะ resolve เป็น https://bbdrug.com/schemas/address.json ซึ่งตรงกับคีย์ที่จดทะเบียน ต่างกันเพียงอักขระเดียวก็ได้ can't resolve reference

แนวทางปฏิบัติสามแบบเปรียบเทียบกันดังนี้:

แนวทาง วิธี จุดแข็ง ต้นทุน
จดทะเบียนตอนรันไทม์ addSchema ทีละไฟล์ ไม่ต้องมีขั้น build ไฟล์ยังแยกอิสระ ลำดับการจดทะเบียนและคีย์ต้องจัดการเอง
bundle ตอน build รวมด้วย ref parser เป็นไฟล์เดียว ไม่มีการพึ่งพาตอนรันไทม์ artifact ใหญ่ขึ้น diff รก
โหลดแบบ async loadSchema + compileAsync รองรับ schema ที่เปลี่ยนบ่อย กังวลเรื่องเครือข่ายและแคช; หลีกเลี่ยงบน critical path

สำหรับโปรเจกต์ frontend การ bundle ตอน build ปลอดภัยกว่า บนเซิร์ฟเวอร์ การจดทะเบียนก็พอ ตราบเท่าที่มีการทดสอบพิสูจน์ว่าทุกการอ้างอิง resolve ได้ ไม่ว่าทางไหน ให้รัน compile ใน CI เพื่อให้ error ของการอ้างอิงปรากฏก่อน merge ไม่ใช่ตอนคำขอโปรดักชันแรก

8. ลำดับการดีบัก: resolve, compile แล้วตรวจสาขา

ปัญหา $ref เกือบทุกอย่างระบุได้ในสามขั้น และลำดับสำคัญ:

อาการ สาเหตุที่เป็นไปได้ วิธีแก้
can't resolve reference ... from id ... สะกด $id ผิด base สัมพัทธ์ผิด เป้าหมายไม่ได้จดทะเบียน พิมพ์ $id และ URI สัมบูรณ์ที่ resolve ได้ แล้วเทียบกับคีย์ที่จดทะเบียน
schema with key or id already exists $id เดียวกันถูกจดทะเบียนสองครั้ง หรือเรียก compile ซ้ำ ย้ายการจดทะเบียนไปที่ init ของโมดูลและรันครั้งเดียว
strict mode: unknown keyword: "definitions" ผสมไวยากรณ์ draft-07 เปลี่ยนชื่อเป็น $defs; อย่าปิด strict mode
ข้อมูลตรงกับสองสาขาของ oneOf ข้อจำกัดสาขาทับซ้อน ไม่มีฟิลด์แท็ก เพิ่ม const ในแต่ละสาขา หรือเปลี่ยนเป็น anyOf
ข้อจำกัดภายใน $defs ไม่มีผล ไม่มีอะไรอ้างอิงมัน ตรวจพาธการอ้างอิง; $defs ไม่ตรวจสอบอะไรด้วยตัวเอง

เมื่อดีบัก ให้อ่าน schemaPath ก่อน message: มันระบุสาขาและคีย์เวิร์ดที่ล้มเหลว และเชื่อถือได้กว่าข้อความภาษาอังกฤษมาก ให้แคช validator ที่ compile แล้วที่ขอบเขตโมดูลแทนการ compile ใหม่ทุกคำขอ สำหรับทุกการเปลี่ยน schema ให้ครอบคลุมทั้งกรณีที่ผ่านและล้มเหลว — ทดสอบเฉพาะเส้นทางที่มีความสุขเท่ากับไม่ได้ทดสอบ สำหรับการเลือก validator และการออกแบบข้อความ error ดู JSON Schema: จากพื้นฐานสู่การใช้งานจริง; สำหรับการต่อ schema เข้ากับการสร้าง type ดู การซิงก์สองทางระหว่าง Schema และ TypeScript Type

9. คำถามที่พบบ่อย

$ref อยู่ข้าง type หรือ description ได้ไหม

2020-12 อนุญาตให้ $ref อยู่ข้างคีย์เวิร์ดข้างเคียง และคีย์เวิร์ดข้างเคียงเหล่านั้นถูกประเมินตามปกติ ใน draft-07 คีย์เวิร์ดข้างเคียงถูกละเลยและใช้เฉพาะ $ref นี่คือความต่างเชิงพฤติกรรมที่ใหญ่ที่สุดเมื่อใช้ schema เดียวกันข้าม draft ดังนั้นยืนยันก่อนว่า validator ของคุณรองรับ draft ใดก่อนย้าย

ควรใช้ $defs หรือ definitions

ใช้ $defs สำหรับงานใหม่; มันมาพร้อม 2019-09 definitions ไม่ใช่คีย์เวิร์ดใน 2020-12 pointer อย่าง $ref: "#/definitions/foo" ยัง resolve ได้เพราะเป็น JSON Pointer ธรรมดา แต่ strict mode จะรายงานคีย์เวิร์ดที่ไม่รู้จัก การผสมทั้งสองสไตล์ทิ้งกับระเบิดที่ระเบิดเฉพาะใน CI

ทำไม oneOf ปฏิเสธข้อมูลที่ดูถูกต้อง

เพราะข้อมูลเป็นจริงกับสองสาขาพร้อมกัน oneOf ต้องการเพียงหนึ่งเดียว และสาขาที่จำกัดเพียงบางส่วนของออบเจกต์ก็เป็นจริงได้แม้ฟิลด์ของสาขาอื่นหายไป ให้เพิ่มแท็ก const ให้แต่ละสาขาเพื่อทำให้มันแยกจากกัน

การอ้างอิงแบบเรียกซ้ำจะวนไม่รู้จบไหม

ไม่ validator ไม่ขยายการอ้างอิงตอน compile; มันเรียกซ้ำบนข้อมูลจริง ความเสี่ยงจริงคือข้อมูลที่ลึกพอจะทำให้ stack overflow และลำดับการ resolve ที่ผิดระหว่างไฟล์ที่อ้างถึงกัน ขีดจำกัดความลึกระดับธุรกิจบนฟิลด์แบบเรียกซ้ำได้ผลกว่ากลเม็ด schema ใด ๆ

โปรดักชันพึ่ง $ref ระยะไกลได้ไหม

ไม่ได้ การอ้างอิงระยะไกลผูกความพร้อมของการตรวจสอบกับเครือข่าย: การเปลี่ยนโดเมน ใบรับรองหมดอายุ และการแยกเครือข่ายภายในล้วนทำให้ห่วงโซ่พัง ให้ bundle schema ที่ถูกอ้างอิงเข้า artifact หรือจดทะเบียนตอนเริ่มระบบหากต้องการพฤติกรรมที่คาดเดาได้

จะตัดสินใจระหว่าง $defs กับไฟล์แยกอย่างไร

โครงสร้างที่ปรากฏในสองที่คัดลอกได้ครั้งเดียว; เมื่อปรากฏที่ที่สาม ให้ยกขึ้นเป็น $defs เมื่อภาษาอื่นหรือ repository อื่นต้องอ่านมัน จึงคู่ควรกับไฟล์แยกที่มี $id ของตัวเอง

ทำไมข้อจำกัดภายใน $defs ไม่มีผล

$defs เป็นเพียงคอนเทนเนอร์ และโหนดที่ไม่มี $ref ชี้ไปจะไม่เข้าร่วมการตรวจสอบ รายการ required ที่วางไว้บนสุดของ $defs โดยไม่มีการอ้างอิงเข้ามายังปล่อยให้ข้อมูลผ่าน — นั่นคือข้อกำหนด ไม่ใช่ bug ของ validator

ควรวาง additionalProperties: false ภายในสาขาของ allOf ไหม

ไม่ แต่ละสาขาถูกประเมินแยกกัน สาขาหนึ่งจึงตีฟิลด์ที่สาขาอื่นประกาศว่าเกิน แนวทางของ 2020-12 ย้ายนโยบายฟิลด์ที่ไม่รู้จักไประดับบนสุดและใช้ unevaluatedProperties: false ซึ่งประเมินครั้งเดียวหลังทุกสาขา