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.

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:
instancePathlà JSON Pointer theo RFC 6901. Chuỗi rỗng nghĩa là gốc;/items/0/pricenghĩa là trường price của phần tử mảng đầu tiên.schemaPathbắ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~1và~0tươ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:
- Đừ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ữ.
- Giao số nhiều và lượng từ cho thư viện i18n. Đừng bao giờ ghi cứng "characters" trong template;
1 charactervà3 characterskhác nhau. - Dịch cả nhãn trường.
params.missingPropertylà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.