التحقّق ومعالجة الاستثناءات

معالجة أخطاء التحقق من صحة البيانات

18 دقيقة الدرس 5 من 13

معالجة أخطاء التحقق من صحة البيانات

في الدرس السابق تعلّمت كيف تُعلن القيود على كائنات جسم الطلب وتُفعّلها بـ @Valid. لكن ماذا يحدث فعليًا عند انتهاك قيد ما؟ يرمي Spring استثناء MethodArgumentNotValidException، وما لم تتعامل معه ستحصل على استجابة 400 خام تحتوي على بيانات مكدس الاستدعاءات تكشف تفاصيل التنفيذ وهي عديمة الفائدة لمستهلكي الواجهة البرمجية. يعلّمك هذا الدرس كيف تقرأ هذا الاستثناء وتستخرج رسائل الأخطاء لكل حقل وتُعيد استجابة خطأ منظّمة وواضحة يمكن لعملائك التصرف بناءً عليها.

ما الذي يرميه Spring — ولماذا

عندما يربط Spring MVC كائن @RequestBody ويُشغّل فحص @Valid، تملأ Bean Validation كائن BindingResult داخليًا. إذا فشلت أي قيد، يرمي Spring فورًا استثناء MethodArgumentNotValidException — وهو فئة فرعية من BindException — قبل أن ينفّذ جسم طريقة المتحكم أصلًا. يحمل هذا الاستثناء كائن BindingResult الكامل وهو حاوية لكائنات FieldError وObjectError.

FieldError مقابل ObjectError: يشير FieldError إلى خاصية واحدة (مثلًا حقل email فارغ). أما ObjectError (يُسمى أيضًا خطأ عامًا) فيشير إلى الكائن بأكمله — يُنتجه عادةً قيد على مستوى الفئة. كلاهما متاح عبر BindingResult.

الوصول إلى BindingResult مباشرةً

أبسط طريقة للتعامل مع الأخطاء دون معالج استثناء منفصل هي إضافة معامل BindingResult مباشرةً بعد معامل @Valid في توقيع طريقة المتحكم. يمتنع Spring عندئذٍ عن الرمي ويملأ النتيجة ويترك لطريقتك حرية القرار.

import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping("/users") public class UserController { @PostMapping public ResponseEntity<?> createUser( @Valid @RequestBody CreateUserRequest request, BindingResult bindingResult) { if (bindingResult.hasErrors()) { Map<String, String> errors = new HashMap<>(); for (FieldError error : bindingResult.getFieldErrors()) { errors.put(error.getField(), error.getDefaultMessage()); } return ResponseEntity.badRequest().body(errors); } // المسار السعيد return ResponseEntity.ok("User created"); } }

يعمل هذا الأسلوب لكنه يعاني من عيب جوهري: عليك تكرار منطق استخراج الأخطاء في كل طريقة متحكم تستقبل جسمًا خاضعًا للتحقق. في أي مشروع يتجاوز الحدّ البسيط، يكون مركزة هذا المنطق أفضل بكثير.

لا تجمع BindingResult مع @ExceptionHandler للاستثناء ذاته. إذا أعلنت معامل BindingResult فإن Spring يُخمد الاستثناء. ولن يُطلَق معالج @ExceptionHandler(MethodArgumentNotValidException.class) أبدًا لتلك النقطة النهائية. اختر استراتيجية واحدة لكل طريقة.

استخراج الأخطاء من MethodArgumentNotValidException

يستخدم الأسلوب المركزي — الذي تتناوله بعمق الدرسان 6 و7 — كائن @ExceptionHandler. في الوقت الحالي افهم الواجهة البرمجية التي ستستخدمها داخل هذا المعالج. يعرض MethodArgumentNotValidException كائن BindingResult ذاته:

import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import java.util.List; import java.util.Map; import java.util.stream.Collectors; // داخل طريقة @ExceptionHandler: public Map<String, String> extractFieldErrors(MethodArgumentNotValidException ex) { return ex.getBindingResult() .getFieldErrors() .stream() .collect(Collectors.toMap( FieldError::getField, FieldError::getDefaultMessage, (first, second) -> first + "; " + second // دمج أخطاء الحقل الواحد )); }

تتعامل دالة الدمج في Collectors.toMap مع حالة انتهاك حقل واحد لأكثر من قيد في آنٍ واحد — مثلًا حقل كلمة مرور تكون قصيرة جدًا وتفتقر إلى حرف كبير في الوقت نفسه.

تصميم رسالة خطأ مفيدة

تُجيب استجابة الخطأ المصمّمة جيدًا عن ثلاثة أسئلة لمستهلك الواجهة البرمجية: ما الذي حدث؟ أي حقل تسبّب في ذلك؟ وماذا يجب أن أُرسل بدلًا من ذلك؟ تُمثّل الخريطة البسيطة Map<String, String> من الحقل إلى الرسالة الحد الأدنى الصالح للاستخدام. يُضيف سجلّ أغنى رمز حالة HTTP وطابعًا زمنيًا ورمز خطأ قابلًا للقراءة آليًا:

public record ValidationErrorResponse( int status, String error, Map<String, String> fieldErrors, java.time.Instant timestamp ) { public static ValidationErrorResponse of(Map<String, String> fieldErrors) { return new ValidationErrorResponse( 400, "Validation Failed", fieldErrors, java.time.Instant.now() ); } }

تبدو الاستجابة الناتجة عن هذا السجلّ كالتالي:

{ "status": 400, "error": "Validation Failed", "fieldErrors": { "email": "must be a well-formed email address", "username": "size must be between 3 and 20" }, "timestamp": "2024-09-15T10:32:01.543Z" }
استخدم رسالة القيد كما هي حيثما أمكن. الرسائل الافتراضية من Jakarta Validation (مثل "must not be blank" و"must be a well-formed email address") واضحة بالفعل ومُحلَّلة محليًا إذا هيّأت MessageSource. تجاوزها فقط حين تكون الرسالة الافتراضية مُربِكة فعلًا في سياق نطاقك — فالتخصيص الزائد يُضاعف عبء الصيانة.

توطين رسائل الأخطاء

تبحث Bean Validation عن رسائل القيود عبر MessageSource. يهيّئ Spring Boot تلقائيًا أحد يقرأ من src/main/resources/ValidationMessages.properties (ومتغيّرات المناطق اللغوية مثل ValidationMessages_ar.properties). تجاوز أي رسالة افتراضية بإضافة مفتاحها:

# ValidationMessages.properties jakarta.validation.constraints.NotBlank.message=This field is required. jakarta.validation.constraints.Email.message=Please enter a valid email address. jakarta.validation.constraints.Size.message=Must be between {min} and {max} characters.

يمكنك أيضًا كتابة رسائل مُضمَّنة مباشرةً على التعليق التوضيحي وهي تأخذ الأولوية:

@NotBlank(message = "Username cannot be empty") @Size(min = 3, max = 20, message = "Username must be 3–20 characters") private String username;
عناصر نائبة للاستيفاء: داخل سلاسل message في التعليقات التوضيحية، تُستبدَل {min} و{max} و{value} بقيم سمات القيد في وقت التشغيل. هذا يُجنّبك تكرار الأرقام السحرية في نصوص الأخطاء.

معالجة انتهاكات القيود على متغيرات المسار ومعاملات الاستعلام

عندما تُعلّق معاملات الطريقة مباشرةً — لا كائن جسم الطلب — ترمي Bean Validation استثناء ConstraintViolationException لا MethodArgumentNotValidException. يجب عليك معالجة الاثنين:

import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolation; // داخل طريقة @ExceptionHandler: public Map<String, String> extractViolations(ConstraintViolationException ex) { return ex.getConstraintViolations() .stream() .collect(Collectors.toMap( cv -> extractFieldName(cv.getPropertyPath().toString()), cv -> cv.getMessage(), (first, second) -> first + "; " + second )); } private String extractFieldName(String path) { // المسار يبدو كـ "methodName.parameterName" — خذ الجزء الأخير int dot = path.lastIndexOf('.'); return dot >= 0 ? path.substring(dot + 1) : path; }

هذا أيضًا هو سبب ضرورة تعليق فئة المتحكم بـ @Validated (لا فقط @Valid على المعامل) عند التحقق من متغيرات المسار ومعاملات الطلب — فـ Spring يحتاج التعليق على مستوى الفئة لتطبيق وكيل AOP الذي يعترض استدعاءات الطريقة.

إعادة رمز حالة HTTP الصحيح

فشل التحقق من البيانات خطأ العميل دائمًا، لذا يكون رمز الحالة الصحيح دائمًا في نطاق 4xx:

  • 400 Bad Request — جسم الطلب أو معاملاته تنتهك قيودًا. هذا هو الاختيار المعياري لأخطاء التحقق.
  • 422 Unprocessable Entity — مُشكَّل بشكل صحيح من الناحية النحوية لكنه غير صالح منطقيًا (مثل تاريخ بدء بعد تاريخ الانتهاء). تُفضّل بعض الفرق 422 تحديدًا لانتهاكات قواعد الأعمال للتمييز بينها وبين أخطاء الصياغة.
  • 404 Not Found — لا تُعيده لحقل مطلوب مفقود؛ احتفظ به لـ "المورد الذي طلبته غير موجود".
كن متسقًا في واجهتك البرمجية بأكملها. خلط 400 و422 بشكل غير متوقع يُجبر العملاء على معالجة الاثنين لنفس فئة الخطأ. اختر اتفاقية واحدة ووثّقها. معظم الفرق تستخدم 400 لجميع فشل القيود وتحتفظ بـ 422 للفحوصات على مستوى النطاق.

الخلاصة

عند فشل Bean Validation ترمي Spring إما MethodArgumentNotValidException (جسم الطلب) أو ConstraintViolationException (معاملات الطريقة). كلاهما يحمل قائمة أخطاء يمكنك التكرار عليها وتحويلها إلى خرائط حقل-إلى-رسالة. أعد استجابة JSON منظّمة برمز حالة 400 وأسماء الحقول ورسائل القيود — إما مُضمَّنةً في كل طريقة باستخدام BindingResult أو (والأفضل) بشكل مركزي في معالج استثناء. يُريك الدرس التالي كيف تكتب هذا المعالج باستخدام @ExceptionHandler.