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

مجموعات التحقق والسيناريوهات المتقدمة

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

مجموعات التحقق والسيناريوهات المتقدمة

التعليقات التوضيحية المدمجة في Jakarta Validation والقيود المخصصة التي رأيتها حتى الآن تُطبَّق بشكل موحّد — في كل مرة تتحقق فيها من كائن ما تُفحص كل حقل موسوم. هذا مناسب للنماذج البسيطة، لكن الواجهات البرمجية الحقيقية سرعان ما تتجاوز هذا النموذج. يحتاج UserDto المستخدم في التسجيل إلى كلمة مرور؛ نفس الكائن المستخدم في تحديث الملف الشخصي يجب ألا يطلبها. طلب إنشاء مورد يجب ألا يُمرَّر فيه معرّف (ID)؛ طلب التحديث يجب أن يحتوي عليه. وقواعد مثل "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء" لا يمكن التعبير عنها على حقل واحد — فهي تمتد عبر الكائن بأكمله.

تُجيب Jakarta Validation على كلا التحديين: مجموعات التحقق (Validation Groups) تُتيح لك تفعيل مجموعة فرعية فقط من القيود في كل مرة، أما قيود الحقول المتقاطعة (Cross-Field Constraints) فتُتيح لك التحقق من العلاقات بين الحقول. كلاهما من الميزات المدعومة بشكل كامل في Spring Boot 3 مع Spring 6.

ما هي مجموعات التحقق؟

كل تعليق توضيحي للقيود يقبل خاصية groups. عندما تتركها فارغة تنتمي القيد إلى المجموعة Default وهي ما يُفعّله @Valid. بتعيين groups لواجهة برمجية أو أكثر تُنشئ مجموعات مسمّاة، ثم تطلب من Spring التحقق من تلك المجموعات فقط باستبدال @Valid بـ @Validated.

الواجهات البرمجية التمييزية هي واجهات Java عادية — لا تحمل أي توابع. غرضها الوحيد هو العمل كعلامة آمنة من حيث الأنواع.

package com.example.api.validation; public interface OnCreate {} public interface OnUpdate {}

الآن قم بتوسيم حقول الـ DTO الخاص بك بالمجموعة المناسبة:

package com.example.api.dto; import com.example.api.validation.OnCreate; import com.example.api.validation.OnUpdate; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Null; import jakarta.validation.constraints.Size; public class UserDto { // يجب أن يكون null عند الإنشاء (الخادم يُسنده)، ويجب أن يكون موجودًا عند التحديث @Null(groups = OnCreate.class, message = "ID must not be supplied on creation") @NotNull(groups = OnUpdate.class, message = "ID is required for updates") private Long id; @NotBlank(groups = { OnCreate.class, OnUpdate.class }) @Size(min = 2, max = 60) private String name; // مطلوب فقط عند إنشاء حساب جديد @NotBlank(groups = OnCreate.class, message = "Password is required for new accounts") @Size(min = 8, groups = OnCreate.class) private String password; // getters و setters محذوفة للإيجاز }
المجموعات تراكمية. يمكن أن ينتمي الحقل إلى مجموعات متعددة بسردها في المصفوفة: groups = { OnCreate.class, OnUpdate.class }. عند تفعيل OnCreate، تُطبَّق القيود التي تُصرّح بتلك المجموعة فقط (أو Default حسب الإعداد).

تفعيل المجموعات في المتحكم باستخدام @Validated

يقبل @Validated من Spring (من حزمة org.springframework.validation.annotation) فئات المجموعات كمعطيات. استخدمه بدلًا من @Valid عندما تريد التحقق الانتقائي حسب المجموعة:

import com.example.api.dto.UserDto; import com.example.api.validation.OnCreate; import com.example.api.validation.OnUpdate; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/users") public class UserController { @PostMapping public UserDto createUser(@RequestBody @Validated(OnCreate.class) UserDto dto) { // تُفحص هنا قيود OnCreate فقط return userService.create(dto); } @PutMapping("/{id}") public UserDto updateUser(@PathVariable Long id, @RequestBody @Validated(OnUpdate.class) UserDto dto) { // تُفحص هنا قيود OnUpdate فقط return userService.update(id, dto); } }
نصيحة — أضف Default عندما تقصده. القيود التي لا تُصرّح بمجموعة صريحة تنتمي إلى Default. عند استخدام @Validated(OnCreate.class)، لا تُفحص قيود المجموعة Default إلا إذا مررت أيضًا jakarta.validation.groups.Default.class. كن متعمدًا: إما أسند كل قيد لمجموعة صريحة، أو ادمج مجموعتك مع Default دائمًا.

ترتيب المجموعات باستخدام GroupSequence

أحيانًا تريد أن تعمل المجموعات بالترتيب وتتوقف عند أول فشل. يُحدّد @GroupSequence هذا الترتيب على واجهة برمجية مخصصة:

import jakarta.validation.GroupSequence; import jakarta.validation.groups.Default; import com.example.api.validation.OnCreate; @GroupSequence({ Default.class, OnCreate.class }) public interface CreateSequence {}

ثم استخدم @Validated(CreateSequence.class) في المتحكم. إذا فشلت أي قيد من مجموعة Default، لن تُنفَّذ مجموعة OnCreate أبدًا، مما يتجنب تراكم رسائل خطأ زائدة.

التحقق من الحقول المتقاطعة بقيود على مستوى الفئة

القيود على مستوى الحقل تتحقق من قيمة واحدة بمعزل عن غيرها. أما القواعد المتقاطعة — "يجب أن يكون النهاية بعد البداية"، "يجب أن تتطابق كلمة المرور والتأكيد" — فتحتاج إلى الوصول إلى الكائن كله. الحل الأنظف هو قيد على مستوى الفئة (Class-Level Constraint): تعليق توضيحي مخصص يُوضع على الفئة ذاتها ويستقبل المحقق (validator) فيه الكائن بأكمله.

عرّف التعليق التوضيحي مستهدفًا ElementType.TYPE:

package com.example.api.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; @Documented @Constraint(validatedBy = DateRangeValidator.class) @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface ValidDateRange { String message() default "startDate must be before endDate"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }

اكتب المحقق. معامل النوع T هو الفئة المُوسَمة:

package com.example.api.validation; import com.example.api.dto.EventDto; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public class DateRangeValidator implements ConstraintValidator<ValidDateRange, EventDto> { @Override public boolean isValid(EventDto dto, ConstraintValidatorContext ctx) { if (dto.getStartDate() == null || dto.getEndDate() == null) { return true; // دع @NotNull يعالج حالة القيمة الفارغة } boolean valid = dto.getStartDate().isBefore(dto.getEndDate()); if (!valid) { // إرفاق الخطأ بحقل محدد بدلًا من جذر الفئة ctx.disableDefaultConstraintViolation(); ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate()) .addPropertyNode("endDate") .addConstraintViolation(); } return valid; } }

طبّقه على الـ DTO:

import com.example.api.validation.ValidDateRange; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; @ValidDateRange public class EventDto { @NotNull private LocalDate startDate; @NotNull private LocalDate endDate; @NotBlank private String title; // getters و setters محذوفة }
أعد دائمًا true عندما تكون القيم اللازمة للفحص المتقاطع فارغة (null). إذا أعدت false على قيم فارغة ستنتج رسالة خطأ محيّرة على مستوى الفئة إلى جانب رسالة @NotNull الأكثر وضوحًا على مستوى الحقل. ضع الحراسة في أعلى isValid ودع قيود الحقول تؤدي عملها أولًا.

نمط التحقق من تطابق كلمة المرور

سيناريو متقاطع شائع جدًا هو التحقق من تطابق حقلين. ينطبق عليه نفس أسلوب القيد على مستوى الفئة:

@PasswordsMatch public class RegistrationDto { @NotBlank @Size(min = 8) private String password; @NotBlank private String passwordConfirm; // getters محذوفة } // المحقق: public class PasswordsMatchValidator implements ConstraintValidator<PasswordsMatch, RegistrationDto> { @Override public boolean isValid(RegistrationDto dto, ConstraintValidatorContext ctx) { if (dto.getPassword() == null || dto.getPasswordConfirm() == null) { return true; } boolean match = dto.getPassword().equals(dto.getPasswordConfirm()); if (!match) { ctx.disableDefaultConstraintViolation(); ctx.buildConstraintViolationWithTemplate("Passwords do not match") .addPropertyNode("passwordConfirm") .addConstraintViolation(); } return match; } }

الجمع بين المجموعات وقيود الحقول المتقاطعة

قيود مستوى الفئة تدعم خاصية groups تمامًا كقيود الحقول. يمكنك تقييد الفحص المتقاطع لمجموعة محددة:

@ValidDateRange(groups = OnCreate.class) public class EventDto { ... }

هذا مفيد عندما يُطبَّق نطاق التاريخ فقط أثناء الإنشاء، بينما تسمح عمليات التعديل الجزئي (PATCH) بغياب أحد التاريخين.

التحقق البرمجي خارج المتحكمات

أحيانًا تحتاج للتحقق يدويًا في خدمة ما — قبل الحفظ في قاعدة البيانات أو داخل عملية دُفعية. أدخل حبة jakarta.validation.Validator التي تُهيّؤها Spring Boot تلقائيًا:

import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; import java.util.Set; @Service public class EventService { private final Validator validator; public EventService(Validator validator) { this.validator = validator; } public void schedule(EventDto dto) { Set<ConstraintViolation<EventDto>> violations = validator.validate(dto); if (!violations.isEmpty()) { throw new ConstraintViolationException(violations); } // المتابعة بالحفظ } }

للتحقق من مجموعة محددة برمجيًا مرر فئة المجموعة كمعطى ثانٍ: validator.validate(dto, OnCreate.class).

المقايضات ومتى تستخدم كل أسلوب

  • المجموعات — مثالية عندما يُعاد استخدام نفس الـ DTO عبر نقاط نهاية الإنشاء والتحديث والتعديل الجزئي والاختلاف هيكلي (أي الحقول المطلوبة). تجنب الإفراط فيها: إذا تباينت الأشكال كثيرًا تكون DTOs منفصلة أنظف.
  • قيود مستوى الفئة — الأداة الصحيحة لأي قاعدة تتضمن أكثر من حقل. ابقها مركّزة؛ محقق يفحص خمس علاقات متشابكة صعب الاختبار وصعب الصيانة.
  • التحقق البرمجي — استعن به في الخدمات والعمليات الدُفعية، لكن فضّل التحقق بالتعليقات التوضيحية عند حدود الواجهة البرمجية حيث التوصيل بالإطار جاهز.

الخلاصة

تُتيح مجموعات التحقق تطبيق مجموعات قواعد مختلفة على نفس الفئة حسب السياق، مُفعَّلة من خلال @Validated(Group.class) في المتحكم. أما التحقق من الحقول المتقاطعة فيستخدم قيودًا مخصصة على مستوى الفئة يستقبل محققها الكائن بأكمله، مما يُتيح تطبيق العلاقات بين الحقول وإلصاق انتهاكات القيود بمسارات خصائص محددة. يجمع هذان الأسلوبان معًا تغطية شبه كاملة لكل سيناريوهات التحقق التي تواجهها الواجهة البرمجية في بيئة الإنتاج — دون تشتيت قواعد الأعمال عبر المتحكمات أو الخدمات.