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

تصميم استجابات خطأ متسقة

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

تصميم استجابات خطأ متسقة

كل واجهة برمجية قابلة للفشل — وجميعها كذلك — تحتاج إلى صيغة خطأ موحّدة. حين تُنتهَك قيد تحقق، أو لا يُعثر على مورد، أو يتسرّب استثناء غير معالَج، ينبغي للعملاء أن يتلقّوا بنية يمكن التنبؤ بها وقراءتها آليًا، لا تتبّعًا خامًا للمكدس ولا شكل JSON اعتباطيًا اخترعناه في اللحظة. يتناول هذا الدرس المقاربتين الرئيسيتين: كائن نقل البيانات (DTO) المشترك للأخطاء المنشأ يدويًا، ومعيار RFC 9457 لتفاصيل المشكلة (Problem Details)، وكلاهما مع Spring Boot 3.

لماذا تهمّ الاتساقية

تفرز استجابات الخطأ غير المتسقة ألمًا حقيقيًا. عميل موبايل يعالج {"error": "not found"} في مكان و{"message": "Entity not found", "code": 404} في مكان آخر يحتاج إلى منطق تحليل مخصص في كل مكان. كاتب اختبارات التكامل يجب أن يستوعب أشكالًا متعددة. أنظمة التسجيل وتتبع الأثر التي تحاول ربط الأخطاء عبر الخدمات تُسلّم حين تتغير أسماء الحقول من نقطة نهاية لأخرى.

الاتفاق على بنية خطأ واحدة — وفرضها عبر كل controller وكل @ExceptionHandler وكل filter — قرار معماري سرعان ما يُؤتي ثماره.

المقاربة الأولى: DTO مشترك للأخطاء

أبسط حل هو سجل Java (record) أو فئة عادية تُعيّن إليها كل المعالجات استثناءاتها. عرّفها مرة واحدة واستخدمها في كل مكان:

package com.example.api.error; import com.fasterxml.jackson.annotation.JsonInclude; import java.time.Instant; import java.util.List; @JsonInclude(JsonInclude.Include.NON_NULL) public record ApiError( int status, String error, String message, String path, Instant timestamp, List<FieldViolation> violations ) { public record FieldViolation(String field, String message) {} /** مصنع مختصر للأخطاء ذات الرسالة الواحدة */ public static ApiError of(int status, String error, String message, String path) { return new ApiError(status, error, message, path, Instant.now(), null); } /** مصنع لأخطاء التحقق التي تحمل تفاصيل على مستوى الحقل */ public static ApiError validation(String path, List<FieldViolation> violations) { return new ApiError(422, "Unprocessable Entity", "Validation failed", path, Instant.now(), violations); } }

الخيارات التصميمية الرئيسية هنا: @JsonInclude(NON_NULL) يحذف حقل violations حين يكون null، مما يُبقي أخطاء غير التحقق مختصرة. استخدام السجل (record) يجعل DTO غير قابل للتعديل ويزيل الكثير من الكود الروتيني. حقل timestamp من نوع Instant يُسلسَل إلى ISO-8601 افتراضيًا — لا أرقام epoch غامضة.

اربط هذا بـ @RestControllerAdvice:

package com.example.api.error; import jakarta.servlet.http.HttpServletRequest; 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; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) public ApiError handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) { List<ApiError.FieldViolation> violations = ex.getBindingResult() .getFieldErrors() .stream() .map(fe -> new ApiError.FieldViolation(fe.getField(), fe.getDefaultMessage())) .toList(); return ApiError.validation(req.getRequestURI(), violations); } @ExceptionHandler(ResourceNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ApiError handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) { return ApiError.of(404, "Not Found", ex.getMessage(), req.getRequestURI()); } @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiError handleGeneric(Exception ex, HttpServletRequest req) { return ApiError.of(500, "Internal Server Error", "An unexpected error occurred", req.getRequestURI()); } }
لا تكشف رسائل الاستثناءات الداخلية للعملاء في الإنتاج أبدًا. تعيد الدالة handleGeneric عمدًا رسالة عامة. سجّل الاستثناء الحقيقي على جانب الخادم (log.error("Unhandled", ex))، لكن أبقِ استجابة العميل مبهمة لتجنّب تسريب تفاصيل التنفيذ أو تتبّعات المكدس.

مع هذا الإعداد يصل كل خطأ — سواء أكان 404 أم فشل تحقق 422 أم 500 — إلى العميل في الشكل ذاته:

// 422 من فحص @Valid الفاشل { "status": 422, "error": "Unprocessable Entity", "message": "Validation failed", "path": "/api/users", "timestamp": "2024-11-15T10:23:45.123Z", "violations": [ { "field": "email", "message": "must be a well-formed email address" }, { "field": "firstName", "message": "must not be blank" } ] } // 404 من ResourceNotFoundException { "status": 404, "error": "Not Found", "message": "User with id 42 not found", "path": "/api/users/42", "timestamp": "2024-11-15T10:23:46.001Z" }

المقاربة الثانية: RFC 9457 — تفاصيل المشكلة

تحدّد مواصفة Problem Details (RFC 9457 التي استبدلت RFC 7807) بنية JSON معيارية لاستجابات أخطاء HTTP. يأتي Spring Framework 6 بدعم من الدرجة الأولى لها عبر ProblemDetail. استخدام Problem Details يعني أن واجهتك البرمجية تتحدث لغة تفهمها بالفعل عملاء HTTP الجاهزة وبوابات API وأدوات التوثيق.

الشكل القانوني يبدو كالتالي:

{ "type": "https://api.example.com/errors/validation-failed", "title": "Unprocessable Entity", "status": 422, "detail": "Validation failed for 2 field(s).", "instance": "/api/users", "violations": [ { "field": "email", "message": "must be a well-formed email address" } ] }

الحقول المطلوبة هي type (URI يُعرّف نوع المشكلة) وtitle (ملخص قصير قابل للقراءة البشرية) وstatus. كلٌّ من detail وinstance اختياريان لكن يُنصح بهما بقوة. أي خصائص إضافية مسموح بها كامتدادات.

يستطيع Spring Boot 3 تفعيل Problem Details تلقائيًا لاستثناءاته المضمّنة:

# application.properties spring.mvc.problemdetails.enabled=true

لاستثناءاتك الخاصة، أنشئ كائن ProblemDetail مباشرةً:

import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.net.URI; import java.util.List; import java.util.Map; @RestControllerAdvice public class ProblemDetailsHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) { ProblemDetail pd = ProblemDetail.forStatus(422); pd.setType(URI.create("https://api.example.com/errors/validation-failed")); pd.setTitle("Unprocessable Entity"); pd.setDetail("Validation failed for " + ex.getBindingResult().getErrorCount() + " field(s)."); pd.setInstance(URI.create(req.getRequestURI())); List<Map<String, String>> violations = ex.getBindingResult() .getFieldErrors() .stream() .map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage())) .toList(); pd.setProperty("violations", violations); return pd; } }
فضّل Problem Details للواجهات البرمجية العامة الجديدة. العملاء الذين يحلّلون RFC 9457 يمكنهم معالجة أخطاء أي واجهة برمجية متوافقة دون عملية تعيين مخصصة. إذا كنت تبني واجهة برمجية داخلية يستهلكها فقط واجهتك الأمامية، فمقاربة DTO المخصصة صالحة بالقدر ذاته وأسهل في التوسيع.

الاختيار بين المقاربتين

  • DTO الخطأ المخصص: تحكم كامل في الشكل، سهل إضافة حقول له، مألوف لمعظم مطوري Spring. الخيار الصحيح للواجهات البرمجية الداخلية أو المقترنة بإحكام.
  • RFC 9457 Problem Details: قابل للتشغيل البيني، صديق للأدوات، موصوف ذاتيًا عبر URI النوع. الخيار الصحيح للواجهات البرمجية العامة أو التصاميم API-first أو حين لا يكون المستهلكون تحت سيطرتك.
  • يمكنك أيضًا دمج الاثنتين: نفّذ Problem Details كغلاف خارجي وضع انتهاكات حقولك في خاصية امتداد مخصصة — وهو بالضبط ما يفعله المثال الثاني أعلاه.

ضبط ترويسة Content-Type

لاستجابات Problem Details يكون نوع MIME هو application/problem+json لا application/json. تضبط آلية ProblemDetail في Spring هذا تلقائيًا حين يُعاد منها من معالج. إذا استخدمت DTO مخصصًا قد ترغب في ضبطه صراحةً للإشارة إلى النية:

@ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) { ApiError body = ApiError.of(404, "Not Found", ex.getMessage(), req.getRequestURI()); return ResponseEntity.status(HttpStatus.NOT_FOUND) .contentType(MediaType.APPLICATION_PROBLEM_JSON) .body(body); }

الخلاصة

بنية استجابة الخطأ المتسقة ليست تفصيلًا مظهريًا — إنها عقد بين واجهتك البرمجية ومستهلكيها. يمنحك DTO الخطأ المشترك تحكمًا كاملًا ويندمج بسلاسة في أي مشروع Spring Boot. يوفر معيار RFC 9457 لتفاصيل المشكلة قابلية تشغيل بيني ودعم أدوات جاهزًا مع Spring 6. كلتا المقاربتين تتمحوران حول الفكرة ذاتها: كل مسار فشل يجب أن ينتج الغلاف المتوقع نفسه، تملؤه @RestControllerAdvice مركزية لا منطق مبعثر عشوائيًا في متحكماتك.