JSON Toolkit

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ế.

Trung cấpĐọc khoảng 14 phútXuất bản 2026-10-30Cập nhật 2026-10-30
Ảnh bìa cho JSON Schema: Từ cơ bản đến triển khai 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:

  1. required chỉ đò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êm minLength: 1;
  2. additionalProperties: false phó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;
  3. properties khô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" }
  }
}
  • $defs là dạng được giới thiệu từ 2019-09; draft-07 dùng definitions;
  • Tham chiếu chéo tệp dùng $id tươ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 $ref vớ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ùng allOf.

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 compile mộ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: true phát hiện sớm các từ khóa gõ sai (ví dụ minlength thay vì minLength), nên rất đáng bật;
  • Chỉ có ajv-formats mới làm cho format: "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 oneOf gâ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;
  • uniqueItems thự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: false kết hợp với allOf, 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ùng unevaluatedProperties.

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 + $ref thay vì sao chép dán;
  • [ ] Ưu tiên discriminator thay cho oneOf;
  • [ ] Tái sử dụng kết quả compile của trình xác thực và bật chế độ strict;
  • [ ] Ghi rõ trong tài liệu liệu format có 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.

Tài liệu tham khảo

Bài viết liên quan

Đồng bộ hai chiều giữa Schema và kiểu TypeScript

Kiểu và schema mỗi thứ phủ một đoạn: một bên ở thời điểm biên dịch, một bên ở thời điểm chạy. Vấn đề không phải chọn bên nào, mà là làm sao giữ hai bên đồng bộ một cách tự động.

Trung cấpĐọc khoảng 11 phútCập nhật 2026-10-30

Không đóng additionalProperties thì xác thực Schema vô nghĩa

Xác thực thành công không đồng nghĩa dữ liệu sạch. Pydantic mặc định bỏ qua trường lạ, Zod gỡ bỏ chúng, Ajv cho qua — tham số bịa và khóa gõ sai lọt thẳng. Bài viết chỉ ra ba dạng chấp nhận im lặng và đưa ra cách làm đầy đủ: đóng đối tượng, kiểm soát ép kiểu, siết từng tầng lồng nhau.

IntermediateĐọc khoảng 8 phútCập nhật 2026-11-02