المعالجة المركزية باستخدام @ControllerAdvice
في الدرس السابق رأيت كيف تعترض توابع @ExceptionHandler الموضوعة داخل فئة المتحكّم (Controller) الاستثناءاتِ الصادرة من ذلك المتحكّم. هذا يعمل، لكنه لا يتوسّع: إذا كان لديك عشرون متحكّمًا وتريد شكلًا موحّدًا للأخطاء عبر جميعها، فإن نسخ المعالج ذاته في كل فئة هشٌّ ويخرق مبدأ DRY. في اللحظة التي تتغيّر فيها قاعدة عمل — مثلًا قرّر الفريق إضافة حقل traceId لكل استجابة خطأ — يتحتّم عليك تعديل عشرين ملفًا.
يحلّ @ControllerAdvice هذه المشكلة بتمكينك من استخراج تلك التوابع المعالِجة إلى فئة واحدة مخصّصة يطبّقها Spring تلقائيًا على جميع المتحكّمات في التطبيق. فكّر فيها كمعترِض يلتفّ حول كل متحكّم دون أن تعلم المتحكّمات بوجوده.
ما الذي يمثّله @ControllerAdvice فعلًا
@ControllerAdvice هو تعليق توضيحي نمطي (stereotype annotation) — هو نفسه مُعلَّق بـ @Component — لذا يلتقطه Spring أثناء مسح المكوّنات ويسجّله كمستشار خاص. داخليًا، ينسج Spring توابعه في خط أنابيب معالجة الطلبات عبر HandlerExceptionResolverComposite، ما يعني أن توابع الاستشارة تُستدعى بعد رمي المتحكّم للاستثناء لكن قبل ارتكاز الاستجابة.
الفئة المعلَّقة بـ @RestControllerAdvice — اختصار مريح يجمع بين @ControllerAdvice و@ResponseBody — هي الاختيار الاصطلاحي لواجهات REST، لأن قيمة الإرجاع لكل تابع تُسلسَل تلقائيًا إلى JSON دون الحاجة لـ @ResponseBody صريحة على كل معالج.
@ControllerAdvice مقابل @RestControllerAdvice: استخدم @ControllerAdvice حين يمزج تطبيقك بين عروض MVC ونقاط REST، إذ قد تعيد بعض المعالجات ModelAndView. استخدم @RestControllerAdvice حين تبني واجهة REST/JSON خالصة — فهو يُزيل الكود النمطي المتكرر من كل تابع.
معالج استثناءات عالمي بسيط
إليك أبسط مثال مكتمل — فئة تعالج استثناءَين شائعَين على مستوى التطبيق بأكمله:
package com.example.api.exception;
import com.example.api.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse("NOT_FOUND", ex.getMessage());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneric(Exception ex) {
// لا تكشف أبدًا التفاصيل الداخلية للعملاء في بيئة الإنتاج
return new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred.");
}
}
سجلّ ErrorResponse المستخدم أعلاه هو حامل بيانات بسيط:
package com.example.api.dto;
public record ErrorResponse(String code, String message) {}
بوجود هذه الفئة الواحدة، أي متحكّم يرمي ResourceNotFoundException سيتلقى استجابة 404 بجسم JSON موحّد، وأي استثناء غير معالَج سيُنتج 500 — دون أي إعداد على مستوى المتحكّم.
معالجة أخطاء التحقق من @Valid
حين يفشل معامل مُعلَّق بـ @Valid أو @Validated في التحقق، يرمي Spring الاستثناء MethodArgumentNotValidException لأجسام الطلبات وConstraintViolationException لمعاملات المسار والاستعلام. تحمل هذه الاستثناءات المجموعة الكاملة من أخطاء الحقول. المعالج العالمي هو المكان المناسب لترجمتها إلى استجابة خطأ مفيدة:
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.List;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) // 422
public ValidationErrorResponse handleValidation(MethodArgumentNotValidException ex) {
List<FieldViolation> violations = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(e -> new FieldViolation(e.getField(), e.getDefaultMessage()))
.collect(Collectors.toList());
return new ValidationErrorResponse("VALIDATION_FAILED", violations);
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ValidationErrorResponse handleConstraintViolation(ConstraintViolationException ex) {
List<FieldViolation> violations = ex.getConstraintViolations()
.stream()
.map(v -> new FieldViolation(
v.getPropertyPath().toString(),
v.getMessage()))
.collect(Collectors.toList());
return new ValidationErrorResponse("VALIDATION_FAILED", violations);
}
// ... معالجات أخرى
}
لماذا 422 وليس 400؟ يعني HTTP 400 (Bad Request) أن الطلب مشوّه نحويًا — JSON غير قابل للتحليل، نوع محتوى خاطئ، وما إلى ذلك. أما HTTP 422 (Unprocessable Entity) فيعني أن الطلب تمّ تحليله بنجاح لكنه فشل في التحقق الدلالي. إعادة 422 لأخطاء Bean Validation يمنح مستهلكي الواجهة إشارة واضحة قابلة للقراءة آليًا للتمييز بين الطلبات المشوّهة وفشل القيود. مع ذلك تستخدم كثير من الواجهات العامة 400 لكليهما؛ المهم هو الاتساق.
تضييق نطاق @ControllerAdvice
بشكل افتراضي، يُطبَّق @ControllerAdvice على كل متحكّم في سياق التطبيق. يمكنك تضييق نطاقه باستخدام سمات التعليق التوضيحي:
// يُطبَّق فقط على المتحكّمات في هذه الحزمة (والحزم الفرعية)
@RestControllerAdvice(basePackages = "com.example.api.orders")
public class OrderExceptionHandler { ... }
// يُطبَّق فقط على المتحكّمات المعلَّقة بـ @RestController
@ControllerAdvice(annotations = RestController.class)
public class RestOnlyHandler { ... }
// يُطبَّق فقط على مجموعة محددة من فئات المتحكّمات
@ControllerAdvice(assignableTypes = { UserController.class, ProfileController.class })
public class UserDomainHandler { ... }
يُفيد التضييق في التطبيقات المعيارية الكبيرة حيث تملك المجالات المختلفة مفردات أخطاء مختلفة — قد يعيد مجال الطلبات كائنات OrderError بينما يعيد مجال الفوترة كائنات BillingError. فئة معالج واحدة ضخمة تتحوّل سريعًا إلى عبء صيانة؛ التقسيم حسب الحزمة أو المجال يُبقي كل معالج مركّزًا.
أولوية المعالجات عند وجود عدة فئات Advice
يُطبّق Spring توابع @ExceptionHandler وفق ترتيب محدد. يبدأ البحث من نوع الاستثناء الأكثر تحديدًا ويرتقي عبر شجرة الوراثة. حين تُعلن فئتا Advice كلتاهما معالجًا لنوع الاستثناء نفسه، يستخدم Spring الترتيب المحدد بـ @Order أو بتنفيذ Ordered:
import org.springframework.core.annotation.Order;
@RestControllerAdvice
@Order(1) // الرقم الأصغر = الأولوية الأعلى
public class DomainExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse("NOT_FOUND", ex.getMessage());
}
}
@RestControllerAdvice
@Order(2) // احتياطي: يعالج ما لم يُعالَج أعلاه
public class FallbackExceptionHandler {
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleAll(Exception ex) {
return new ErrorResponse("INTERNAL_ERROR", "Unexpected error.");
}
}
تجنّب الإمساك بـ Exception أو Throwable مبكرًا جدًا. إذا احتوت الاستشارة ذات الأولوية الأعلى على @ExceptionHandler(Exception.class)، فستبتلع كل استثناء — بما فيها استثناءات Spring الخاصة كـ MethodArgumentNotValidException وNoHandlerFoundException — قبل أن يتمكّن أي معالج أكثر تحديدًا من معالجتها. احتفظ بالمعالج الاحتياطي العام في فئة الاستشارة ذات الأولوية الأدنى (أعلى رقم @Order).
حقن سياق الطلب في المعالجات
تدعم التوابع المعالِجة في فئة @ControllerAdvice نفس حقن المعاملات الذي تدعمه توابع المتحكّم. هذا مفيد جدًا لربط الأخطاء بالطلبات الواردة:
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException ex,
HttpServletRequest request,
WebRequest webRequest) {
String path = request.getRequestURI();
String traceId = (String) request.getAttribute("X-Trace-Id");
ErrorResponse body = new ErrorResponse(
"NOT_FOUND",
ex.getMessage(),
path,
traceId
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
المعاملات القابلة للحقن تشمل HttpServletRequest وHttpServletResponse وWebRequest وLocale والاستثناء نفسه. استخدام ResponseEntity<T> نوعًا للإرجاع يمنحك تحكّمًا كاملًا في الحالة والترويسات والجسم دون الحاجة لـ @ResponseStatus.
توسيع ResponseEntityExceptionHandler
يوفّر Spring MVC فئة أساسية هي ResponseEntityExceptionHandler تُعلن بالفعل معالجات لجميع استثناءات Spring الداخلية (MethodArgumentNotValidException وHttpMessageNotReadableException وHttpRequestMethodNotSupportedException وحوالي اثنا عشر آخرين). يمنحك توسيعها معالجةً متسقة للاستثناءات على مستوى الإطار مجانًا؛ تحتاج فقط إلى تجاوز التوابع التي تريد تخصيصها:
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
List<String> errors = ex.getBindingResult().getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.toList();
return ResponseEntity.unprocessableEntity()
.body(new ValidationErrorResponse("VALIDATION_FAILED", errors));
}
// أضف توابع @ExceptionHandler لاستثناءات نطاقك أدناه
}
ResponseEntityExceptionHandler هي نقطة البداية المفضّلة لواجهات REST في بيئة الإنتاج. بدونها ستضطر إلى معالجة كل استثناء من استثناءات Spring MVC يدويًا (معامل مسار مفقود، نوع وسائط غير مدعوم، إلخ). توسيعها وتجاوز ما تحتاجه فقط يُبقي المعالج موجزًا.
الخلاصة
@RestControllerAdvice هو حجر الزاوية في استراتيجية معالجة الاستثناءات القابلة للصيانة في Spring Boot. يمركز جميع استجابات الأخطاء في مكان واحد، ويُطبّق شكلًا موحّدًا للخطأ عبر كل نقطة نهاية، ويُلغي الحاجة إلى تكرار كود المعالج في كل متحكّم. القرارات التصميمية الرئيسية هي: دائمًا وسّع ResponseEntityExceptionHandler كفئة أساسية، احتفظ بمُعالج الاستثناءات العام Exception في الاستشارة ذات الأولوية الأدنى، استخدم 422 لفشل Bean Validation، واحقن HttpServletRequest لتضمين مسار الطلب وسياق التتبع في استجابات الخطأ. الدرس القادم يبني على هذا الأساس بتصميم عقد استجابة خطأ متسق وجاهز للإنتاج.