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

التحقق من صحة أجسام الطلبات باستخدام @Valid

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

التحقق من صحة أجسام الطلبات باستخدام @Valid

تعلّمت في الدروس السابقة كيفية تزيين فئة نموذجية بقيود Bean Validation. الخطوة الحرجة التالية هي تشغيل هذا التحقق داخل متحكم Spring MVC. بدون مشغّل صريح، تبقى القيود خاملة — يُفكك Spring حمولة JSON ويُسلّمها إليك بصرف النظر عن صحة حقولها. يتناول هذا الدرس كيف يأمر @Valid و@Validated Spring بتشغيل المدقق قبل تنفيذ جسم الدالة، وما يحدث عند فشل التحقق، والأنماط التي ينبغي اتباعها للحفاظ على نظافة المتحكمات.

التأكد من وجود المكتبة المطلوبة

قبل أي شيء: لا يتضمّن Spring Boot 3 دعم التحقق من الصحة في spring-boot-starter-web. يجب إضافة المكتبة المخصصة صراحةً، وإلا يُتجاهل @Valid صمتًا ولا تُشغَّل القيود أبدًا.

<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>

يجلب هذا التبعية Hibernate Validator، وهو التطبيق المرجعي لـ Jakarta Validation 3.0. بمجرد وجوده في مسار الفئات، يُهيئ Spring تلقائيًا LocalValidatorFactoryBean ويربطه بطبقة MVC.

يُعدّ إغفال هذه المكتبة أكثر أسباب "لماذا لا يعمل التحقق؟" شيوعًا. لا يوجد خطأ في التصريف ولا تحذير عند التشغيل — يتجاهل Spring ببساطة معالجة القيود. تحقق دائمًا من وجود المكتبة عند تتبع سلوك التحقق الغائب.

تشريح دالة المتحكم التي تُجري التحقق

لنأخذ نقطة نهاية تسجيل المستخدم مثالًا. جسم الطلب هو كائن نقل بيانات RegisterRequest مزيَّن بقيود Bean Validation. وضع @Valid مباشرةً قبل معامل @RequestBody يأمر محلّل الوسيطات في Spring بتمرير الكائن المُفكَّك عبر المدقق قبل أن تستقبله الدالة.

package com.example.demo.web; import com.example.demo.dto.RegisterRequest; import com.example.demo.service.UserService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @PostMapping @ResponseStatus(HttpStatus.CREATED) public UserResponse register(@Valid @RequestBody RegisterRequest request) { // يُنفَّذ هذا السطر فقط عندما تجتاز RegisterRequest جميع القيود return userService.register(request); } }

يحمل كائن نقل البيانات نفسه تعليقات القيود:

package com.example.demo.dto; import jakarta.validation.constraints.*; public class RegisterRequest { @NotBlank(message = "Username is required") @Size(min = 3, max = 30, message = "Username must be between 3 and 30 characters") private String username; @NotBlank(message = "Email is required") @Email(message = "Must be a valid email address") private String email; @NotNull @Size(min = 8, message = "Password must be at least 8 characters") private String password; // getters و setters (أو استخدم Java record أو Lombok) }
@Valid مقابل @Validated: يأتي @Valid من مواصفة Jakarta Validation (jakarta.validation.Valid) ويُشغّل التحقق على الكائن المُزيَّن بما في ذلك التتالي إلى الكائنات المتداخلة المُعلَّمة بـ @Valid أيضًا. أما @Validated فهو تعليق خاص بـ Spring يُضيف دعم مجموعات التحقق (تُغطّى في الدرس التاسع). للتحقق من جسم الطلب البسيط، يُعدّ @Valid الخيار المعياري المتوافق مع المواصفة.

ما يحدث عند فشل التحقق

عند انتهاك قيد واحد على الأقل، يرمي Spring استثناء MethodArgumentNotValidException. يُنتج هذا الاستثناء افتراضيًا استجابة 400 Bad Request. يحمل الاستثناء كائن BindingResult الذي يضم كل خطأ حقل فردي. لا يُستدعى جسم الدالة أبدًا — يُرمى الاستثناء من قِبل الإطار قبل وصول التحكم إلى كودك.

التقاط BindingResult يدويًا

ثمة نمط ثانٍ أقل شيوعًا: تصريح معامل BindingResult مباشرةً بعد معامل @RequestBody. عند فعل ذلك، يُثبّط Spring الاستثناء التلقائي ويتيح لك فحص الأخطاء بنفسك داخل جسم الدالة.

@PostMapping public ResponseEntity<?> register( @Valid @RequestBody RegisterRequest request, BindingResult bindingResult) { if (bindingResult.hasErrors()) { List<String> errors = bindingResult.getFieldErrors() .stream() .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) .toList(); return ResponseEntity.badRequest().body(Map.of("errors", errors)); } return ResponseEntity .status(HttpStatus.CREATED) .body(userService.register(request)); }
تجنّب نمط BindingResult في معظم واجهات REST البرمجية. فهو يبعثر منطق معالجة الأخطاء في كل دالة متحكم، مما يجعل قاعدة الكود متكررة وأصعب في التعديل. النهج المفضّل هو @ControllerAdvice واحد (الدرس السابع) يلتقط MethodArgumentNotValidException عالميًا. استخدم BindingResult فقط عندما تحتاج فعلًا إلى منطق خاص بنقطة النهاية لا يستطيع معالج عالمي التعبير عنه.

التحقق من الكائنات المتداخلة

إذا احتوى كائن نقل البيانات على كائن متداخل، طبّق @Valid على الحقل المتداخل. بدونه، تُتجاهل القيود الموجودة على الفئة المتداخلة صمتًا.

public class OrderRequest { @NotNull @Valid // يُتالى التحقق إلى AddressRequest private AddressRequest shippingAddress; @NotEmpty private List<@Valid LineItem> items; // يتحقق من كل عنصر في القائمة }

استخدام @Valid على معامل نوع المجموعة (List<@Valid LineItem>) يستفيد من التحقق من عناصر الحاوية المُقدَّم في Bean Validation 2.0 والمدعوم بالكامل في Spring Boot 3. يتحقق Hibernate Validator من كل عنصر في القائمة ويجمع جميع الانتهاكات قبل الرمي.

التحقق من متغيرات المسار ومعاملات الاستعلام

لا يقتصر Bean Validation على أجسام الطلبات. يمكن تطبيق تعليقات القيود مباشرةً على معاملات الدوال لمتغيرات المسار ومعاملات الطلب. لكي يعمل هذا، أضف @Validated على مستوى الفئة — يأمر هذا Spring بإنشاء وكيل حول المتحكم يعترض استدعاءات الدوال ويفحص القيود على مستوى المعامل.

import org.springframework.validation.annotation.Validated; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @RestController @RequestMapping("/api/products") @Validated // مطلوب على مستوى الفئة هنا public class ProductController { @GetMapping("/{id}") public ProductResponse getById( @PathVariable @Min(1) long id) { return productService.findById(id); } @GetMapping public Page<ProductResponse> search( @RequestParam @NotBlank String query, @RequestParam(defaultValue = "0") @Min(0) int page) { return productService.search(query, page); } }

عند انتهاك قيد متغير مسار أو معامل استعلام، يرمي Spring استثناء ConstraintViolationException بدلًا من MethodArgumentNotValidException. هذا الفارق مهم عند كتابة معالج الاستثناءات العالمي: تحتاج إلى معالجة النوعين لمنح العملاء استجابات أخطاء متسقة عبر جميع وجهات الإدخال.

احتفظ بكائنات نقل البيانات منفصلة عن فئات الكيانات. خطأ شائع هو تزيين كيان JPA بقيود تحقق واستخدامه مباشرةً كنوع @RequestBody. هذا يربط عقد واجهتك البرمجية بمخطط قاعدة البيانات. عرِّف كائنات نقل بيانات طلب مخصصة — تعمل سجلات Java بشكل نظيف في Java 16 وما بعده — وصلها بالكيانات في طبقة الخدمة. تحتفظ بالتحكم الكامل في الحقول المكشوفة والقيود المُطبَّقة على حدود الواجهة البرمجية وكيفية الإبلاغ عن الأخطاء للعملاء.

الصورة الكاملة: تدفق الطلب

عند وصول طلب POST إلى /api/users تجري الخطوات التالية بالترتيب:

  1. إلغاء التسلسل: يقرأ Jackson جسم JSON وينشئ نسخة RegisterRequest.
  2. التحقق من الصحة: لوجود @Valid، يستدعي Spring مدقق Hibernate Validator على تلك النسخة.
  3. وُجد انتهاك؟ يرمي Spring استثناء MethodArgumentNotValidException؛ يُتجاوز جسم الدالة.
  4. لا انتهاكات: يُمرَّر الكائن المُتحقَّق منه إلى register() التي تفوّض إلى طبقة الخدمة.

يُبقي هذا التسلسل مخاوف التحقق خارج منطق الأعمال تمامًا. يمكن لدوال الخدمة أن تثق بأن كل RegisterRequest تستقبله يستوفي القيود المُعلَنة بالفعل — لا حاجة لفحوصات دفاعية يدوية للقيم الفارغة أو فحص الحقول.

الخلاصة

يتطلب تشغيل التحقق في متحكم Spring ثلاثة أشياء: وجود spring-boot-starter-validation في مسار الفئات، وتعليقات القيود على كائن نقل البيانات، ووضع @Valid قبل معامل @RequestBody. عند فشل قيد، يرمي Spring استثناء MethodArgumentNotValidException قبل تشغيل جسم الدالة. تتطلب الكائنات المتداخلة @Valid الخاصة بها لتتالي التحقق. لمتغيرات المسار ومعاملات الاستعلام، استخدم @Validated على مستوى الفئة مع قيود على مستوى المعامل، وانتبه إلى أن الانتهاكات تُنتج ConstraintViolationException بدلًا منه. تركّز الدروس التالية على التقاط هذه الاستثناءات وتشكيل استجابات الخطأ التي يستقبلها عملاء واجهتك البرمجية.