JSON Schema: Từ cơ bản đến triển khai thực tế
Kết luận: Từ khóa thì nhiều, nhưng hằng ngày chỉ dùng hơn chục cái. Nắm đúng type/properties/required/$ref, xử lý tốt thông báo lỗi là đã phủ được tám mươi phần trăm tình huống thực tế.

Chốt lại: hãy ghim $schema về 2020-12 trước, rồi tách các cấu trúc dùng chung bằng $defs + $ref; chỉ dùng oneOf khi bắt buộc đúng một nhánh khớp, còn mọi trường hợp khác nên ưu tiên discriminator hoặc anyOf.
Schema không phải phụ lục của tài liệu; nó là tài sản có thể thực thi, kiểm thử và sinh mã.
1. Trước hết phải xác định đúng phiên bản
| Phiên bản | Giá trị $schema |
Trạng thái | Ghi chú |
|---|---|---|---|
| draft-04 | http://json-schema.org/draft-04/schema# |
Lỗi thời | Dự án cũ và một số công cụ OpenAPI 2.0 |
| draft-07 | http://json-schema.org/draft-07/schema# |
Dùng rộng rãi | Nền tảng thực tế của OpenAPI 3.0 |
| 2019-09 | https://json-schema.org/draft/2019-09/schema |
Có sẵn | Giới thiệu $defs và bộ từ vựng chú thích |
| 2020-12 | https://json-schema.org/draft/2020-12/schema |
Đang được khuyến nghị | Giới thiệu prefixItems, thay đổi ngữ nghĩa của items |
Dùng 2020-12 cho dự án mới. Lưu ý rằng trong 2020-12, items chỉ ràng buộc "các phần tử còn lại" — kiểu khai báo tuple đã chuyển sang prefixItems.
Đó là cái bẫy phổ biến nhất khi di chuyển từ draft-07.
2. Một schema tối thiểu dùng được
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/user.json",
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1, "maxLength": 64 },
"age": { "type": "integer", "minimum": 0, "maximum": 150 },
"tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
},
"additionalProperties": false
}
Schema này bao trùm bốn mối quan tâm phổ biến nhất: ràng buộc kiểu, trường bắt buộc, giới hạn miền giá trị và ràng buộc phần tử mảng.
3. Bản đồ từ khóa
3.1 Tổng quan
| Từ khóa | Mục đích | Lưu ý |
|---|---|---|
type |
Kiểu dữ liệu; có thể là một mảng | "type": ["string", "null"] nghĩa là có thể null |
enum |
Liệt kê các giá trị | Các giá trị phải duy nhất |
const |
Ràng buộc một giá trị duy nhất | Hợp cho giá trị cố định như số phiên bản |
$ref |
Tham chiếu tới một schema khác | Tham chiếu toàn bộ schema và không hợp nhất với các từ khóa cùng cấp trên object tham chiếu |
3.2 Số
multipleOf, maximum, exclusiveMaximum, minimum, exclusiveMinimum.
Lưu ý rằng trong draft-04, exclusiveMaximum là boolean (dùng cùng maximum); chỉ từ draft-07 trở đi nó mới thành từ khóa số độc lập —
điều này khiến nhiều "schema chép từ tài liệu cũ" hỏng dưới chế độ strict.
3.3 Chuỗi
minLength, maxLength, pattern (biểu thức chính quy ECMA-262) và format (một chú thích).
minLength đếm điểm mã Unicode, không phải đơn vị UTF-16 và cũng không phải byte. Xác thực bằng string.length của JavaScript sẽ cho kết quả khác.
3.4 Mảng
| Từ khóa | Mục đích |
|---|---|
prefixItems |
Ràng buộc kiểu tuple của 2020-12, khớp từng phần tử theo chỉ số |
items |
Ràng buộc đồng nhất cho các phần tử còn lại (2020-12) |
contains + minContains/maxContains |
Phải có ít nhất/nhiều nhất bao nhiêu phần tử khớp |
uniqueItems |
Các phần tử phải hoàn toàn khác nhau (so sánh sâu; tránh dùng trên mảng lớn) |
minItems / maxItems |
Ràng buộc độ dài |
3.5 Đối tượng
properties, required, patternProperties, additionalProperties, propertyNames,
minProperties, maxProperties, dependentRequired, dependentSchemas.
Ba hiểu lầm có tần suất cao:
requiredchỉ đòi khóa phải tồn tại:{"name": ""}vẫn hợp lệ. Muốn từ chối chuỗi rỗng, bạn phải thêmminLength: 1;additionalProperties: falsephóng đại vấn đề tương thích: một trường dư thừa từ phía client làm hỏng toàn bộ payload, nên mỗi lần API tiến hóa, mọi bên gọi đều buộc phải nâng cấp đồng loạt;propertieskhông hạn chế các khóa không khai báo — mặc định chúng được phép.
3.6 Kết hợp logic
{
"oneOf": [
{ "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
{ "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
]
}
| Từ khóa | Ngữ nghĩa | Lỗi điển hình |
|---|---|---|
allOf |
Tất cả phải thỏa mãn | Nhiều nhánh áp ràng buộc xung đột lên cùng một trường |
anyOf |
Ít nhất một nhánh phải thỏa mãn | Thông báo lỗi khó truy ra nhánh cụ thể |
oneOf |
Đúng một nhánh phải thỏa mãn | Dữ liệu khớp hai nhánh và fail, dù "trông rõ ràng là đúng" |
not |
Không được thỏa mãn | Ít dùng, và thông báo lỗi kém thân thiện nhất |
oneOf là từ khóa dễ sinh ra "dữ liệu hợp lệ nhưng fail xác thực" nhất. Nếu ràng buộc giữa các nhánh chồng lấn (ví dụ cả hai đều cho phép trường bổ sung),
một bản ghi sẽ khớp nhiều nhánh và oneOf fail ngay. Trong trường hợp đó, hãy chuyển sang anyOf, hoặc dùng trường discriminator với const bên trong từng nhánh.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": { "properties": { "kind": { "const": "image" } } },
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
Kiểu discriminator dễ đọc hơn oneOf, và cho thông báo lỗi tốt hơn.
4. Tách và tái sử dụng
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"money": { "type": "string", "pattern": "^-?\\d+\\.\\d{2}$" },
"address": {
"type": "object",
"required": ["city"],
"properties": { "city": { "type": "string" }, "detail": { "type": "string" } }
}
},
"type": "object",
"required": ["total", "shipTo"],
"properties": {
"total": { "$ref": "#/$defs/money" },
"shipTo": { "$ref": "#/$defs/address" }
}
}
$defslà dạng được giới thiệu từ 2019-09; draft-07 dùngdefinitions;- Tham chiếu chéo tệp dùng
$idtương đối, nhưng đừng dựa vào việc tải schema từ xa lúc chạy trong môi trường production — hãy bundle chúng thành tệp tĩnh; - Bạn không thể thêm ràng buộc bên cạnh một tham chiếu (hành vi của
$refvới các từ khóa cùng cấp thay đổi theo phiên bản). Khi cần kết hợp, hãy dùngallOf.
5. Xác thực lúc chạy với Ajv
import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({
allErrors: true,
strict: true,
allowUnionTypes: true,
});
addFormats(ajv);
const validateOrder = ajv.compile(orderSchema);
export function assertOrder(input: unknown): void {
if (validateOrder(input)) {
return;
}
const detail = (validateOrder.errors ?? []).map((errorItem) => ({
path: errorItem.instancePath || '/',
keyword: errorItem.keyword,
message: errorItem.message,
params: errorItem.params,
}));
throw new Error(`Order data failed validation: ${JSON.stringify(detail)}`);
}
Những điểm chính:
- Chỉ gọi
compilemột lần, ở cấp module; biên dịch lại mỗi request sẽ biến một lần xác thực 10 ms thành hàng trăm mili giây; strict: truephát hiện sớm các từ khóa gõ sai (ví dụminlengththay vìminLength), nên rất đáng bật;- Chỉ có
ajv-formatsmới làm choformat: "email"vàformat: "date-time"thực sự có hiệu lực; - 2020-12 cần điểm vào
ajv/dist/2020— đừng dùng export mặc định.
6. Thiết kế thông báo lỗi như thế nào
Trình xác thực cho bạn biết "từ khóa nào fail"; người dùng muốn biết "ô nhập nào sai". Rất đáng để thêm một lớp ánh xạ:
const FIELD_LABEL: Record<string, string> = {
'/name': 'Name',
'/age': 'Age',
'/tags': 'Tags',
};
/**
* Convert Ajv errors into display-ready messages.
* @param errors The list of Ajv errors
* @returns An array of display messages
*/
function toMessages(errors: Array<{ instancePath: string; keyword: string; params: unknown }>): string[] {
return errors.map((errorItem) => {
const label = FIELD_LABEL[errorItem.instancePath] ?? errorItem.instancePath;
if (errorItem.keyword === 'required') {
const missing = (errorItem.params as { missingProperty: string }).missingProperty;
return `${label} is missing the required field ${missing}`;
}
if (errorItem.keyword === 'minLength') {
return `${label} is too short`;
}
return `${label} failed validation (${errorItem.keyword})`;
});
}
Phía API, hãy giữ một cấu trúc đọc được bằng máy để front end quyết định cách hiển thị:
{
"type": "https://bbdrug.com/problems/validation",
"title": "Request data validation failed",
"status": 422,
"errors": [
{ "path": "/items/0/price", "keyword": "pattern", "message": "Must be a monetary string with two decimal places" }
]
}
7. Hiệu năng và những cái bẫy
- Chi phí xác thực tăng theo độ phức tạp của schema và kích thước dữ liệu; nhiều nhánh
oneOfgây bùng nổ tổ hợp, và điều này trở nên rất rõ khi có hàng nghìn bản ghi; uniqueItemsthực hiện so sánh sâu O(n²) trên mảng; với mảng lớn, hãy khử trùng lặp ở lớp nghiệp vụ thay vì dùng nó;- Tránh các mẫu backtracking thảm khốc trong
pattern(như(a+)+$); đây là điểm vào kinh điển của ReDoS; - Khi
additionalProperties: falsekết hợp vớiallOf, các trường được khai báo trong schema con sẽ bị schema cha coi là "bổ sung", nên bạn cần gộp chúng vào một schema duy nhất hoặc dùngunevaluatedProperties.
8. Danh sách kiểm tra
- [ ] Ghim 2020-12 và khai báo
$schemaở đầu tệp; - [ ] Tách các cấu trúc dùng chung bằng
$defs+$refthay vì sao chép dán; - [ ] Ưu tiên discriminator thay cho
oneOf; - [ ] Tái sử dụng kết quả
compilecủa trình xác thực và bật chế độ strict; - [ ] Ghi rõ trong tài liệu liệu
formatcó bị cưỡng chế hay không; - [ ] Phản hồi lỗi cung cấp đường dẫn, từ khóa và một thông báo hướng tới người dùng;
- [ ] Đưa schema vào CI: chạy các ca của JSON Schema Test Suite như kiểm thử hồi quy.
FAQ
Nên chọn draft JSON Schema nào?
Dùng 2020-12 cho dự án mới và khai báo $schema ở đầu tệp. Nếu schema đi vào OpenAPI 3.1 thì cũng phải là 2020-12, vì 3.1 nhúng trực tiếp phiên bản đó; chỉ giữ draft-07 cho những dự án còn gắn với bộ công cụ cũ. Hai phiên bản hành xử khác nhau với items và với các từ khóa cùng cấp của $ref, nên đừng trộn chúng trong cùng một bộ schema.
Tại sao additionalProperties: false lại phóng đại vấn đề tương thích?
Nó coi mọi khóa không khai báo là lỗi, nên một trường dư thừa từ client sẽ làm hỏng toàn bộ payload. Mỗi lần API tiến hóa, mọi bên gọi đều phải nâng cấp đồng loạt, nếu không client cũ sẽ bị từ chối. Schema kiểu cấu hình nên đóng các trường bổ sung một cách tường minh, còn API công khai cần cân nhắc chi phí tương thích.
Nên dùng oneOf hay anyOf?
Chỉ dùng oneOf khi dữ liệu bắt buộc phải khớp đúng một nhánh. Nếu ràng buộc giữa các nhánh có thể cùng thỏa mãn, một bản ghi sẽ khớp nhiều nhánh và oneOf fail ngay; hãy chuyển sang anyOf, hoặc thêm discriminator const bên trong mỗi nhánh.