JSON Toolkit

Tại sao JSON Schema sinh tự động cứ từ chối dữ liệu thật

Kết luận: Schema sinh ra thường chặn dữ liệu hợp lệ. Hãy hiểu tại sao mẫu càng đầy đủ lại cho schema càng tệ, rồi làm theo quy trình: lấy mẫu nhiều bản ghi, bổ sung ngữ nghĩa, chỉ ra lỗi chính xác.

Trung cấpĐọc khoảng 14 phútXuất bản 2026-10-30Cập nhật 2026-10-30
Ảnh bìa minh họa xung đột giữa bản nháp JSON sang Schema và dữ liệu thật

Chốt lại: trước hết hãy tự tay thu hẹp ranh giới required/optional — nạp 5–20 bản ghi có hình dạng khác nhau với "all required" tắt — rồi sinh schema và bổ sung thủ công format, enum, additionalProperties; một mẫu đầy đủ mọi trường sẽ luôn quá chặt.

Bộ sinh chỉ nhìn thấy đúng bản ghi bạn vừa dán vào. Nó đọc "bản ghi này có email" thành "mọi bản ghi đều phải có email", nên mẫu của bạn càng đầy đủ thì nó càng đòi hỏi nhiều.

1. Bộ sinh làm ánh xạ cấu trúc, không hiểu nghiệp vụ

Đưa một khối JSON cho bộ sinh và nó chỉ làm đúng một việc: duyệt cấu trúc của các giá trị rồi dịch từng nút thành từ khóa schema. Toàn bộ bảng ánh xạ là cố định:

Giá trị trong mẫu Kiểu suy ra Ghi chú
"hello" {"type": "string"} Không phân biệt email, ngày, UUID
42 {"type": "number"} Số nguyên không được suy ra là integer
3.14 {"type": "number"} Như trên
true {"type": "boolean"} —
null {"type": "null"} Chỉ nhận ra null, không bao giờ hiểu là "nullable"
[1, "a"] {"type": "array", "items": {"type": ["number", "string"]}} Kiểu phần tử được hợp nhất
{"a": 1} {"type": "object", "properties": {...}, "required": [...]} required là mấu chốt ở đây

Toàn bộ rắc rối nằm ở dòng cuối. required không được suy ra từ kiểu; nó được suy ra từ "khóa này có xuất hiện trong mẫu này hay không". Khi mẫu chỉ là một bản ghi, câu đó tương đương với "mọi khóa đều bắt buộc".

Và dữ liệu do API thật trả về gần như luôn có một nửa số trường là tùy chọn. Đó là nơi xung đột bắt đầu.

2. Năm bản ghi cùng hình dạng, năm tỷ lệ chấp nhận

Hãy bắt đầu bằng đo lường. Ai nhìn năm bản ghi dưới đây cũng sẽ nói "chúng cùng một hình dạng":

[
  {
    "id": 1,
    "name": "Ada",
    "email": "ada@example.com",
    "tags": ["a"],
    "score": 10
  },
  { "id": 2, "name": "Bob", "tags": [], "score": 7 },
  {
    "id": 3,
    "name": "Cy",
    "email": "cy@example.com",
    "tags": ["a", "b"],
    "score": 8.5
  },
  { "id": 4, "name": "Dee", "email": null, "tags": ["c"], "score": 9 },
  {
    "id": 5,
    "name": "Eve",
    "email": "eve@example.com",
    "tags": ["d"],
    "score": 6,
    "admin": true
  }
]

email xuất hiện trong 3 trên 5 bản ghi, admin chỉ xuất hiện một lần, và bản ghi 2 có mảng tags rỗng.

Giờ hãy sinh schema từ từng bản ghi riêng lẻ, rồi dùng mỗi kết quả để xác thực cả năm bản ghi (trình xác thực: ajv 8):

Sinh từ Số khóa required sinh ra Chấp nhận trên 5
Bản ghi 1 5 id, name, email, tags, score 3 bản ghi
Bản ghi 2 4 id, name, tags, score 5 bản ghi
Bản ghi 3 5 id, name, email, tags, score 3 bản ghi
Bản ghi 4 5 id, name, email, tags, score 1 bản ghi
Bản ghi 5 6 id, name, email, tags, score, admin 1 bản ghi

Không schema nào trong năm cái là đúng, nhưng cái duy nhất chấp nhận toàn bộ dữ liệu lại sinh từ bản ghi 2 — bản ghi thiếu email. Nó thắng vì một lý do đơn giản: những khóa không bao giờ xuất hiện thì không bao giờ được ghi vào required.

Bản ghi 4 còn đáng chú ý hơn: giá trị email của nó là null, nên bộ sinh ghi cứng {"type": "null"}, và bốn bản ghi còn lại có email dạng chuỗi đều bị từ chối.

3. Tại sao "dán bản ghi đầy đủ nhất" lại là lựa chọn tệ nhất

Trực giác là "dán mẫu có nhiều trường nhất thì schema sẽ đầy đủ nhất". Các phép đo cho kết quả ngược lại hoàn toàn — và một bên thứ ba đã chạy một thử nghiệm có kiểm soát lớn hơn.

absurdtools dùng 3000 "họ dữ liệu" ngẫu nhiên, mỗi họ 8 bản ghi, với mỗi trường tùy chọn xuất hiện khoảng một nửa số lần, rồi đo xem schema sinh từ các mẫu có số khóa khác nhau chấp nhận được bao nhiêu dữ liệu:

Số khóa trong mẫu Tỷ lệ dữ liệu của họ được chấp nhận
2 100.0%
3 56.8%
4 34.8%
5 23.4%
6 17.7%
7 14.9%

Kết luận không phải "giảm nhẹ" mà là đảo ngược hoàn toàn: mẫu tối thiểu tạo ra schema lỏng nhất trong 95% các họ, còn mẫu đầy đủ nhất chỉ làm được điều đó 0.1% số lần.

Nguyên nhân nằm ở ngữ nghĩa của required, nghĩa là "tất cả"; tính tùy chọn chỉ có thể được quan sát qua sự vắng mặt. Dán một bản ghi có đủ mọi trường là nói với bộ sinh rằng "không một khóa nào trong số này được phép vắng"; dán một bản ghi thiếu trường là trao cho nó bằng chứng rằng "khóa này có thể vắng".

Hãy nhớ trong một câu: giá trị của một mẫu không nằm ở độ đầy đủ, mà ở chỗ nó bao phủ những sự vắng mặt nào.

Bao nhiêu bản ghi là đủ

Cùng loạt thử nghiệm đó cũng đo tốc độ hội tụ: tích lũy từng bản ghi theo quy tắc "một khóa chỉ còn required nếu mọi bản ghi đến nay đều có nó", số bản ghi trung vị để required hội tụ về tập bắt buộc thật là 3; 6 bản ghi bao phủ khoảng chín mươi phần trăm các họ, và 12 bản ghi bao phủ tất cả.

Vậy nên lấy mẫu 5–10 bản ghi hằng ngày là hiệu quả nhất so với công sức; ép lên hai mươi bản ghi trở lên chỉ thêm rất ít.

Nhưng có một điều kiện tiên quyết: các bản ghi này phải đến từ những tình huống thật sự khác nhau. Năm bản ghi chép từ cùng một mẫu sẽ không cung cấp thông tin mới nào kể từ bản thứ hai — tập trường của chúng giống hệt nhau, và required không thể thu hẹp dù chỉ một khóa.

4. Cách khắc phục: nạp nhiều bản ghi cùng lúc và để sự vắng mặt lên tiếng

Vì lấy mẫu một bản ghi chắc chắn quá chặt, cách đúng là để bộ sinh nhìn nhiều bản ghi cùng lúc và hội tụ required theo quy tắc "chỉ required nếu mọi bản ghi đều có".

Bộ sinh trên trang này nhận trực tiếp một mảng và có công tắc "all required". Hãy tắt nó đi, bộ sinh sẽ quyết định theo số lần xuất hiện: chỉ những khóa được mọi bản ghi mang theo mới được ghi vào required.

Vẫn năm bản ghi đó — lần này gộp chúng thành một mảng và suy luận cùng nhau, rồi lấy items làm hợp đồng cho phần tử:

Số bản ghi nạp vào required suy ra Xác thực cả 5
1 bản ghi id, name, email, tags, score 3 đạt
2 bản ghi id, name, tags, score 4 đạt
3 bản ghi id, name, tags, score 4 đạt
4 bản ghi id, name, tags, score 5 đạt
5 bản ghi id, name, tags, score 5 đạt

required hội tụ ngay khi thêm bản ghi thứ tư, và không đổi sau đó. email và admin được đặt đúng vào properties nhưng không nằm trong required. Và vì email của bản ghi 4 là null, suy luận nhiều bản ghi gộp nó thành {"type": ["string", "null"]}, nên cả chuỗi lẫn null đều qua — điều mà lấy mẫu một bản ghi không bao giờ làm được.

Đây cùng bậc độ lớn với phát hiện của absurdtools rằng "required hội tụ sau trung vị 3 bản ghi".

Tuy nhiên, suy luận nhiều bản ghi cũng có điểm mù riêng: nếu email trong các bản ghi tình cờ là null ở mọi bản (ví dụ tất cả đều đến từ tài khoản chưa điền hồ sơ), kết quả suy luận sẽ thành {"type": "null"}, và email dạng chuỗi vẫn bị từ chối. Tương tự, nếu mọi giá trị tags đều là mảng rỗng, kiểu phần tử hoàn toàn không thể suy ra.

Vậy nên việc lấy mẫu còn một yêu cầu cứng nữa: mọi dạng của mọi trường phải xuất hiện ít nhất một lần. Hãy mang vào một chuỗi, một số, một null và một mảng rỗng; đừng để bất kỳ dạng nào vắng mặt hoàn toàn.

Cụ thể:

  1. Lấy 5–20 phản hồi từ log, mock hoặc request thật, và nhớ bao gồm các dòng bất thường (dòng có trường rỗng, dòng có null, dòng có mảng rỗng);
  2. Ghép chúng thành một mảng JSON và dán vào bộ sinh;
  3. Tắt "all required" và chọn draft 2020-12;
  4. Kết quả là type: array cộng một items; hãy lấy phần items làm hợp đồng cho một bản ghi;
  5. Xác thực ngay các mẫu gốc bằng schema này — 100% phải đạt. Nếu dù chỉ một cái fail, mẫu của bạn chưa đủ; hãy quay lại thêm bản ghi.

Bước 5 là bước tự kiểm tra rẻ nhất. Nếu bộ sinh còn không chứa được chính dữ liệu bạn nạp vào, phương pháp lấy mẫu của bạn sai, và đi tiếp cũng vô nghĩa.

5. Bốn thứ bộ sinh không thể điền — bạn phải viết tay

Kể cả sau khi nạp nhiều bản ghi, những điều sau vẫn không thể suy ra và phải bổ sung thủ công:

Phần thiếu Hành vi đo được Hệ quả Cách khắc phục
Định dạng chuỗi "a@b.com" chỉ sinh ra {"type": "string"} Xác thực "not an email" vẫn đạt Thêm "format": "email" và bật kiểm tra format
Miền giá trị và enum Không thấy được score bị giới hạn 0–100, cũng không thấy status chỉ có ba giá trị Số âm, giá trị khổng lồ và giá trị enum rác đều qua Thêm minimum / maximum / enum
Chính sách trường lạ additionalProperties không được sinh Khóa gõ sai bị chấp nhận âm thầm, nguy hiểm hơn cả báo lỗi Thêm "additionalProperties": false cho tệp kiểu cấu hình
Ngữ nghĩa null và tập rỗng Một mẫu có null sẽ khóa vào {"type": "null"}; mảng rỗng sinh ra "items": {} Giá trị hợp lệ bị từ chối, hoặc phần tử mảng hoàn toàn không bị ràng buộc Đổi thành ["string", "null"], và cho mảng rỗng phần tử thật

Dòng thứ ba đáng nói riêng: giá trị mặc định của additionalProperties là "cho phép". Nghĩa là một tệp cấu hình gõ timeout thành timeuot vẫn qua xác thực rồi âm thầm không làm gì lúc chạy. Với schema kiểu cấu hình, đây là mục bạn bắt buộc phải tắt tường minh.

format cũng có bẫy: từ draft 2019-09, format thuộc bộ từ vựng chú thích, và mặc định nó chỉ chú thích chứ không cưỡng chế. Nếu bạn đã thêm format: "email" mà giá trị sai vẫn qua, thì tám trên mười trường hợp là trình xác thực chưa bật validateFormats hoặc plugin format tương ứng — không phải schema sai.

Nhân đây nói thêm về việc chọn draft: nếu schema đi vào OpenAPI 3.1 thì phải là draft 2020-12, vì 3.1 nhúng trực tiếp phiên bản đó; các bộ công cụ cũ (hệ sinh thái Swagger 2.0 và một số hiện thực $ref) vẫn xoay quanh draft-07. Hai phiên bản hành xử khác nhau với các từ khóa cùng cấp của $ref và với các từ khóa liên quan mảng, nên đừng trộn chúng trong cùng một bộ schema.

6. Với API bên thứ ba không có tài liệu, hãy suy ngược hợp đồng từ mẫu phản hồi

Tích hợp callback của bên thứ ba, làm việc với đối tác không viết tài liệu, hay bảo trì một hệ thống di sản mười năm tuổi — tất cả đều nghĩa là "dữ liệu trong tay, đặc tả thì không". Ở đây bộ sinh không chỉ là tiện ích; nó là con đường nhanh nhất để có một hợp đồng.

Cách làm giống như trên, với hai yêu cầu bắt buộc thêm:

Một: lấy mẫu theo thời gian và theo trạng thái. Các trường của bên thứ ba thường thay đổi theo trạng thái nghiệp vụ — một chương trình khuyến mãi thêm discount, một đơn hoàn tiền thêm refundAt, và phản hồi lỗi hoàn toàn không có data. Chụp một ảnh duy nhất nghĩa là chỉ thấy một trạng thái.

Hãy nạp cùng lúc vài phản hồi thành công, vài phản hồi thất bại và vài kết quả rỗng: error và data thường xuất hiện loại trừ nhau, và suy luận nhiều bản ghi đặt cả hai vào properties nhưng để ngoài required — đúng với thực tế.

Hai: lưu schema trong kho mã và gắn một bước kiểm tra hằng ngày. Xác thực phản hồi thật của hôm nay theo nó, và bạn sẽ biết về những trường âm thầm biến mất hay đổi kiểu trước cả người dùng.

// Run once a day: contract-drift alert
const drifted = records.filter((record) => !validate(record));

if (drifted.length > 0) {
  console.warn(
    `${drifted.length} responses do not match the contract; first difference:`,
    validate.errors,
  );
}

Giá trị của thực hành này không phải "tiết kiệm công viết tay", mà là biến một thỏa thuận miệng thành một cảnh báo có thể thực thi. Bên thứ ba không bao giờ báo trước rằng nó đã đổi một trường, nhưng thay đổi chắc chắn sẽ hiện ra trong log của bạn.

Cần vạch rõ một ranh giới: schema suy ngược mô tả điều bên kia đang trả về, không phải điều nó hứa trả về. Cái trước dùng để giám sát; chỉ cái sau mới thuộc về hợp đồng hay tài liệu API.

7. Sau khi hoàn thiện, hãy để lỗi xác thực chỉ đúng trường cụ thể

Khi schema đã dùng được, mối quan tâm tiếp theo là lỗi dễ đọc. Một object lỗi của ajv có năm trường, và bốn trường đầu là những trường hữu ích:

Trường Ý nghĩa Ví dụ (id: 0 vi phạm minimum: 1)
instancePath JSON Pointer tới giá trị vi phạm trong dữ liệu "/id"
schemaPath Vị trí của quy tắc bị kích hoạt trong schema "#/properties/id/minimum"
keyword Tên của ràng buộc bị fail "minimum"
params Chi tiết đọc được bằng máy của ràng buộc đó { "comparison": ">=", "limit": 1 }
message Câu văn tiếng Anh thay đổi giữa các phiên bản, đừng parse nó "must be >= 1"

Một điểm bất đối xứng dễ vấp nhất: khi required fail, instancePath chỉ vào object cha (thường là chuỗi rỗng, tức gốc), chứ không chỉ vào trường bị thiếu. Tên khóa thiếu nằm trong params.missingProperty. Tương tự, khi additionalProperties fail, tên khóa dư nằm trong params.additionalProperty.

Vì vậy khi ánh xạ lỗi thành "trường → thông báo", hãy xử lý hai trường hợp đặc biệt này trước:

import Ajv from "ajv";

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);

/** Convert the ajv error array into { fieldPath: [message] } for form highlighting */
function groupByField(errors) {
  const grouped = {};

  for (const error of errors) {
    // For required and additionalProperties, the offending field lives in params, not in instancePath
    const field =
      error.keyword === "required"
        ? error.params.missingProperty
        : error.keyword === "additionalProperties"
          ? error.params.additionalProperty
          : error.instancePath.replace(/^\//u, "").split("/").join(".") ||
            "(root)";

    (grouped[field] ??= []).push(error.message);
  }

  return grouped;
}

if (!validate(data)) console.log(groupByField(validate.errors));

allErrors: true không phải tùy chọn. Với cấu hình mặc định, ajv dừng ở lỗi đầu tiên, nên người dùng sửa một trường, gửi lại, nhận lỗi tiếp theo, và cần ba vòng chỉ để điền xong một form. Nếu bạn còn dùng ajv-errors để có thông báo tùy chỉnh, thiếu cờ này plugin sẽ âm thầm chỉ thay thế một phần các thông báo.

8. Quy trình triển khai tái sử dụng được

Hãy xâu các bước trên thành một quy trình cố định và làm theo mỗi lần tích hợp API mới:

  1. Lấy mẫu: lấy 5–20 phản hồi thật, cố ý chọn những cái có trường rỗng, null, mảng rỗng và trường dư;
  2. Suy luận: dán chúng cùng nhau thành một mảng vào bộ sinh, tắt "all required", và lấy items;
  3. Hoàn thiện ngữ nghĩa: thêm format, enum, minimum/maximum, minItems và additionalProperties từng mục một;
  4. Tự kiểm tra: các mẫu gốc phải đạt 100%, nếu không hãy quay lại bước 1 và thêm mẫu;
  5. Chứng minh phía âm: chuẩn bị một đầu vào "phải fail" cho mọi ràng buộc và xác nhận nó thật sự fail — chỉ kiểm thử đường thành công thì cũng như không kiểm thử;
  6. Commit: đưa schema vào quản lý phiên bản, giữ các mẫu fail làm ca kiểm thử, và chạy kiểm thử trước mỗi khi API đổi trường.

Bước 5 là bước kiểm thử phía âm dễ bị bỏ qua nhất. Thêm một khóa vào required mà không bao giờ xác minh rằng thiếu khóa sẽ báo lỗi nghĩa là đang đặt cược rằng trình xác thực đồng ý với mô hình tinh thần của bạn.

import { describe, expect, it } from "vitest";

describe("user schema", () => {
  it("accepts a complete record", () => {
    expect(validate({ id: 1, name: "Ada", tags: [], score: 10 })).toBe(true);
  });

  it("rejects a record without name", () => {
    expect(validate({ id: 2, tags: [], score: 7 })).toBe(false);
    // Also assert the reason, so the test doesn't pass because some other field failed
    expect(
      validate.errors?.some((e) => e.params?.missingProperty === "name"),
    ).toBe(true);
  });
});

Khẳng định cuối cùng đó quan trọng. Chỉ khẳng định false có thể vẫn xanh kể cả sau khi schema đã bị làm hỏng — lúc đó lỗi có thể đến từ một nguyên nhân hoàn toàn khác.

9. FAQ

Schema do bộ sinh tạo ra có dùng thẳng vào production được không

Không. Nó chỉ là một ảnh chụp của cấu trúc. Tối thiểu bạn phải thêm định dạng, miền giá trị, enum và chính sách trường lạ, rồi chạy cả mẫu gốc lẫn mẫu phía âm qua nó. Bộ sinh giải quyết "gõ tay properties thì phiền", không phải "nó quyết định quy tắc nghiệp vụ".

Về hệ thống từ khóa khi soạn schema, tách $ref và các từ khóa kết hợp, xem JSON Schema: Từ cơ bản đến triển khai thực tế.

Tại sao bản ghi có nhiều trường nhất lại từ chối nhiều dữ liệu hơn

Vì required được thu thập từ "những gì đã xuất hiện trong mẫu". Một mẫu đầy đủ mọi trường biến mọi khóa tùy chọn thành bắt buộc, trong khi phần lớn các khóa đó vắng mặt trong dữ liệu thật. Nạp nhiều bản ghi, hoặc một bản ghi cố ý bỏ trống trường, sẽ đưa bạn đến gần sự thật hơn mẫu đầy đủ nhất.

Số nguyên có được suy ra là integer không

Bộ sinh trên trang này không làm vậy: cả số nguyên lẫn số thực đều được xuất ra number, nên schema sinh từ {"price": 5} chấp nhận 5.99. Điều đó tránh được tai nạn kiểu "mẫu tình cờ là số nguyên, nên production từ chối giá có phần thập phân". Một số công cụ bên thứ ba có suy ra integer từ giá trị, nên hãy đặc biệt cẩn thận với trường tiền.

Chi tiết về độ chính xác của số và ranh giới kiểu, xem Sáu kiểu dữ liệu JSON và cạm bẫy độ chính xác của số.

Tôi đã thêm format nhưng nó không có tác dụng

Từ draft 2019-09, format mặc định chỉ là chú thích. Hoặc bật tường minh việc kiểm tra format trong trình xác thực (ajv cần ajv-formats), hoặc dùng pattern để ràng buộc cứng. Đừng giả định rằng thêm nó vào sẽ chặn được điều gì.

required báo lỗi, nhưng dữ liệu rõ ràng có trường đó

Hãy xem instancePath trước. Lỗi required chỉ vào object cha, nghĩa là khóa thiếu không nằm ở cấp đó — nguyên nhân phổ biến là sai cấp lồng nhau (/profile/name không giống name ở gốc), khác biệt hoa thường (userId so với userID), hoặc serialization phía trên đã bỏ undefined hoàn toàn.

Trước khi điều tra, hãy xác nhận chính tài liệu là JSON hợp lệ: comment, dấu phẩy thừa và những vấn đề tương tự fail ở giai đoạn parse, trước khi schema có cơ hội chạy. Phân tích & Định dạng JSON chỉ thẳng vào dòng vi phạm.

Nên lấy mẫu bao nhiêu bản ghi, và dùng draft nào?

Lấy mẫu 5–10 bản ghi hằng ngày cho hiệu quả tốt nhất; required hội tụ ở trung vị 3 bản ghi, và 12 bản ghi bao phủ mọi họ. Mỗi bản ghi phải đến từ một tình huống thật sự khác nhau, và mọi dạng của mọi trường phải xuất hiện ít nhất một lần. Ghim draft 2020-12, vì OpenAPI 3.1 chỉ chấp nhận phiên bản đó.

Tại sao mặc định dễ dãi của additionalProperties lại nguy hiểm hơn

Mặc định không sinh ra từ khóa nào, nên khóa gõ sai bị chấp nhận âm thầm, qua xác thực, rồi fail lúc chạy — khó chẩn đoán hơn một lỗi trực tiếp. Schema kiểu cấu hình phải đặt "additionalProperties": false tường minh, còn schema API nên cân nhắc chi phí tương thích trước.

Có nên dùng schema suy ngược làm cổng chặn cứng không?

Không. Schema suy ngược mô tả điều bên kia đang trả về, không phải điều nó hứa trả về, nên nó thuộc về giám sát và cảnh báo lệch. Bất cứ thứ gì đi vào hợp đồng hay tài liệu API đều phải là hợp đồng đã hứa.