เมื่อ error ของ JSON Schema ไม่มีความหมาย: การระบุตำแหน่งและ governance ข้อความ error ของ Ajv
สรุปสำคัญ: validator ปล่อย error ที่เครื่องอ่านได้; ผู้ใช้ต้องการรู้ว่าช่องกรอกไหนผิด ครอบคลุมอาร์เรย์ errors ของ Ajv, instancePath เทียบกับ schemaPath, การจัดกลุ่มและถ้อยคำ

สรุปสั้น: อ่าน instancePath ก่อนเพื่อระบุข้อมูลที่ล้มเหลว จากนั้นอ่าน schemaPath เพื่อระบุกฎที่ถูกทริกเกอร์; สำหรับ required และ additionalProperties ให้ดึงชื่อคีย์จริงจาก params ก่อนเลือกข้อความ
หน้าที่ของ validator คือบอกว่ากฎใดล้มเหลว การแปลสิ่งนั้นเป็น "ช่องกรอกนี้ผิด" เป็นหน้าที่ของคุณ
1. อาร์เรย์ errors ให้อะไรคุณจริง ๆ
Ajv ไม่ throw เมื่อการตรวจสอบล้มเหลว; มันเก็บผลลัพธ์ไว้ใน validate.errors การตั้งค่าเริ่มต้นเก็บเพียง error แรก และ allErrors คือสิ่งที่สร้างรายการเต็ม ออบเจกต์ error หนึ่งตัวมีห้าฟิลด์ที่สำคัญ และการเข้าใจมันเป็นเงื่อนไขเบื้องต้นของทุกอย่างที่ตามมา:
| ฟิลด์ | ความหมาย | ตัวอย่าง |
|---|---|---|
instancePath |
JSON Pointer ไปยังค่าที่ล้มเหลว | /email |
schemaPath |
ตำแหน่งของคีย์เวิร์ดที่ล้มเหลวใน schema | #/properties/email/format |
keyword |
ชื่อของข้อจำกัดที่ถูกละเมิด | format |
params |
พารามิเตอร์ที่เครื่องอ่านได้ของข้อจำกัดนั้น | { "format": "email" } |
message |
ข้อความภาษาอังกฤษที่เปลี่ยนไปตามเวอร์ชัน | must match format "email" |
error จริงมีหน้าตาดังนี้:
[
{
"instancePath": "/email",
"schemaPath": "#/properties/email/format",
"keyword": "format",
"params": { "format": "email" },
"message": "must match format \"email\""
}
]
โปรดสังเกตว่า message เป็นถ้อยคำที่เขียนเพื่อมนุษย์ ไม่ใช่อินเทอร์เฟซที่เสถียร Ajv อาจเปลี่ยนถ้อยคำระหว่างเวอร์ชัน และมันไม่สามารถรองรับผลิตภัณฑ์ที่แปลหลายภาษาได้ อย่า parse มันด้วย regular expression และอย่าคืนมันตามต้นฉบับให้ไคลเอนต์
2. instancePath กับ schemaPath ตอบคำถามต่างกัน
instancePath บอกว่าข้อมูลส่วนใดล้มเหลว; schemaPath บอกว่ากฎใดถูกทริกเกอร์ ทั้งสองเป็นระบบพิกัดคนละชุด และไม่สมมาตรกัน:
instancePathเป็น JSON Pointer ตาม RFC 6901 สตริงว่างหมายถึงราก;/items/0/priceหมายถึงฟิลด์ price ของสมาชิก array ตัวแรกschemaPathเริ่มต้นด้วย#และชี้ไปยังคีย์เวิร์ดภายในเอกสาร schema เมื่อข้าม$refมันจะชี้ภายใน schema ที่ถูกอ้างอิง คุณจึงต้องมี$idเพื่อรู้ว่ามันมาจากไฟล์ใด- คีย์ที่มี
/หรือ~จะถูก escape เป็น~1และ~0ตามลำดับ ให้ย้อนกลับก่อนแสดงผล มิฉะนั้น UI จะแสดงสัญลักษณ์ที่ชวนงง
ตัวอย่างเช่น instancePath: "/email" พร้อม schemaPath: "#/properties/email/format" หมายถึงฟิลด์ email บนออบเจกต์รากไม่ผ่านการตรวจรูปแบบ
กับดักที่พบบ่อยที่สุดคือ required และ additionalProperties: instancePath ของมันชี้ไปที่ออบเจกต์แม่ ไม่ใช่คีย์ที่ผิด
| คีย์เวิร์ด | instancePath ชี้ไปที่ |
คีย์จริงอยู่ที่ไหน |
|---|---|---|
required |
ออบเจกต์แม่ (สตริงว่างที่ราก) | params.missingProperty |
additionalProperties |
ออบเจกต์แม่ | params.additionalProperty |
type |
ค่าที่ล้มเหลวเอง | — |
enum |
ค่าที่ล้มเหลวเอง | params.allowedValues |
เมื่อแมป error ลงบนฟิลด์ฟอร์ม ให้จัดการสองกรณีนี้ก่อน มิฉะนั้น "email หายไป" จะกลายเป็น "error ที่ออบเจกต์ราก" และผู้ใช้ไม่รู้ว่าจะพิมพ์ที่ไหน
3. allErrors กับ verbose: ความครบถ้วนเทียบกับต้นทุน
ค่าเริ่มต้น allErrors: false ทำให้ Ajv หยุดที่ความล้มเหลวแรก ซึ่งดีสำหรับเครื่องมือบรรทัดคำสั่งแต่หายนะสำหรับฟอร์ม: ผู้ใช้แก้ฟิลด์หนึ่ง ส่งฟอร์ม เห็น error ถัดไป และทำซ้ำสามรอบกว่าจะผ่าน
| การตั้งค่า | พฤติกรรม | ประโยชน์ | ต้นทุน |
|---|---|---|---|
allErrors: false |
เฉพาะ error แรก | เร็วที่สุด หน่วยความจำน้อยที่สุด | ต้องส่งซ้ำหลายรอบ |
allErrors: true |
เก็บทุก error | เห็นทุกปัญหาในครั้งเดียว | จำนวน error พองเมื่อ schema ซับซ้อน |
verbose: true |
แนบ schema และ data กับทุก error |
วินิจฉัยฝั่งเซิร์ฟเวอร์ง่ายขึ้น | ขนาดเพิ่มขึ้นมาก; ห้ามเปิดเผย |
messages: false |
ตัดฟิลด์ message ออก |
payload เล็กลง | เสียเบาะแสการดีบักภาษาอังกฤษ |
คำแนะนำในการแยกใช้: เปิด allErrors: true สำหรับการตรวจสอบฝั่งเซิร์ฟเวอร์และเก็บอาร์เรย์เต็มใน log ภายใน ขณะที่การตอบกลับสาธารณะบรรจุเฉพาะฟิลด์ที่อนุญาต เปิด verbose เฉพาะเมื่อสอบสวน เพราะมันฝัง fragment ของ schema ที่ตรงและข้อมูลของผู้ใช้ลงในทุก error ซึ่งกินหน่วยความจำและอาจรั่วค่าที่ละเอียดอ่อนลง log
ปริมาณ error เป็นอีกปัญหาที่พบบ่อย สาขา anyOf ที่ล้มเหลวปล่อย error ลูกรวมกับ error สรุปอีกหนึ่งตัว และ oneOf ที่มีหลายสาขาแย่กว่า แสดงอาร์เรย์ตามจริงแล้วผู้ใช้เห็นข้อความใกล้เคียงกันห้าข้อสำหรับฟิลด์เดียว ต้องจัดกลุ่มก่อนเรนเดอร์
4. จัดกลุ่ม error ตามฟิลด์
การจัดกลุ่มทำสามสิ่ง: เปลี่ยนพาธกลับเป็นชื่อฟิลด์ แก้จุดอ้างอิงของ required และ additionalProperties และรวม error หลายตัวบนฟิลด์เดียวกันเป็นข้อความที่ลงมือได้ที่สุด
/** error ของการตรวจสอบหนึ่งรายการที่พร้อมแสดง */
interface FieldError {
field: string;
keyword: string;
params: Record<string, unknown>;
}
/**
* จัดกลุ่ม error ของ Ajv ตามฟิลด์
* @param errors อาร์เรย์ error ที่ Ajv สร้าง
* @returns Map ที่คีย์เป็นพาธฟิลด์ แต่ละค่าเป็นรายการ error ของฟิลด์นั้น
*/
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;
}
รวมด้วยลำดับความสำคัญ: required (หายไป) ก่อน จากนั้น type จากนั้น enum/const จากนั้น format/pattern จากนั้นความยาวและช่วง แสดงเพียงข้อความที่สำคัญที่สุดต่อฟิลด์และเก็บที่เหลือไว้ใน log เหตุผลตรงไปตรงมา: เมื่อชนิดผิด ข้อความเรื่องความยาวคือเสียงรบกวน และการแก้ชนิดคือก้าวถัดไปที่สมเหตุสมผลเพียงอย่างเดียว
error สรุปของ oneOf และ anyOf มักไม่มีข้อมูลที่ลงมือได้ ให้ข้ามไปตอนเรนเดอร์และเก็บเฉพาะ error ของสาขาที่ชี้ไปยังฟิลด์ที่เป็นรูปธรรม
5. ข้อความกำหนดเอง: เส้นทางปลั๊กอินและเส้นทางเทมเพลต
Ajv ไม่มีถ้อยคำที่แปลไว้ จึงมีสองทางเลือกในทางปฏิบัติ
ทางเลือกแรก: ปลั๊กอิน ajv-errors มันเพิ่มคีย์เวิร์ด errorMessage เพื่อให้ข้อความอยู่ข้างข้อจำกัด:
{
"properties": {
"email": {
"type": "string",
"format": "email",
"errorMessage": {
"format": "Please enter a valid email address",
"type": "Email must be a string"
}
}
}
}
ข้อดีคือถ้อยคำไปกับฟิลด์และ schema ทำหน้าที่เป็นเอกสารไปในตัว ข้อเสียคือข้อความถูกตรึงไว้ในภาษาเดียว การแปลจึงหมายถึงต้องดูแลหลาย schema ปลั๊กอินยังแทนที่ทุกข้อความได้เฉพาะเมื่อตั้ง allErrors: true; ลืมธงนี้แล้วคุณจะได้ข้อความกำหนดเองปนกับภาษาอังกฤษเริ่มต้น
ทางเลือกที่สอง: ตาราง keyword-to-template validator ให้เพียง keyword และ params; แอปพลิเคชันเป็นผู้สร้างถ้อยคำ:
| คีย์เวิร์ด | เทมเพลต | พารามิเตอร์ที่ใช้ได้ |
|---|---|---|
required |
ฟิลด์ที่จำเป็นหายไป: {field} | missingProperty |
type |
ชนิดที่คาดหวัง: {type} | type |
minLength |
กรอกอย่างน้อย {limit} อักขระ | limit |
maximum |
ต้องไม่เกิน {limit} | limit |
format |
รูปแบบไม่ถูกต้อง: {format} | format |
enum |
ต้องเป็นหนึ่งในตัวเลือกที่อนุญาต | allowedValues |
pattern |
ค่าไม่ตรงกับรูปแบบที่คาดหวัง | pattern (อย่าสะท้อนกลับ) |
ทางเลือกที่สองเหมาะกับผลิตภัณฑ์ที่ต้องใช้หลายภาษาและน้ำเสียงที่สม่ำเสมอ มันแยกการตรวจสอบออกจากการนำเสนอ เก็บการแปลไว้นอก schema และให้ frontend ใช้เทมเพลตเดียวกันซ้ำได้
6. การแปลภาษา: ข้อความไม่ใช่ของ validator
การย้ายถ้อยคำไปยัง language pack คือจุดเริ่มต้นของ governance ข้อความ language pack เก็บเทมเพลตและคีย์ ไม่เก็บชื่อฟิลด์; ป้ายชื่อฟิลด์มาจากการตั้งค่าฟอร์มหรือสัญญา API และทั้งสองถูกรวมกันตอนเรนเดอร์
/** เทมเพลตข้อความตรวจสอบที่คีย์ตามคีย์เวิร์ดของ Ajv */
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',
};
/**
* สร้างข้อความตรวจสอบหนึ่งข้อความสำหรับผู้ใช้
* @param keyword ชื่อของข้อจำกัดที่ล้มเหลว
* @param params พารามิเตอร์ของข้อจำกัด
* @returns ข้อความที่แปลแล้ว
*/
function buildMessage(keyword: string, params: Record<string, any>): string {
const template = VALIDATION_TEMPLATES[keyword];
return template ? template(params) : 'The value is invalid';
}
กฎสามข้อสำคัญ ณ จุดนี้:
- อย่าต่อประโยคด้วยสตริง "Email format is invalid" กับคู่แปลภาษาไทยไม่ได้มีลำดับคำเดียวกัน การประกอบสตริงสุดท้ายจะสร้างประโยคไร้ความหมายในภาษาใดภาษาหนึ่ง
- มอบพหูพจน์และลักษณนามให้ไลบรารี i18n อย่า hardcode "characters" ในเทมเพลต;
1 characterกับ3 charactersต่างกัน - แปลป้ายชื่อฟิลด์ด้วย
params.missingPropertyคือemailขณะที่ UI ควรเขียน "อีเมล" ให้เก็บการแมปนั้นในการตั้งค่าฟอร์ม ไม่ใช่ใน language pack
7. อย่าคืนโครงสร้างภายในให้ไคลเอนต์
การคืน schemaPath, params และ message พร้อมกันสะดวกตอนพัฒนาแต่เป็นการรั่วข้อมูลในโปรดักชัน allowedValues เผย enum ภายใน pattern เผย regex สำหรับตรวจสอบ และ schemaPath เผยโครงสร้างไฟล์และรูปแบบการตั้งชื่อฟิลด์ — ในบางกรณีเท่ากับมอบแผนที่จุดอ่อนให้ผู้โจมตี
การตอบกลับสาธารณะเก็บเพียงสามอย่าง: พาธฟิลด์ (หรือชื่อฟิลด์ฟอร์ม) รหัส error ที่เสถียร และข้อความสำหรับผู้ใช้ ใช้รหัสสั้นที่เสถียรแทน keyword ดิบ เพื่อให้ยังมีที่ว่างสำหรับเปลี่ยน validator ในภายหลัง
{
"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 ฝั่งเซิร์ฟเวอร์ควรเก็บสิ่งที่ตรงกันข้าม: อาร์เรย์ error เต็ม, request id และเวอร์ชัน schema เพื่อให้การสอบสวนตอบได้ว่า "กฎใดบล็อกสิ่งนี้" หลีกเลี่ยงการทิ้ง body ของคำขอทั้งก้อนลง log; เก็บเฉพาะพาธและ keyword ที่ล้มเหลวแทน
8. การนำเสนอฝั่ง frontend: จากไฮไลต์ฟิลด์ถึง marker ในเอดิเตอร์
error สุดท้ายไปลงที่ UI และการนำเสนอขึ้นกับบริบท:
| บริบท | การนำเสนอ | การกระทำสำคัญ |
|---|---|---|
| ฟอร์ม | ข้อความสีแดงใต้ฟิลด์ โฟกัส error แรก | แมปพาธเป็นชื่อฟิลด์ฟอร์ม; เก็บดัชนี array ไว้ |
| นำเข้าตารางจำนวนมาก | หมายเลขแถว ชื่อคอลัมน์ สรุป ส่งออกได้ | แปลง /items/3/price เป็นแถวที่ 4 คอลัมน์ price |
| เอดิเตอร์ JSON | marker แบบอินไลน์และแผงปัญหา | ใช้ parser แมป JSON Pointer เป็นบรรทัดและคอลัมน์ |
| เครื่องมือดีบัก API | รายการ error แบบมีโครงสร้างที่เก็บ schemaPath |
หันหน้าให้นักพัฒนา จึงรับรายละเอียดได้มากกว่า |
หมายเหตุการ implement สามข้อ:
- แสดงเพียง error แรกต่อฟิลด์ และยุบที่เหลือเป็น "อีก 2 ปัญหา" เพื่อให้ฟอร์มไม่จมอยู่ในสีแดง
- เก็บดัชนี array ไว้สำหรับชี้เป้า แต่ตัดออกจากป้ายชื่อ "แถวที่ 4, price" อ่านง่ายกว่า
/items/3/price - สถานการณ์ในเอดิเตอร์ต้องมีการแมปตำแหน่ง การค้นหาสตริงธรรมดาไม่น่าเชื่อถือเมื่อชื่อฟิลด์ซ้ำ; parser ควรให้ offset ของแต่ละโหนด ซึ่งคุณแปลงเป็นบรรทัดและคอลัมน์ต่อ
ถ้า JSON มาจากผู้ใช้พิมพ์แทนฟอร์ม ให้ยืนยันว่าเอกสารถูกต้องตามไวยากรณ์ก่อน error ทางไวยากรณ์เกิดขึ้นตอน parse และไม่เคยไปถึงการตรวจสอบ schema JSON Parse & Format รายงานบรรทัดและคอลัมน์ที่แน่นอน ซึ่งเร็วกว่าการตามหา error ของ schema มาก สำหรับสาเหตุที่ schema เข้มงวดเกินไปเมื่ออนุมานจากตัวอย่าง ดู ทำไม JSON Schema ที่สร้างอัตโนมัติจึงปฏิเสธข้อมูลจริงซ้ำ ๆ; สำหรับการตั้งค่า validator และระบบคีย์เวิร์ด ดู JSON Schema: จากพื้นฐานสู่การใช้งานจริง
9. คำถามที่พบบ่อย
ทำไมได้ error เพียงตัวเดียว
allErrors มีค่าเป็น false โดยค่าเริ่มต้น Ajv จึงคืนค่าที่ความล้มเหลวแรก ให้เปิดมันอย่างชัดเจนสำหรับการตรวจสอบฟอร์ม; มิฉะนั้นผู้ใช้ต้องส่งซ้ำหลายรอบเพื่อค้นหาทุกปัญหา
ทำไมพาธบน error ของ required ว่างเปล่า
instancePath ของ required ชี้ไปที่ออบเจกต์แม่ ซึ่งเป็นสตริงว่างที่ราก; ชื่อคีย์ที่หายไปอยู่ใน params.missingProperty ต่อทั้งสองเข้าด้วยกันเพื่อให้ได้พาธเต็ม การแสดง instancePath เพียงอย่างเดียวให้ "error ที่ออบเจกต์ราก" ที่ไม่มีประโยชน์
แสดง message ให้ผู้ใช้โดยตรงได้ไหม
ไม่แนะนำ มันเป็นถ้อยคำภาษาอังกฤษที่เปลี่ยนไปตามเวอร์ชัน และไม่มีความสามารถแปลภาษา ให้สร้างข้อความจาก keyword และ params แทน และเก็บ message ไว้สำหรับ log
ทำไมข้อความ error กำหนดเองของฉันถูกละเลย
ตรวจก่อนว่าเปิด allErrors: true แล้วหรือไม่; ajv-errors แทนที่ทุกข้อความได้เฉพาะเมื่อมีธงนี้ จากนั้นยืนยันว่า errorMessage อยู่ระดับเดียวกับคีย์เวิร์ดที่ล้มเหลว — เยื้องผิดไปหนึ่งระดับก็จะไม่ถูกจดจำ
ฟิลด์ใดปลอดภัยที่จะคืนในโปรดักชัน
พาธฟิลด์ รหัส error ที่เสถียร และข้อความสำหรับผู้ใช้ เก็บ schemaPath, params และ message ไว้ใน log ฝั่งเซิร์ฟเวอร์: สิ่งนั้นรองรับการดีบักโดยไม่เผย enum, regex หรือโครงสร้างไฟล์ภายใน