عندما تصبح أخطاء JSON Schema بلا معنى: تحديد موقع أخطاء Ajv وحوكمة الرسائل
الخلاصة: يصدر المدقق أخطاءً آلية، بينما يريد المستخدم معرفة أي مربع إدخال هو الخطأ. يغطي المقال مصفوفة أخطاء Ajv، وinstancePath مقابل schemaPath، والتجميع وصياغة النص.

الخلاصة: اقرأ instancePath أولًا لتحديد البيانات الفاشلة، ثم schemaPath لتحديد القاعدة التي أُطلقت؛ وبالنسبة إلى required وadditionalProperties، استخرج اسم المفتاح الحقيقي من params قبل اختيار الرسالة.
عمل المدقق أن يقول أي قاعدة فشلت. أما ترجمة ذلك إلى "مربع الإدخال هذا خطأ" فهو عملك.
1. ما الذي تمنحك إياه مصفوفة errors فعلًا
لا يرمي Ajv استثناءً عند فشل التحقق؛ بل يترك النتيجة في validate.errors. وتحتفظ التهيئة الافتراضية بأول خطأ فقط، وallErrors هي ما ينتج القائمة الكاملة. ولكائن خطأ واحد خمسة حقول مهمة، وفهمها مقدمة لكل ما يلي:
| الحقل | المعنى | المثال |
|---|---|---|
instancePath |
JSON Pointer إلى القيمة الفاشلة | /email |
schemaPath |
موقع الكلمة المفتاحية الفاشلة في المخطط | #/properties/email/format |
keyword |
اسم القيد المنتهك | format |
params |
معاملات ذلك القيد القابلة للقراءة آليًا | { "format": "email" } |
message |
نص إنجليزي يتغير بين الإصدارات | must match format "email" |
ويبدو خطأ حقيقي هكذا:
[
{
"instancePath": "/email",
"schemaPath": "#/properties/email/format",
"keyword": "format",
"params": { "format": "email" },
"message": "must match format \"email\""
}
]
لاحظ أن message نص مكتوب للبشر، لا واجهة مستقرة. فقد يعيد Ajv صياغته بين الإصدارات، ولا يصلح لمنتج متعدد اللغات. لا تحلله أبدًا بتعبير نمطي، ولا تعده حرفيًا إلى العميل.
2. instancePath وschemaPath يجيبان عن سؤالين مختلفين
يقول instancePath أي شريحة من البيانات فشلت؛ ويقول schemaPath أي قاعدة أُطلقت. وهما نظاما إحداثيات، وليسا متماثلين:
instancePathهو JSON Pointer وفق RFC 6901. والسلسلة الفارغة تعني الجذر؛ و/items/0/priceتعني حقل السعر في أول عنصر من المصفوفة.- يبدأ
schemaPathبـ#ويشير إلى الكلمة المفتاحية داخل مستند المخطط. وعبر$refفإنه يشير داخل المخطط المُحال، لذا تحتاج إلى$idلمعرفة الملف الذي جاء منه. - يُهرَّب المفتاح الذي يحتوي
/أو~إلى~1و~0على الترتيب. أرجِع ذلك قبل العرض، وإلا فستعرض الواجهة رموزًا محيّرة.
ومثال ذلك: instancePath: "/email" مع schemaPath: "#/properties/email/format" يعني أن حقل البريد في الكائن الجذري لم يجتز فحص التنسيق.
وأكثر فخ شيوعًا هو required وadditionalProperties: فـinstancePath الخاص بهما يشير إلى الكائن الأب، لا إلى المفتاح المخالف.
| الكلمة المفتاحية | جهة instancePath |
مكان المفتاح الحقيقي |
|---|---|---|
required |
الكائن الأب (سلسلة فارغة عند الجذر) | params.missingProperty |
additionalProperties |
الكائن الأب | params.additionalProperty |
type |
القيمة الفاشلة نفسها | — |
enum |
القيمة الفاشلة نفسها | params.allowedValues |
وعند ربط الأخطاء بحقول النموذج، تعامل مع هاتين الحالتين أولًا. وإلا فسيتحول "البريد مفقود" إلى "خطأ في الكائن الجذري"، ولن يعرف المستخدم أين يكتب.
3. allErrors وverbose: الاكتمال مقابل التكلفة
يجعل allErrors: false الافتراضي Ajv يتوقف عند أول فشل. وهذا جيد لأداة سطر أوامر وكارثي لنموذج: يصلح المستخدم حقلًا، ويرسل، ويرى الخطأ التالي، ويكرر ثلاث مرات قبل أن ينجح النموذج.
| الإعداد | السلوك | الفائدة | التكلفة |
|---|---|---|---|
allErrors: false |
أول خطأ فقط | الأسرع، وأقل ذاكرة | إرسالات متكررة |
allErrors: true |
جمع كل خطأ | كل المشكلات في تمريرة واحدة | عدد الأخطاء يتضخم في المخططات المعقدة |
verbose: true |
إرفاق schema وdata بكل خطأ |
تشخيص أسهل على الخادم | يزيد الحجم كثيرًا؛ ولا تعرضه أبدًا |
messages: false |
إسقاط حقل message |
حمولات أصغر | تفقد الدليل الإنجليزي للتصحيح |
التقسيم الموصي به: فعّل allErrors: true للتحقق على الخادم واحفظ المصفوفة الكاملة في السجلات الداخلية، بينما تحمل الاستجابة العامة حقولًا مسموحًا بها فقط. ولا تفعّل verbose إلا أثناء التحقيق؛ فهو يضمّن مقتطف المخطط المطابق وبيانات المستخدم في كل خطأ، وهو ما يستهلك الذاكرة وقد يسرب قيمًا حساسة إلى السجلات.
وحجم الأخطاء مشكلة شائعة أخرى. ففرع anyOf الفاشل يُصدر عدة أخطاء فرعية مع خطأ ملخّص واحد، وoneOf مع فروع كثيرة أسوأ. اعرض المصفوفة كما هي فيرى المستخدمون خمس رسائل شبه مكررة لحقل واحد. ولا بد من التجميع قبل العرض.
4. جمّع الأخطاء حسب الحقل
يفعل التجميع ثلاثة أشياء: يحوّل المسار مرة أخرى إلى اسم حقل، ويصحح إزاحة required وadditionalProperties، ويدمج الأخطاء المتعددة على الحقل نفسه في الرسالة الأكثر قابلية للتنفيذ.
/** خطأ تحقق واحد جاهز للعرض */
interface FieldError {
field: string;
keyword: string;
params: Record<string, unknown>;
}
/**
* يجمع أخطاء Ajv حسب الحقل.
* @param errors مصفوفة الأخطاء الناتجة عن Ajv
* @returns خريطة مفتاحها مسار الحقل، وكل قيمة تسرد أخطاءه
*/
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، ثم الطول والنطاق. واعرض رسالة الأولوية الأعلى فقط لكل حقل واحفظ الباقي في السجلات. والسبب واضح: عندما يكون النوع خاطئًا، فرسالة الطول ضجيج، وإصلاح النوع هو الخطوة المعقولة التالية وحدها.
أما أخطاء الملخّص لـ oneOf وanyOf فلا تحمل عادة معلومة قابلة للتنفيذ. فتخطَّها أثناء العرض واحتفظ فقط بأخطاء الفروع التي تشير إلى حقل محدد.
5. الرسائل المخصصة: مسار الإضافة ومسار القالب
لا يشحن Ajv صياغة متعددة اللغات، فثمّة خياران عمليان.
الخيار الأول: إضافة ajv-errors. تضيف كلمة مفتاحية errorMessage لتوضع الرسائل بجوار القيود:
{
"properties": {
"email": {
"type": "string",
"format": "email",
"errorMessage": {
"format": "يرجى إدخال عنوان بريد إلكتروني صالح",
"type": "يجب أن يكون البريد سلسلة نصية"
}
}
}
}
الميزة أن الصياغة تسافر مع الحقل، ويعمل المخطط توثيقًا في الوقت نفسه. أما العيب فهو أن النص مجمّد في لغة واحدة، فالتعريب يعني صيانة عدة مخططات. كما أن الإضافة لا تستبدل كل الرسائل إلا عند تعيين allErrors: true؛ وإن نسيت ذلك العلم فستحصل على مزيج من النصوص المخصصة والافتراضية الإنجليزية.
الخيار الثاني: جدول كلمة مفتاحية إلى قالب. لا يوفر المدقق إلا keyword وparams؛ ويُنتج التطبيق الصياغة:
| الكلمة المفتاحية | القالب | المعاملات المتاحة |
|---|---|---|
required |
حقل مطلوب مفقود: {field} | missingProperty |
type |
النوع المتوقع: {type} | type |
minLength |
أدخل {limit} حرفًا على الأقل | limit |
maximum |
يجب ألا يتجاوز {limit} | limit |
format |
تنسيق غير صالح: {format} | format |
enum |
يجب أن تكون إحدى القيم المسموح بها | allowedValues |
pattern |
القيمة لا تطابق التنسيق المتوقع | pattern (لا تُعِد طبعه) |
الخيار الثاني يناسب المنتجات التي تحتاج إلى عدة لغات وإلى نبرة موحدة. فهو يفصل التحقق عن العرض، ويُبقي الترجمة خارج المخطط، ويتيح للواجهة إعادة استخدام القوالب نفسها.
6. التعريب: الرسائل لا تنتمي إلى المدقق
نقل الصياغة إلى حزم اللغات هو نقطة بداية حوكمة الرسائل. فحزمة اللغة تحفظ القوالب والمفاتيح، لا أسماء الحقول؛ وتأتي تسميات الحقول من تهيئة النموذج أو من عقد API، ويُدمج الاثنان عند العرض.
/** قوالب رسائل التحقق مفتاحها كلمة Ajv المفتاحية */
const VALIDATION_TEMPLATES: Record<string, (params: Record<string, any>) => string> = {
required: (params) => `حقل مطلوب مفقود: ${params.missingProperty}`,
type: (params) => `النوع المتوقع: ${params.type}`,
minLength: (params) => `أدخل ${params.limit} حرفًا على الأقل`,
maximum: (params) => `يجب ألا يتجاوز ${params.limit}`,
format: () => 'تنسيق غير صالح',
};
/**
* يبني رسالة تحقق واحدة موجهة للمستخدم.
* @param keyword اسم القيد الفاشل
* @param params معاملات القيد
* @returns نص الرسالة المعرّبة
*/
function buildMessage(keyword: string, params: Record<string, any>): string {
const template = VALIDATION_TEMPLATES[keyword];
return template ? template(params) : 'القيمة غير صالحة';
}
وهنا تهمّ ثلاث قواعد:
- لا تجمع الجمل بالتسلسل. فـ"تنسيق البريد غير صالح" وما يكافئه في لغة أخرى لا يتشاركان ترتيب الكلمات، وسينتج تجميع السلاسل هراءً في إحداهما في النهاية.
- فوّض الجموع وأسماء الكميات إلى مكتبة i18n. لا تُثبّت كلمة "حرف" في قالب أبدًا؛ فـ
1 characterو3 charactersمختلفان. - ترجم تسميات الحقول أيضًا. فـ
params.missingPropertyهوemail، بينما يجب أن تقول الواجهة "عنوان البريد الإلكتروني". واحفظ هذا الربط في تهيئة النموذج، لا في حزمة اللغة.
7. لا تُعِد البنية الداخلية إلى العملاء
إعادة schemaPath وparams وmessage معًا مريحة أثناء التطوير، وهي تسريب معلومات في الإنتاج. فـallowedValues تكشف تعدادات داخلية، وpattern يكشف تعبيرات التحقق النمطية، وschemaPath يكشف تخطيط الملفات وتسمية الحقول — وفي بعض الحالات يمنح المهاجمين خريطة لنقاط ضعفك.
تحتفظ الاستجابة العامة بثلاثة أشياء فقط: مسار الحقل (أو اسم حقل النموذج)، ورمز خطأ مستقر، ونص موجه للمستخدم. استخدم رموزًا قصيرة مستقرة بدلًا من keyword الخام حتى تحتفظ بمساحة لتغيير المدقق لاحقًا.
{
"type": "about:blank",
"title": "فشل التحقق من الطلب",
"status": 422,
"errors": [
{ "field": "email", "code": "invalid_format", "message": "يرجى إدخال عنوان بريد إلكتروني صالح" },
{ "field": "items[0].price", "code": "invalid_pattern", "message": "يجب أن يحتوي المبلغ على منزلتين عشريتين بالضبط" }
]
}
أما سجلات الخادم فيجب أن تحمل العكس: مصفوفة الأخطاء الكاملة، ومعرّف الطلب، وإصدار المخطط، بحيث يستطيع التحقيق الإجابة عن "أي قاعدة منعت هذا". وتجنب إفراغ أجسام الطلبات كاملة في السجلات؛ واحفظ المسارات والكلمات المفتاحية الفاشلة بدلًا من ذلك.
8. العرض في الواجهة: من إبراز الحقل إلى علامات المحرر
تصل الأخطاء في النهاية إلى واجهة، ويعتمد العرض على السياق:
| السياق | العرض | الإجراء الأساسي |
|---|---|---|
| النموذج | نص أحمر تحت الحقل، مع تركيز على أول خطأ | ربط المسارات بأسماء النماذج؛ والاحتفاظ بفهارس المصفوفات |
| استيراد جدول بالجملة | رقم الصف، واسم العمود، وملخص، وقابلية التصدير | تحويل /items/3/price إلى الصف 4 وعمود السعر |
| محرر JSON | علامات داخلية ولوحة مشكلات | استخدام محلل لربط JSON Pointer بالسطر والعمود |
| أداة تصحيح API | قائمة أخطاء منظمة تحتفظ بـ schemaPath |
موجهة للمطورين، فالتفصيل الأكبر مقبول |
ثلاث ملاحظات تنفيذية:
- اعرض أول خطأ فقط لكل حقل، واطوِ الباقي في "و2 مشكلات أخرى"، حتى لا يغرق النموذج في الأحمر.
- احتفظ بفهارس المصفوفات للاستهداف واحذفها في التسميات. فـ"الصف 4، السعر" يُقرأ أفضل من
/items/3/price. - سيناريوهات المحرر تحتاج إلى ربط المواضع. فالبحث النصي العادي غير موثوق عندما يتكرر اسم حقل؛ ويجب أن يوفر المحلل إزاحات لكل عقدة، ثم تحولها إلى سطر وعمود.
وإذا كان JSON ناتجًا عن كتابة مستخدم لا عن نموذج، فتحقق أولًا من صحة المستند نحويًا. فأخطاء الصياغة تحدث أثناء التحليل ولا تصل أبدًا إلى التحقق من المخطط. وتحليل JSON وتنسيقه يبلّغ عن السطر والعمود بدقة، وهو أسرع بكثير من مطاردة أخطاء المخطط. ولمعرفة كيف تنشأ المخططات المفرطة الصرامة عند استنتاجها من عينات، انظر لماذا يظل JSON Schema المولّد تلقائيًا يرفض البيانات الحقيقية؛ ولتهيئة المدقق ونظام الكلمات المفتاحية، انظر JSON Schema: من الأساسيات إلى الإنتاج.
9. الأسئلة المتكررة
لماذا أحصل على خطأ واحد فقط؟
allErrors قيمتها false افتراضيًا، فيعيد Ajv عند أول فشل. فعّلها صراحةً للتحقق في النماذج؛ وإلا فيجب على المستخدمين الإرسال مرارًا لاكتشاف كل مشكلة.
لماذا يكون المسار في خطأ required فارغًا؟
يشير instancePath الخاص بـ required إلى الكائن الأب، وهو سلسلة فارغة عند الجذر؛ ويقع اسم المفتاح المفقود في params.missingProperty. ادمج الاثنين للحصول على المسار الكامل. وعرض instancePath وحده ينتج "خطأ في الكائن الجذري" عديم الفائدة.
هل أستطيع عرض message للمستخدمين مباشرة؟
لا يُنصح بذلك. فهو نص إنجليزي تتغير صياغته بين الإصدارات، ولا يملك قدرة على التعريب. ولّد النص من keyword وparams بدلًا من ذلك، واحفظ message للسجلات.
لماذا تُتجاهل رسالة الخطأ المخصصة لدي؟
تحقّق أولًا من تفعيل allErrors: true؛ فـajv-errors لا تستبدل كل الرسائل إلا بذلك العلم. ثم تأكد من أن errorMessage في المستوى نفسه للكلمة المفتاحية الفاشلة — ومستوى واحد خطأً لن تُعرَف.
أي الحقول آمن لإعادته في الإنتاج؟
مسار الحقل، ورمز خطأ مستقر، ونص موجه للمستخدم. واحفظ schemaPath وparams وmessage في سجلات الخادم: فذلك يدعم التصحيح دون كشف التعدادات الداخلية أو التعبيرات النمطية أو تخطيط الملفات.