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

قيود التحقق المخصصة

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

قيود التحقق المخصصة

تأتي Jakarta Bean Validation مزوّدة بمجموعة متينة من القيود المدمجة — @NotNull و@Size و@Pattern و@Email وغيرها من عشرات الأخرى. وهي تغطي الحالات الشائعة، غير أن قواعد النطاق في التطبيقات الحقيقية نادرًا ما تكون بهذا العموم. اسم المستخدم يجب ألا يحتوي على مسافات. نسبة الخصم لا يمكن أن تتجاوز السعر الأساسي. رقم الهاتف يجب أن يتطابق مع صيغة خاصة ببلد معين. لا يمكن التعبير عن هذه القواعد بأي تعليق توضيحي مدمج واحد، كما أن تجميع عدة منها معًا يجعل الكود هشًا وصعب القراءة. هنا يأتي دور كتابة قيد مخصص.

يتألف القيد المخصص في Jakarta Validation دائمًا من جزأين: تعليق توضيحي تضعه على الحقول أو المعاملات، وفئة منقّحة (validator) تحتوي على المنطق. يُعلن التعليق التوضيحي عن البيانات الوصفية؛ بينما تؤدي فئة التحقق العمل الفعلي. يربطهما Spring Boot تلقائيًا — لا حاجة لأي إعداد إضافي للـ bean.

تشريح تعليق قيد مخصص

ابدأ بتعريف التعليق التوضيحي نفسه. ثلاثة تعليقات توضيحية وصفية (meta-annotations) من Jakarta API إلزامية:

  • @Constraint(validatedBy = ...) — يربط التعليق بفئة التحقق الخاصة به.
  • @Target — يتحكم في أماكن وضع التعليق (الحقول، معاملات الدوال، الأنواع الكاملة، إلخ).
  • @Retention(RUNTIME) — يجب أن يكون التعليق متاحًا وقت التشغيل لكي تقرأه محرك التحقق.

يجب أن يُعلن التعليق أيضًا عن ثلاثة سمات يطلبها الإطار: message وgroups وpayload. تمتلك هذه القيم الافتراضية المعيارية؛ فأنت لا تتجاوزها تقريبًا على مستوى تعريف التعليق.

package com.example.api.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Documented @Constraint(validatedBy = NoSpacesValidator.class) @Target({ ElementType.FIELD, ElementType.PARAMETER }) @Retention(RetentionPolicy.RUNTIME) public @interface NoSpaces { String message() default "must not contain spaces"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
لماذا @Documented؟ ليست مطلوبة من الإطار، لكنها تجعل التعليق يظهر في Javadoc المُولَّد. يستحق تضمينها في أي قيد يخص واجهة برمجية عامة حتى يرى مستخدمو مكتبتك القاعدة مباشرةً في التوثيق.

كتابة فئة التحقق

تُنفّذ فئة التحقق الواجهة ConstraintValidator<A, T>، حيث A هو نوع تعليقك التوضيحي وT هو نوع القيمة المُتحقَّق منها. تحتوي الواجهة على دالتين:

  • initialize(A annotation) — تُستدعى مرة واحدة عند إعداد المنقّح. استخدمها لقراءة سمات التعليق إن كان قيدك قابلًا للضبط (مثل قيمة دنيا/قصوى). اتركها فارغة إن لم يكن هناك ما تقرأه.
  • isValid(T value, ConstraintValidatorContext context) — تُستدعى لكل عملية تحقق. أعِد true إن كانت القيمة مقبولة، وfalse لإخفاق التحقق.
package com.example.api.validation; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public class NoSpacesValidator implements ConstraintValidator<NoSpaces, String> { @Override public void initialize(NoSpaces annotation) { // لا توجد سمات لقراءتها } @Override public boolean isValid(String value, ConstraintValidatorContext context) { // null "صحيحة" هنا — @NotNull يتولى حالة null بشكل منفصل if (value == null) { return true; } return !value.contains(" "); } }
تعامل مع null باعتبارها صحيحة في دالة isValid. تُعرّف Jakarta Validation فصلًا واضحًا للمهام: مسؤولية التعامل مع null تعود لـ @NotNull (أو @NotBlank للنصوص). إذا رفض منقّحك المخصص قيم null أيضًا، فأنت تجمع هاتين المسؤوليتين في مكان واحد ولا تستطيع التحكم فيهما بشكل مستقل على حقول مختلفة. أعِد true لـ null ودع @NotNull يتولى الأمر حين تحتاج.

تطبيق القيد

بمجرد وجود التعليق والمنقّح، تستخدم قيدك المخصص تمامًا كأي قيد مدمج. يمكنك تراكمه مع غيره:

public class RegistrationRequest { @NotBlank @Size(min = 3, max = 30) @NoSpaces(message = "Username must be a single word with no spaces") private String username; @NotBlank @Email private String email; // getters / setters }

وفي الـ controller، يُفعّل @Valid جميع القيود بما فيها قيدك المخصص:

@PostMapping("/register") public ResponseEntity<Void> register(@Valid @RequestBody RegistrationRequest request) { userService.register(request); return ResponseEntity.status(HttpStatus.CREATED).build(); }

القيود ذات المعاملات

تأتي القوة الحقيقية للقيود المخصصة من جعلها قابلة للتهيئة. لنفترض أنك تحتاج للتحقق من أن قيمة نصية تنتمي إلى مجموعة محددة من القيم المسموح بها — قيد "القيم المسموح بها". أضف سمات لتعليقك التوضيحي واقرأها في initialize:

@Documented @Constraint(validatedBy = AllowedValuesValidator.class) @Target({ ElementType.FIELD, ElementType.PARAMETER }) @Retention(RetentionPolicy.RUNTIME) public @interface AllowedValues { String[] values(); // سمة إلزامية — لا قيمة افتراضية String message() default "must be one of the allowed values"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
import java.util.Arrays; import java.util.Set; import java.util.stream.Collectors; public class AllowedValuesValidator implements ConstraintValidator<AllowedValues, String> { private Set<String> allowed; @Override public void initialize(AllowedValues annotation) { allowed = Arrays.stream(annotation.values()).collect(Collectors.toSet()); } @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value == null) return true; return allowed.contains(value); } }

الاستخدام على حقل مع رسالة مفيدة واضحة:

@AllowedValues( values = { "STANDARD", "EXPRESS", "OVERNIGHT" }, message = "Shipping tier must be STANDARD, EXPRESS, or OVERNIGHT" ) private String shippingTier;

القيود على مستوى الفئة

في بعض الأحيان يستلزم التحقق مقارنة حقول متعددة — المثال الكلاسيكي هو "تاريخ الانتهاء يجب أن يكون بعد تاريخ البداية". لا يمكنك التعبير عن هذا في حقل واحد. بدلًا من ذلك، استهدف الفئة بأكملها بضبط @Target(ElementType.TYPE) والتحقق من الكائن:

@Documented @Constraint(validatedBy = DateRangeValidator.class) @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface ValidDateRange { String message() default "End date must be after start date"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
public class DateRangeValidator implements ConstraintValidator<ValidDateRange, EventRequest> { @Override public boolean isValid(EventRequest request, ConstraintValidatorContext context) { if (request.getStartDate() == null || request.getEndDate() == null) return true; return request.getEndDate().isAfter(request.getStartDate()); } }
@ValidDateRange public class EventRequest { @NotNull private LocalDate startDate; @NotNull private LocalDate endDate; // getters / setters }
أخطاء القيود على مستوى الفئة مرتبطة بالكائن الجذري وليس بحقل محدد. عند الإبلاغ عن المخالفة، ستظهر دون مسار خاصية إلا إذا أضفته صراحةً داخل isValid باستخدام context.buildConstraintViolationWithTemplate(...).addPropertyNode("endDate").addConstraintViolation(). هذا مهم لواجهات REST التي تُترجم المخالفات إلى مسارات حقول JSON، وهو ما يُغطّيه الدرس التالي.

حقن Spring Beans داخل المنقّحات

نظرًا لأن Spring Boot يسجّل LocalValidatorFactoryBean باعتباره المنقّح الافتراضي، فكل تنفيذ لـ ConstraintValidator هو bean مُدار بـ Spring. هذا يعني أنك تستطيع حقن الخدمات بـ @Autowired أو حقن المُنشئ — مفيد حين يتطلب القيد استعلامًا من قاعدة البيانات، كالتحقق من أن اسم المستخدم لم يُستخدم بالفعل:

@Component public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> { private final UserRepository userRepository; public UniqueUsernameValidator(UserRepository userRepository) { this.userRepository = userRepository; } @Override public boolean isValid(String username, ConstraintValidatorContext context) { if (username == null) return true; return !userRepository.existsByUsername(username); } }
انتبه للأداء في المنقّحات التي تستعلم من قاعدة البيانات. كل استدعاء لـ isValid قد ينفّذ استعلام SQL. لنقاط نهاية التسجيل هذا مقبول. أما للعمليات الكثيرة أو التسلسلات الهرمية العميقة للكائنات، فكّر فيما إذا كانت هذه القاعدة تنتمي للمنقّح أم لطبقة الخدمة حيث تملك سيطرة أكبر على تجميع الاستعلامات.

الخلاصة

بناء قيد مخصص أمر بسيط بمجرد أن تعرف الوصفة المكونة من ثلاثة أجزاء: تعليق توضيحي مزيّن بـ @Constraint و@Target و@Retention(RUNTIME)؛ وفئة منقّح تُنفّذ ConstraintValidator<A, T>؛ وreturn true لقيم null. تضيف القيود ذات المعاملات سمات قابلة للتهيئة تُقرأ في initialize. تتحقق القيود على مستوى الفئة من حالة الكائن عبر حقول متعددة. يجعل Spring Boot المنقّحات beans كاملة فتستطيع حقن المستودعات أو الخدمات حين تتطلب القاعدة ذلك. في الدرس التالي سترى كيف تُترجم مخالفات القيود المدمجة والمخصصة على حد سواء إلى استجابات HTTP خطأ منظّمة.