JSON Toolkit

Khi lỗi JSON Schema vô lý: định vị lỗi Ajv và quản trị thông báo

Kết luận: Trình xác thực phát ra lỗi cho máy đọc; người dùng lại muốn biết ô nhập nào sai. Bài viết bao gồm mảng errors của Ajv, phân biệt instancePath và schemaPath, nhóm lỗi và cách diễn đạt thông báo.

Nâng caoĐọc khoảng 10 phútXuất bản 2026-10-31Cập nhật 2026-10-31
Sơ đồ định vị lỗi xác thực Ajv và quản trị thông báo

Chốt lại: hãy đọc instancePath trước để định vị dữ liệu bị fail, rồi schemaPath để định vị quy tắc đã kích hoạt; với required và additionalProperties, phải lấy tên khóa thật từ params trước khi chọn thông báo.

Việc của trình xác thực là nói quy tắc nào đã fail. Việc chuyển nó thành "ô nhập này sai" là việc của bạn.

1. Mảng errors thật sự cung cấp cho bạn những gì

Ajv không ném exception khi xác thực fail; nó để kết quả trong validate.errors. Cấu hình mặc định chỉ giữ lỗi đầu tiên, và allErrors là thứ tạo ra danh sách đầy đủ. Một object lỗi có năm trường quan trọng, và hiểu chúng là tiền đề cho mọi bước phía sau:

Trường Ý nghĩa Ví dụ
instancePath JSON Pointer tới giá trị bị fail /email
schemaPath Vị trí của từ khóa bị fail trong schema #/properties/email/format
keyword Tên của ràng buộc bị vi phạm format
params Tham số đọc được bằng máy của ràng buộc đó { "format": "email" }
message Câu văn tiếng Anh thay đổi giữa các phiên bản must match format "email"

Một lỗi thật trông như thế này:

[
  {
    "instancePath": "/email",
    "schemaPath": "#/properties/email/format",
    "keyword": "format",
    "params": { "format": "email" },
    "message": "must match format \"email\""
  }
]

Lưu ý rằng message là câu văn viết cho người đọc, không phải một giao diện ổn định. Ajv có thể đổi cách diễn đạt giữa các phiên bản, và nó không thể phục vụ một sản phẩm đa ngôn ngữ. Đừng bao giờ parse nó bằng biểu thức chính quy, và cũng đừng bao giờ trả nguyên văn nó cho client.

2. instancePath và schemaPath trả lời những câu hỏi khác nhau

instancePath nói phần nào của dữ liệu bị fail; schemaPath nói quy tắc nào đã kích hoạt. Chúng là hai hệ tọa độ, và không đối xứng với nhau:

  • instancePath là JSON Pointer theo RFC 6901. Chuỗi rỗng nghĩa là gốc; /items/0/price nghĩa là trường price của phần tử mảng đầu tiên.
  • schemaPath bắt đầu bằng # và trỏ tới từ khóa bên trong tài liệu schema. Khi đi qua một $ref, nó trỏ bên trong schema được tham chiếu, nên bạn cần $id để biết nó đến từ tệp nào.
  • Một khóa chứa / hoặc ~ được escape thành ~1 và ~0 tương ứng. Hãy hoàn tác việc này trước khi hiển thị, nếu không giao diện sẽ hiện những ký hiệu khó hiểu.

Ví dụ, instancePath: "/email" cùng với schemaPath: "#/properties/email/format" nghĩa là trường email trên object gốc không qua được kiểm tra format.

Cái bẫy phổ biến nhất là required và additionalProperties: instancePath của chúng chỉ vào object cha, không chỉ vào khóa vi phạm.

Từ khóa instancePath chỉ tới Khóa thật nằm ở đâu
required Object cha (chuỗi rỗng ở gốc) params.missingProperty
additionalProperties Object cha params.additionalProperty
type Chính giá trị bị fail —
enum Chính giá trị bị fail params.allowedValues

Khi ánh xạ lỗi lên các trường form, hãy xử lý hai trường hợp này trước. Nếu không, "thiếu email" sẽ biến thành "lỗi object gốc", và người dùng không biết phải gõ vào đâu.

3. allErrors và verbose: đầy đủ so với chi phí

Mặc định allErrors: false khiến Ajv dừng ở lần fail đầu tiên. Điều đó ổn với một công cụ dòng lệnh và là thảm họa với một form: người dùng sửa một trường, gửi đi, thấy lỗi tiếp theo, và lặp ba lần trước khi form qua được.

Cấu hình Hành vi Lợi ích Chi phí
allErrors: false Chỉ lỗi đầu tiên Nhanh nhất, bộ nhớ nhỏ nhất Phải gửi lại nhiều lần
allErrors: true Thu thập mọi lỗi Thấy toàn bộ vấn đề trong một lần Số lỗi bùng nổ trên schema phức tạp
verbose: true Gắn schema và data vào mỗi lỗi Chẩn đoán phía máy chủ dễ hơn Kích thước tăng rất mạnh; đừng bao giờ lộ ra ngoài
messages: false Bỏ trường message Payload nhỏ hơn Mất manh mối debug bằng tiếng Anh

Cách chia đề xuất: bật allErrors: true cho xác thực phía máy chủ và giữ toàn bộ mảng trong log nội bộ, còn phản hồi công khai chỉ mang các trường được duyệt. Chỉ bật verbose khi đang điều tra; nó nhúng mảnh schema khớp và dữ liệu của người dùng vào mỗi lỗi, vốn tốn bộ nhớ và có thể làm rò rỉ giá trị nhạy cảm vào log.

Số lượng lỗi là vấn đề phổ biến còn lại. Một nhánh anyOf fail sinh ra vài lỗi con cộng một lỗi tổng hợp, và oneOf với nhiều nhánh còn tệ hơn. Hiển thị nguyên mảng thì người dùng thấy năm thông báo gần như trùng nhau cho một trường. Việc nhóm lỗi phải diễn ra trước khi render.

4. Nhóm lỗi theo trường

Việc nhóm làm ba điều: biến đường dẫn trở lại thành tên trường, sửa độ lệch của required và additionalProperties, và gộp nhiều lỗi trên cùng một trường thành thông báo duy nhất dễ hành động nhất.

/** One validation error ready for display */
interface FieldError {
  field: string;
  keyword: string;
  params: Record<string, unknown>;
}

/**
 * Group Ajv errors by field.
 * @param errors Error array produced by Ajv
 * @returns Map keyed by field path, each value listing its errors
 */
function groupByField(errors: Array<Record<string, any>>): Record<string, FieldError[]> {
  const grouped: Record<string, FieldError[]> = {};

  for (const errorItem of errors) {
    const keyword: string = errorItem.keyword;
    let field: string = errorItem.instancePath;

    if (keyword === 'required') {
      const parentPath: string = errorItem.instancePath;
      field = `${parentPath}/${errorItem.params.missingProperty}`;
    } else if (keyword === 'additionalProperties') {
      field = `${errorItem.instancePath}/${errorItem.params.additionalProperty}`;
    }

    const normalizedField = field === '' ? '(root)' : field;
    (grouped[normalizedField] ??= []).push({
      field: normalizedField,
      keyword,
      params: errorItem.params ?? {},
    });
  }

  return grouped;
}

Hãy gộp theo thứ tự ưu tiên: required (thiếu) trước, rồi type, rồi enum/const, rồi format/pattern, rồi độ dài và miền giá trị. Chỉ hiển thị thông báo ưu tiên cao nhất cho mỗi trường và giữ phần còn lại trong log. Lý do rất rõ ràng: khi kiểu sai, thông báo độ dài chỉ là nhiễu, và sửa kiểu là bước tiếp theo hợp lý duy nhất.

Các lỗi tổng hợp của oneOf và anyOf thường không mang thông tin hành động được. Hãy bỏ qua chúng khi render và chỉ giữ các lỗi nhánh trỏ tới một trường cụ thể.

5. Thông báo tùy chỉnh: con đường plugin và con đường template

Ajv không cung cấp câu chữ bản địa hóa, nên có hai lựa chọn thực tế.

Lựa chọn một: plugin ajv-errors. Nó thêm từ khóa errorMessage để thông báo nằm cạnh các ràng buộc:

{
  "properties": {
    "email": {
      "type": "string",
      "format": "email",
      "errorMessage": {
        "format": "Please enter a valid email address",
        "type": "Email must be a string"
      }
    }
  }
}

Ưu điểm là câu chữ đi cùng trường và schema kiêm luôn vai trò tài liệu. Nhược điểm là văn bản bị đóng băng ở một ngôn ngữ, nên bản địa hóa nghĩa là phải bảo trì nhiều schema. Plugin này cũng chỉ thay thế mọi thông báo khi đặt allErrors: true; quên cờ đó là bạn nhận được sự pha trộn giữa thông báo tùy chỉnh và tiếng Anh mặc định.

Lựa chọn hai: bảng ánh xạ từ khóa sang template. Trình xác thực chỉ cung cấp keyword và params; ứng dụng tạo ra câu chữ:

Từ khóa Template Tham số có sẵn
required Thiếu trường bắt buộc: {field} missingProperty
type Kiểu mong đợi: {type} type
minLength Nhập ít nhất {limit} ký tự limit
maximum Không được vượt quá {limit} limit
format Định dạng không hợp lệ: {format} format
enum Phải là một trong các lựa chọn được phép allowedValues
pattern Giá trị không khớp định dạng mong đợi pattern (đừng lặp lại nó)

Lựa chọn hai hợp với sản phẩm cần đa ngôn ngữ và một giọng điệu nhất quán. Nó tách xác thực khỏi hiển thị, giữ bản dịch ra ngoài schema, và cho phép frontend tái sử dụng cùng các template.

6. Bản địa hóa: thông báo không thuộc về trình xác thực

Chuyển câu chữ vào các gói ngôn ngữ là điểm khởi đầu của quản trị thông báo. Một gói ngôn ngữ lưu template và khóa, không bao giờ lưu tên trường; nhãn trường đến từ cấu hình form hoặc hợp đồng API, và hai thứ được ghép lúc render.

/** Validation message templates keyed by Ajv keyword */
const VALIDATION_TEMPLATES: Record<string, (params: Record<string, any>) => string> = {
  required: (params) => `Missing required field: ${params.missingProperty}`,
  type: (params) => `Expected type: ${params.type}`,
  minLength: (params) => `Enter at least ${params.limit} characters`,
  maximum: (params) => `Must not exceed ${params.limit}`,
  format: () => 'Invalid format',
};

/**
 * Build one user-facing validation message.
 * @param keyword Name of the failed constraint
 * @param params Constraint parameters
 * @returns Localized message text
 */
function buildMessage(keyword: string, params: Record<string, any>): string {
  const template = VALIDATION_TEMPLATES[keyword];
  return template ? template(params) : 'The value is invalid';
}

Có ba quy tắc quan trọng ở đây:

  1. Đừng nối chuỗi thành câu. "Email format is invalid" và tương đương tiếng Việt không cùng trật tự từ, và việc ghép chuỗi cuối cùng sẽ tạo ra câu vô nghĩa ở một trong hai ngôn ngữ.
  2. Giao số nhiều và lượng từ cho thư viện i18n. Đừng bao giờ ghi cứng "characters" trong template; 1 character và 3 characters khác nhau.
  3. Dịch cả nhãn trường. params.missingProperty là email, trong khi giao diện nên nói "Địa chỉ email". Hãy giữ ánh xạ đó trong cấu hình form, không phải trong gói ngôn ngữ.

7. Đừng trả cấu trúc nội bộ cho client

Trả schemaPath, params và message cùng nhau thì tiện khi phát triển và là một lỗ rò rỉ thông tin trong production. allowedValues để lộ enum nội bộ, pattern để lộ biểu thức chính quy xác thực, và schemaPath để lộ bố cục tệp cùng cách đặt tên trường — trong một số trường hợp là trao cho kẻ tấn công bản đồ các điểm yếu của bạn.

Phản hồi công khai giữ đúng ba thứ: đường dẫn trường (hoặc tên trường form), một mã lỗi ổn định và văn bản hướng tới người dùng. Hãy dùng mã ổn định ngắn thay cho keyword thô để còn chỗ đổi trình xác thực về sau.

{
  "type": "about:blank",
  "title": "Request validation failed",
  "status": 422,
  "errors": [
    { "field": "email", "code": "invalid_format", "message": "Please enter a valid email address" },
    { "field": "items[0].price", "code": "invalid_pattern", "message": "Amount must have exactly two decimals" }
  ]
}

Log máy chủ nên giữ điều ngược lại: toàn bộ mảng lỗi, request id và phiên bản schema, để một cuộc điều tra có thể trả lời "quy tắc nào đã chặn việc này". Tránh đổ toàn bộ body request vào log; hãy giữ đường dẫn và từ khóa bị fail thay vì thế.

8. Hiển thị phía frontend: từ tô sáng trường đến marker trong trình soạn thảo

Cuối cùng, lỗi sẽ được hiển thị trong một giao diện, và cách trình bày phụ thuộc ngữ cảnh:

Ngữ cảnh Cách hiển thị Hành động chính
Form Chữ đỏ dưới trường, focus vào lỗi đầu tiên Ánh xạ đường dẫn sang tên trường; giữ chỉ số mảng
Nhập bảng hàng loạt Số dòng, tên cột, tóm tắt, có thể xuất ra Biến /items/3/price thành dòng 4, cột price
Trình soạn thảo JSON Marker nội tuyến và bảng vấn đề Dùng parser để ánh xạ JSON Pointer sang dòng và cột
Công cụ debug API Danh sách lỗi có cấu trúc giữ schemaPath Dành cho nhà phát triển, nên chi tiết hơn là chấp nhận được

Ba lưu ý triển khai:

  • Chỉ hiển thị lỗi đầu tiên cho mỗi trường, gộp phần còn lại thành "2 vấn đề khác", để form không bị ngập trong màu đỏ.
  • Giữ chỉ số mảng để định vị nhưng bỏ chúng trong nhãn. "Dòng 4, price" dễ đọc hơn /items/3/price.
  • Tình huống trình soạn thảo cần ánh xạ vị trí. Tìm chuỗi đơn thuần không đáng tin khi tên trường lặp lại; parser nên cung cấp offset cho mỗi nút, rồi bạn chuyển thành dòng và cột.

Nếu JSON đến từ người dùng gõ tay chứ không phải từ form, hãy xác nhận tài liệu hợp lệ về cú pháp trước. Lỗi cú pháp xảy ra ở giai đoạn parse và không bao giờ chạm tới xác thực schema. Phân tích & Định dạng JSON báo đúng dòng và cột, nhanh hơn nhiều so với việc đuổi theo lỗi schema. Về việc schema quá nghiêm ngặt hình thành thế nào khi suy ra từ mẫu, xem Tại sao JSON Schema sinh tự động cứ từ chối dữ liệu thật; về cấu hình trình xác thực và hệ thống từ khóa, xem JSON Schema: Từ cơ bản đến triển khai thực tế.

9. Câu hỏi thường gặp

Tại sao tôi chỉ nhận được một lỗi?

allErrors mặc định là false, nên Ajv trả về ngay ở lần fail đầu tiên. Hãy bật nó tường minh cho xác thực form; nếu không, người dùng phải gửi lại nhiều lần mới phát hiện hết mọi vấn đề.

Tại sao đường dẫn trên lỗi required lại rỗng?

instancePath của required chỉ vào object cha, mà ở gốc thì đó là chuỗi rỗng; tên khóa thiếu nằm trong params.missingProperty. Hãy ghép hai thứ để có đường dẫn đầy đủ. Chỉ hiển thị instancePath sẽ cho ra "lỗi object gốc" vô dụng.

Có thể hiển thị message trực tiếp cho người dùng không?

Không nên. Đó là câu văn tiếng Anh với cách diễn đạt thay đổi giữa các phiên bản, và nó không có khả năng bản địa hóa. Hãy tạo văn bản từ keyword và params thay thế, và để message cho log.

Tại sao thông báo lỗi tùy chỉnh của tôi bị bỏ qua?

Trước hết kiểm tra xem allErrors: true đã được bật chưa; ajv-errors chỉ có thể thay thế mọi thông báo khi có cờ đó. Sau đó xác nhận errorMessage nằm cùng cấp với từ khóa bị fail — lệch một cấp là nó sẽ không được nhận ra.

Những trường nào an toàn để trả về trong production?

Đường dẫn trường, mã lỗi ổn định và văn bản hướng tới người dùng. Hãy giữ schemaPath, params và message trong log máy chủ: điều đó hỗ trợ debug mà không lộ enum nội bộ, biểu thức chính quy hay bố cục tệp.