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

สรุปสั้น: แยกโครงสร้างที่ใช้ร่วมกันเข้า $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"] }
}
รายละเอียดสามข้อกำหนดว่ามันทำงานตามที่ตั้งใจหรือไม่:
- รายการ
propertiesภายในifไม่ล้มเหลวเมื่อคีย์หายไป ให้จับคู่กับrequiredเพื่อแสดง "kind มีอยู่และเท่ากับ image" ifเองไม่เคยสร้าง error ของการตรวจสอบ การไม่ผ่านมันเพียงเปลี่ยนเส้นทางไป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 และไม่พกพาข้าม 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 ซึ่งประเมินครั้งเดียวหลังทุกสาขา