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

معالجة الاستثناءات باستخدام @ExceptionHandler

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

معالجة الاستثناءات باستخدام @ExceptionHandler

كل واجهة برمجية حقيقية تُطلق استثناءات. المورد المطلوب غير موجود، أو المُستدعي يُمرّر معرّفًا خاطئًا، أو خدمة خارجية غير متاحة. السؤال ليس هل ستقع الاستثناءات بل أين تريد التعامل معها، وماذا يتلقّى المُستدعي حين تقع. تمنحك آلية @ExceptionHandler في Spring MVC إجابةً نظيفة وتصريحية: أضف التوصيف على دالة مع نوع الاستثناء الذي تريد اعتراضه، وسيوجّه Spring الاستدعاء إلى تلك الدالة في أي وقت يُفلت فيه أحد هذه الاستثناءات من أي إجراء في وحدة التحكم.

مشكلة try-catch داخل دوال وحدة التحكم

قبل وجود @ExceptionHandler، كان المطوّرون يلفّون كل جسم إجراء بـ try-catch، ويُعيّنون الاستثناء إلى رمز حالة HTTP يدويًا، ثم يُعيدون استجابة الخطأ. هذا الأسلوب ينتج كودًا متكرّرًا وصعب الصيانة، ويشتّت منطق معالجة الأخطاء في كل مكان بدلًا من مركزته.

// ❌ الطريقة القديمة — تتكرّر في كل دالة @GetMapping("/orders/{id}") public ResponseEntity<Order> getOrder(@PathVariable Long id) { try { return ResponseEntity.ok(orderService.findById(id)); } catch (OrderNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } catch (Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } }

مع @ExceptionHandler تُعرّف هذا التعيين مرة واحدة، وتستفيد منه كل إجراءات وحدة التحكم تلقائيًا.

الإعلان عن دالة @ExceptionHandler

ضع دالة مُوصَّفة بـ @ExceptionHandler داخل @RestController (أو @Controller). يعترض Spring أي استثناء من النوع المُعلَن يتصاعد من أي دالة @RequestMapping في نفس الفئة ويوجّهه إلى معالجك بدلًا من السماح له بالانتشار.

import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/orders") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @GetMapping("/{id}") public Order getOrder(@PathVariable Long id) { // يُطلق OrderNotFoundException إذا لم يُوجد — لا حاجة لـ try-catch هنا return orderService.findById(id); } @PostMapping public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) { Order saved = orderService.create(request); return ResponseEntity.status(HttpStatus.CREATED).body(saved); } // ✅ يعالج OrderNotFoundException المُطلَق من أي دالة في هذه وحدة التحكم @ExceptionHandler(OrderNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) { ErrorResponse body = new ErrorResponse("ORDER_NOT_FOUND", ex.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); } }

لاحظ أن getOrder لا تحتوي على أي try-catch بعد الآن. إنها ببساطة تُفوّض عمل المسار السعيد إلى الخدمة وتترك الاستثناء يتصاعد. يعترضه Spring قبل أن يصل إلى حاوية السيرفلت ويستدعي handleNotFound عوضًا عنه.

توقيع دالة المعالج

دوال المعالج مرنة. يُحلّل Spring عدة معاملات مفيدة تلقائيًا:

  • الاستثناء نفسه — مُكتَّب بنوع الاستثناء (أو نوع أب له).
  • HttpServletRequest / HttpServletResponse — كائنات السيرفلت الخام عند الحاجة إليها.
  • WebRequest — تجريد محمول فوق الطلب.
  • Locale، TimeZone — لرسائل الخطأ المُوطَّنة.
  • Model — لوحدات تحكم MVC (غير REST) التي تُعيد اسم عرض.

نوع الإعادة مرن بالقدر ذاته: ResponseEntity<T> (مُوصى به لـ REST)، أو كائن عادي (يستخدم Spring محوّلات الرسائل المعتادة)، أو ModelAndView (لـ MVC)، أو void إذا كتبت مباشرةً في الاستجابة.

معالجة أنواع استثناءات متعددة في دالة واحدة

يمكنك سرد عدة فئات استثناءات في قيمة التوصيف واستقبال النوع الأساسي في المعامل:

@ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class}) public ResponseEntity<ErrorResponse> handleBadRequest(RuntimeException ex) { return ResponseEntity .badRequest() .body(new ErrorResponse("BAD_REQUEST", ex.getMessage())); }
التسلسل الهرمي للفئات مهم. إذا وصّفت معالجًا بنوع أساسي مثل RuntimeException.class، فسيعترض كل استثناء غير محقَّق لم يُدَّعَ بالفعل من قِبَل معالج أكثر تحديدًا في نفس الفئة. يوجّه Spring دائمًا إلى أكثر معالج محدَّدًا أولًا.

الوصول إلى معلومات الطلب داخل المعالج

أحيانًا تحتاج إلى تسجيل مسار الطلب إلى جانب الخطأ، أو تضمينه في جسم الخطأ حتى يتمكّن العملاء من ربط الاستجابات بطلباتهم. أضف HttpServletRequest كمعامل:

import jakarta.servlet.http.HttpServletRequest; @ExceptionHandler(OrderNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound( OrderNotFoundException ex, HttpServletRequest request) { ErrorResponse body = ErrorResponse.builder() .code("ORDER_NOT_FOUND") .message(ex.getMessage()) .path(request.getRequestURI()) .timestamp(Instant.now()) .build(); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); }

سجل ErrorResponse واقعي

احرص على أن يكون كائن نقل بيانات الخطأ متسقًا وسهل التسلسل. يعمل سجل Java بشكل مثالي — فهو غير قابل للتغيير وموجز، وJackson يُسلسله مباشرةً:

import java.time.Instant; public record ErrorResponse( String code, String message, String path, Instant timestamp ) { // مُصنع مريح للحالة الشائعة public static ErrorResponse of(String code, String message, String path) { return new ErrorResponse(code, message, path, Instant.now()); } }

مع هذا السجل يبدو جسم استجابة الخطأ هكذا:

{ "code": "ORDER_NOT_FOUND", "message": "Order 42 does not exist", "path": "/api/orders/42", "timestamp": "2025-09-15T10:23:44.817Z" }

قيد النطاق — ومتى تنتقل إلى ما هو أبعد

القيد الرئيسي لـ @ExceptionHandler المحلي لوحدة التحكم هو نطاقه: إنه يعترض فقط الاستثناءات المُطلَقة من دوال في تلك الفئة ذاتها. لو كان لديك عشرون وحدة تحكم وتُطلق كل منها OrderNotFoundException، ستضطر إلى نسخ المعالج في كل واحدة منها.

قاعدة عامة: استخدم @ExceptionHandler المحلي لوحدة التحكم حين يكون منطق المعالجة حقًا خاصًا بوحدة تحكم واحدة — على سبيل المثال حين تُشير رسائل الخطأ إلى سياق وحدة التحكم، أو حين تريد عمدًا رموز حالة مختلفة للاستثناء ذاته في أجزاء مختلفة من الـ API. للمعالجة التقاطعية على مستوى التطبيق بأكمله، انتقل إلى @ControllerAdvice الذي سيكون موضوع الدرس التالي.
لا تُكتم الاستثناء وتُعيد 200 OK. المعالج الذي يبتلع الخطأ ويُعيد استجابة نجاح هو خطأ شائع. عيّن دائمًا الاستثناءات إلى رمز حالة HTTP مناسب (4xx لأخطاء العميل، 5xx لأخطاء الخادم) حتى يتمكّن المستهلكون وأدوات المراقبة من الاستجابة بشكل صحيح.

تجميع كل شيء معًا — مثال كامل

@RestController @RequestMapping("/api/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @GetMapping("/{id}") public Product findById(@PathVariable Long id) { return productService.findById(id); // يُطلق ResourceNotFoundException } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Long id) { productService.delete(id); // يُطلق ResourceNotFoundException أو AccessDeniedException } @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound( ResourceNotFoundException ex, HttpServletRequest req) { return ResponseEntity .status(HttpStatus.NOT_FOUND) .body(ErrorResponse.of(ex.getCode(), ex.getMessage(), req.getRequestURI())); } @ExceptionHandler(AccessDeniedException.class) public ResponseEntity<ErrorResponse> handleForbidden( AccessDeniedException ex, HttpServletRequest req) { return ResponseEntity .status(HttpStatus.FORBIDDEN) .body(ErrorResponse.of("FORBIDDEN", ex.getMessage(), req.getRequestURI())); } }

تتشارك كلتا دالتَي المعالجة نفس شكل ErrorResponse، وتُعيّنان إلى رموز حالة HTTP مناسبة، وتتضمّنان مسار الطلب — مما يمنح مستهلكي الـ API كل ما يحتاجونه لفهم الخطأ والتصرف بناءً عليه دون الكشف عن تتبّعات المكدس الداخلية.

الخلاصة

يُزيل @ExceptionHandler النمطي المتكرّر من try-catch في دوال إجراءات وحدة التحكم بمركزة تعيين الأخطاء في دوال معالجة مخصّصة داخل نفس وحدة التحكم. تُعلن عن نوع الاستثناء الذي تريد اعتراضه، وتختار رمز حالة HTTP الصحيح، وتبني جسم خطأ منظّمًا، ويتولّى Spring التوجيه. أما المقايضة فهي النطاق: المعالجات تعيش في فئة واحدة. في الدرس التالي ستتعرف على كيفية رفع @ControllerAdvice لهذا القيد والسماح لفئة واحدة بمعالجة الاستثناءات عبر التطبيق بأكمله.