Tái sử dụng JSON Schema: $ref, $defs và kết hợp allOf/oneOf
Kết luận: $ref giải quyết tái sử dụng, allOf và oneOf giải quyết kết hợp, còn xếp chồng cả hai chính là chỗ mọi thứ vỡ. Bài viết bao gồm $id, $defs, điều kiện, đệ quy, bundle và debug.

Chốt lại: trước hết hãy tách các cấu trúc dùng chung vào $defs bằng $ref, rồi mới đến allOf/oneOf; khi các nhánh có thể cùng khớp, hãy dùng anyOf hoặc thẻ const, và chỉ giữ oneOf cho những trường hợp thật sự cần đúng một nhánh khớp.
Sao chép dán tiết kiệm mười phút và khiến bạn trả giá bằng một nhánh rẽ âm thầm ba tháng sau, khi không ai còn nhớ bản nào mới là đúng.
1. Ba lớp tái sử dụng — hãy chọn một trước khi tách tệp
Khi các trường bắt đầu nhân lên, phản xạ đầu tiên là nhồi mọi ràng buộc vào một tệp duy nhất. Đây là cách nhanh nhất để ship, nhưng vượt qua vài trăm dòng thì mỗi thay đổi trường đều phải nhân bản ở ba nơi. Quên một chỗ là bạn nhận "chạy đúng ở máy local, trả 500 ở production". Tái sử dụng nghĩa là viết một sự thật một lần và tham chiếu nó ở mọi nơi khác.
JSON Schema cung cấp ba lớp, mỗi lớp có phạm vi khác nhau:
| Lớp | Vật mang | Phạm vi | Cách dùng điển hình |
|---|---|---|---|
| Cấu trúc con | $defs + $ref |
Một tệp schema | Địa chỉ, tiền, phân trang, hình dạng lỗi |
| Tệp | Schema độc lập + $id |
Nhiều tệp trong một service | Thực thể user, order, product |
| Thư viện hợp đồng | Kho schema + sản phẩm đã bundle | Nhiều service, nhiều ngôn ngữ | OpenAPI components, hợp đồng công bố |
Với hầu hết dự án, lớp đầu tiên là đủ. Tách thành tệp chỉ có lợi khi hai service thật sự chia sẻ cùng một hợp đồng, hoặc khi schema là sản phẩm công bố được một ngôn ngữ khác tiêu thụ. Tách quá sớm làm việc phân giải $id và đường dẫn tương đối khó debug hơn, và chi phí đó thường vượt giá trị tiết kiệm được nhờ tránh lặp.
Một quy tắc ngón tay cái đơn giản: nếu một cấu trúc xuất hiện ở hai chỗ, giữ một bản là chấp nhận được; khi nó xuất hiện ở chỗ thứ ba, hãy nâng nó lên $defs; chỉ khi một ngôn ngữ hoặc kho mã khác phải đọc nó thì nó mới đáng có tệp độc lập với $id riêng.
2. $id thiết lập danh tính, $ref thực hiện bước nhảy
$id không phải là comment. Nó khai báo base URI của schema. Một giá trị $ref là một URI reference: nó được phân giải theo base URI hiện tại thành URI tuyệt đối, rồi fragment sau # định vị một nút. Nếu base URI sai, mọi tham chiếu tương đối đều trỏ sai chỗ, nhưng lỗi thường chỉ nói "can't resolve reference" mà không nêu tên host sai.
{
"$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 dùng JSON Pointer còn billTo dùng anchor; cả hai đều đến cùng một nút. Bảng dưới ánh xạ từng dạng với cách phân giải của nó:
| Dạng | Trỏ tới | Draft | Ghi chú |
|---|---|---|---|
# |
Gốc của schema hiện tại | Tất cả | Phổ biến cho đệ quy |
#/$defs/address |
Nút dưới $defs |
2019-09 trở đi | Rõ ràng nhất; nên ưu tiên |
#address |
Anchor có tên | $anchor trong 2020-12 |
Draft-07 viết $id: "#address" |
common.json |
Tệp tương đối với base URI | Tất cả | Phải được đăng ký hoặc bundle |
https://example.com/x.json |
URI tuyệt đối | Tất cả | Tránh tải lúc chạy trong production |
Một cái bẫy tinh vi: nếu một schema con bên trong $defs khai báo $id riêng, nó trở thành base URI mới cho mọi thứ bên dưới. Dán một schema chép từ tệp khác và $id còn sót lại sẽ chuyển hướng các tham chiếu tương đối của nó về host gốc — và trình xác thực chỉ nhận ra khi nó thật sự cố phân giải chúng.
3. $defs là vật chứa, không phải nơi xác thực
$defs là một map thông thường. Không thứ gì bên trong nó có hiệu lực cho đến khi một $ref trỏ tới đó. Một danh sách required nằm trên đầu $defs mà không có tham chiếu nào hướng vào sẽ xác thực đúng không gì cả, dù nó trông nghiêm ngặt đến đâu. Đó là đặc tả hoạt động đúng như thiết kế, không phải lỗi trình xác thực.
{
"$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" }
}
}
Có vài quy ước nên thống nhất trong nhóm:
- Các khóa bên trong
$defskhông mang ngữ nghĩa.address,Addressvàaddrđều hợp lệ, nên hãy thống nhất một phong cách. - Trước 2019-09, từ khóa này là
definitions. Cả hai đều có thể được$reftrỏ tới, nhưng chế độ strict có thể báodefinitionslà từ khóa không xác định. $defscó thể chứa bất kỳ schema nào, kể cả một mảnhallOfhoặc một schema con độc lập với$idriêng.- Đừng coi
$defslà kế thừa template. Nó chỉ lưu định nghĩa; ý nghĩa vẫn đến từ ngữ cảnh tham chiếu.
4. allOf, anyOf và oneOf không thể thay thế cho nhau
Cả ba đều liên quan đến nhiều nhánh, nhưng quy tắc chấp nhận của chúng khác nhau. Khi dữ liệu khớp hai nhánh, allOf và anyOf đạt còn oneOf fail — đây là lý do phổ biến nhất khiến người ta kết luận "schema bị hỏng".
| Từ khóa | Số khớp tối thiểu | Số khớp tối đa | Dữ liệu khớp hai nhánh | Chất lượng lỗi | Cách dùng điển hình |
|---|---|---|---|---|---|
allOf |
Tất cả | Không giới hạn | Đạt | Tốt, từng ràng buộc một | Kết hợp các mảnh ràng buộc |
anyOf |
1 | Không giới hạn | Đạt | Kém, các nhánh dồn đống | "Email hoặc số điện thoại, một trong hai" |
oneOf |
1 | 1 | Fail | Kém | Union có discriminator |
not |
0 | 0 | Fail | Tệ nhất | Loại trừ vài giá trị; ít dùng |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf không hợp nhất các định nghĩa object; nó chỉ đòi mọi nhánh đều thỏa. Mỗi nhánh được đánh giá độc lập, nên một additionalProperties: false bên trong một nhánh chỉ biết các khóa mà nhánh đó khai báo và sẽ đánh dấu các trường do nhánh khác khai báo là dư. Trong 2020-12, cách sửa là đặt chính sách trường không xác định ở cấp cao nhất và dùng unevaluatedProperties: false, được đánh giá một lần sau tất cả các nhánh.
Dùng anyOf cho "nhiều hình dạng hợp lệ", không dùng cho "nhiều hình dạng loại trừ nhau". Nếu hai nhánh có thể cùng khớp, oneOf sẽ tạo ra thông báo nổi tiếng khó hiểu must match exactly one schema in oneOf.
5. if/then/else và discriminator: làm cho điều kiện quyết định được
Các ràng buộc có điều kiện thường cho thông báo lỗi tốt hơn oneOf vì đường quyết định là tường minh: if đạt thì then áp dụng; ngược lại else áp dụng.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": {
"properties": { "kind": { "const": "image" } },
"required": ["kind"]
},
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
Ba chi tiết quyết định việc nó có hành xử đúng như mong muốn hay không:
- Một mục
propertiesbên trongifkhông fail khi khóa vắng mặt. Hãy ghép nó vớirequiredđể diễn đạt "kind tồn tại và bằng image". - Bản thân
ifkhông bao giờ sinh ra lỗi xác thực. Fail nó chỉ đơn giản chuyển sangelse, nên nó không thể tự chặn dữ liệu xấu. - Một
thenhoặcelseđứng riêng là vô hiệu. Chúng chỉ áp dụng khi đi kèm mộtif.
Khi các nhánh union lớn dần, chuỗi điều kiện trở thành một mớ lồng nhau không bảo trì được. Một mẫu vững chắc hơn là khóa một trường thẻ bên trong mỗi nhánh để các nhánh loại trừ nhau ngay từ cấu trúc:
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator là phần mở rộng của OpenAPI 3.x, không phải từ khóa JSON Schema. Ajv hỗ trợ nó khi bật tùy chọn discriminator: true, nhưng ánh xạ của nó nằm trong mapping và không khả chuyển giữa các bộ công cụ. Nếu schema phải chạy được trong nhiều trình xác thực, hãy ưu tiên mẫu thẻ const, vốn không phụ thuộc vào phần mở rộng nào.
6. Tham chiếu đệ quy: cây, danh sách liên kết và những tệp tham chiếu lẫn nhau
Cấu trúc đệ quy không cần cú pháp đặc biệt — một $ref trỏ về chính nó là đủ. Các trình xác thực không mở rộng tham chiếu vô hạn lúc biên dịch; chúng đệ quy theo độ sâu thực tế của dữ liệu, nên vài chục dòng mô tả được một cây cao tùy ý.
{
"$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"
}
Ba ranh giới nên xác định trước:
- Độ sâu dữ liệu: schema không áp giới hạn, nhưng xác thực và serialization đều tiêu thụ stack tỉ lệ với độ sâu. API công khai nên thêm một giới hạn nghiệp vụ, ví dụ tám cấp, nếu không một payload cố tình làm sâu có thể làm tràn stack.
- Tham chiếu lẫn nhau: A tham chiếu B và B tham chiếu A là hợp lệ, nhưng cả hai schema phải được đăng ký trước; nếu không, thứ tự phân giải sẽ quyết định kết quả.
- Nhánh nullable: nếu một trường đệ quy có thể là
null, hãy viết"type": ["object", "null"]hoặc bọc nó trongoneOf.$refkhông kế thừa tính nullable.
7. Tham chiếu chéo tệp và bundle: đừng bao giờ tải schema trong production
Tham chiếu chéo tệp thường "chạy được ngay" khi phát triển vì trình xác thực tải mục tiêu theo $id qua mạng. Thay đổi chính sách mạng lúc triển khai và toàn bộ chuỗi xác thực sẽ vỡ. Quy tắc production rất đơn giản: mọi schema được tham chiếu phải được đăng ký trong tiến trình hoặc bundle trước.
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);
Nếu order.json có $id: "https://bbdrug.com/schemas/order.json", thì "$ref": "address.json" của nó phân giải thành https://bbdrug.com/schemas/address.json, khớp với khóa đã đăng ký. Một ký tự khác biệt là đủ để sinh ra can't resolve reference.
Ba cách thực hành được so sánh như sau:
| Cách tiếp cận | Cách làm | Điểm mạnh | Chi phí |
|---|---|---|---|
| Đăng ký lúc chạy | addSchema từng tệp |
Không cần bước build, các tệp độc lập | Thứ tự đăng ký và khóa phải làm thủ công |
| Bundle lúc build | Gộp bằng một ref parser thành một tệp | Không phụ thuộc lúc chạy | Sản phẩm lớn hơn, diff ồn ào |
| Tải bất đồng bộ | loadSchema + compileAsync |
Xử lý schema thay đổi thường xuyên | Lo về mạng và cache; tránh dùng trên đường tới hạn |
Với dự án frontend, bundle lúc build an toàn hơn. Trên máy chủ, đăng ký là đủ, miễn có một bài kiểm thử chứng minh mọi tham chiếu đều phân giải được. Dù cách nào, hãy chạy compile trong CI để lỗi tham chiếu lộ ra trước khi merge thay vì ở request production đầu tiên.
8. Thứ tự debug: phân giải, biên dịch, rồi mới soi các nhánh
Gần như mọi vấn đề $ref đều có thể định vị qua ba giai đoạn, và thứ tự rất quan trọng:
| Triệu chứng | Nguyên nhân khả dĩ | Cách sửa |
|---|---|---|
can't resolve reference ... from id ... |
Gõ sai $id, base tương đối sai, mục tiêu chưa đăng ký |
In $id và URI tuyệt đối sau khi phân giải, rồi so với các khóa đã đăng ký |
schema with key or id already exists |
Cùng $id được đăng ký hai lần, hoặc compile bị gọi lặp |
Chuyển việc đăng ký về khởi tạo module và chạy một lần |
strict mode: unknown keyword: "definitions" |
Trộn cú pháp draft-07 | Đổi tên thành $defs; đừng tắt chế độ strict |
Dữ liệu khớp hai nhánh oneOf |
Ràng buộc nhánh chồng lấn, không có trường thẻ | Thêm const cho mỗi nhánh, hoặc chuyển sang anyOf |
Ràng buộc trong $defs không có tác dụng |
Không có gì tham chiếu tới chúng | Kiểm tra đường dẫn tham chiếu; $defs không tự xác thực gì |
Khi debug, hãy đọc schemaPath trước message: nó nêu tên nhánh và từ khóa bị fail và đáng tin hơn nhiều so với câu văn tiếng Anh. Hãy cache bộ xác thực đã biên dịch ở cấp module thay vì biên dịch lại mỗi request. Với mọi thay đổi schema, hãy bao phủ cả ca đạt lẫn ca fail — chỉ kiểm thử đường thành công là không kiểm thử gì. Về chọn trình xác thực và thiết kế thông báo lỗi, xem JSON Schema: Từ cơ bản đến triển khai thực tế; về việc nối schema vào sinh kiểu, xem Đồng bộ hai chiều giữa Schema và kiểu TypeScript.
9. Câu hỏi thường gặp
$ref có thể nằm cạnh type hoặc description không?
2020-12 cho phép $ref đi cùng các từ khóa cùng cấp, và các từ khóa cùng cấp đó được đánh giá bình thường. Trong draft-07, các từ khóa cùng cấp bị bỏ qua và chỉ $ref có hiệu lực. Đây là khác biệt hành vi lớn nhất khi dùng cùng một schema qua nhiều draft, nên hãy xác nhận trình xác thực của bạn hỗ trợ draft nào trước khi di chuyển.
Nên dùng $defs hay definitions?
Dùng $defs cho công việc mới; nó xuất hiện từ 2019-09. definitions không phải từ khóa trong 2020-12. Một pointer như $ref: "#/definitions/foo" vẫn có thể phân giải vì nó là JSON Pointer thuần, nhưng chế độ strict sẽ báo từ khóa không xác định. Trộn cả hai phong cách để lại một quả mìn chỉ nổ trong CI.
Tại sao oneOf từ chối dữ liệu trông có vẻ hợp lệ?
Vì dữ liệu thỏa hai nhánh cùng lúc. oneOf đòi đúng một nhánh, và một nhánh chỉ ràng buộc một phần object vẫn được thỏa kể cả khi các trường của nhánh kia vắng mặt. Hãy thêm thẻ const cho mỗi nhánh để chúng loại trừ nhau.
Tham chiếu đệ quy có lặp vô hạn không?
Không. Các trình xác thực không mở rộng tham chiếu lúc biên dịch; chúng đệ quy trên dữ liệu thật. Rủi ro thật sự là dữ liệu sâu đến mức làm tràn stack và thứ tự phân giải sai giữa các tệp tham chiếu lẫn nhau. Một giới hạn độ sâu ở cấp nghiệp vụ cho các trường đệ quy hiệu quả hơn mọi mánh khóe schema.
Production có thể dựa vào $ref từ xa không?
Không nên. Tham chiếu từ xa trói khả năng xác thực vào mạng: đổi tên miền, chứng chỉ hết hạn và cô lập mạng nội bộ đều làm vỡ chuỗi. Hãy bundle các schema được tham chiếu vào sản phẩm hoặc đăng ký chúng lúc khởi động nếu muốn hành vi có thể dự đoán.
Làm sao quyết định giữa $defs và tệp riêng?
Một cấu trúc xuất hiện ở hai chỗ có thể chép một lần; khi nó xuất hiện ở chỗ thứ ba, hãy nâng lên $defs. Chỉ khi một ngôn ngữ khác hoặc một kho mã khác phải đọc nó thì nó mới đáng có tệp độc lập với $id riêng.
Tại sao ràng buộc bên trong $defs không có tác dụng?
$defs chỉ là vật chứa, và những nút không được $ref nào trỏ tới sẽ không bao giờ tham gia xác thực. Một danh sách required trên đầu $defs mà không có tham chiếu hướng vào vẫn để dữ liệu đi qua — đó là đặc tả, không phải lỗi trình xác thực.
Có nên đặt additionalProperties: false bên trong các nhánh allOf không?
Không. Mỗi nhánh được đánh giá độc lập, nên một nhánh sẽ coi các trường do nhánh khác khai báo là dư. Cách của 2020-12 là chuyển chính sách trường lạ lên cấp cao nhất và dùng unevaluatedProperties: false, được đánh giá một lần sau tất cả các nhánh.