مشروع: واجهة برمجية متينة مع التحقق الشامل
على مدار هذا البرنامج التعليمي تعلّمت كل قطعة من القطع منفردةً: قيود Bean Validation، و@Valid، والمُحقِّقات المخصصة، و@ExceptionHandler، و@ControllerAdvice، وتصميم استجابات الأخطاء المتسقة. في هذا الدرس الأخير تجمع كل قطعة في تطبيق Spring Boot 3 واحد جاهز للإنتاج. الهدف ليس تقديم واجهات برمجية جديدة — بل إظهار كيف تتكامل الأجزاء مع بعضها وأين تكون نقاط التوصيل عند بناء شيء حقيقي.
ما الذي تبنيه
واجهة برمجية صغيرة لـكتالوج المنتجات تحتوي على موردَين: الفئات والمنتجات. تفرض الواجهة البرمجية:
- التحقق من جميع هيئات الطلبات بقيود Jakarta Validation قبل تشغيل أي منطق أعمال.
- تحويل انتهاكات النطاق (slug مكرر، فئة غير موجودة) إلى استجابات
4xx واضحة.
- كل استجابة خطأ لها نفس شكل JSON بغض النظر عن مصدر الخطأ.
- الاستثناءات غير المتوقعة تُنتج رمز
500 منقّى دون تسريب أي تفاصيل داخلية للعميل.
هيكل المشروع
ابدأ بمشروع Spring Boot 3 قياسي وأضف هذه الـ starters إلى pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
شكل استجابة الخطأ الموحدة
عرّفها مرة واحدة. كل خطأ تُعيده الواجهة البرمجية يستخدم هذا السجل:
package com.example.catalog.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,
List<FieldViolation> violations,
Instant timestamp
) {
public record FieldViolation(String field, String message) {}
/** مصنع مريح للأخطاء ذات رسالة واحدة. */
public static ApiError of(int status, String error, String message) {
return new ApiError(status, error, message, null, Instant.now());
}
/** مصنع مريح لأخطاء التحقق مع تفاصيل الحقول. */
public static ApiError validation(List<FieldViolation> violations) {
return new ApiError(422, "Unprocessable Entity",
"Validation failed", violations, Instant.now());
}
}
لماذا record؟ تمنحك سجلات Java نوع قيمة غير قابل للتغيير وموجز مع مُنشئ تلقائي وgetters وequals وhashCode وtoString — وهو تمامًا ما يحتاجه كائن نقل بيانات الخطأ. يُبقي @JsonInclude(NON_NULL) حقل violations خارج JSON عندما لا يكون ذا صلة (مثل 404).
كائنات نقل بيانات الطلب مع التحقق
ضع جميع قيود التحقق على كائن نقل البيانات لا على الكيان. هذا هو CreateProductRequest:
package com.example.catalog.product;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
public record CreateProductRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 120, message = "Name must be between 2 and 120 characters")
String name,
@NotBlank(message = "Slug is required")
@Pattern(regexp = "^[a-z0-9]+(?:-[a-z0-9]+)*$",
message = "Slug must be lowercase letters, digits, and hyphens only")
String slug,
@NotNull(message = "Price is required")
@DecimalMin(value = "0.01", message = "Price must be greater than zero")
@Digits(integer = 8, fraction = 2, message = "Price must have at most 2 decimal places")
BigDecimal price,
@NotNull(message = "Category ID is required")
@Positive(message = "Category ID must be a positive integer")
Long categoryId
) {}
استخدم BigDecimal للمال وليس double. لا تستطيع أنواع الفاصلة العائمة تمثيل كثير من الكسور العشرية بدقة، مما يُسبّب أخطاء تقريب صامتة في العمليات الحسابية المالية. يضمن BigDecimal مقترنًا بـ@Digits التحكم في كل من مقياس القيمة المخزنة ودقتها.
هرمية الاستثناءات المخصصة للنطاق
عرّف هرمية استثناءات صغيرة ومركّزة حتى يستطيع المُعالج العالمي المطابقة بالنوع لا بالسلاسل السحرية:
package com.example.catalog.error;
/** الأساس لجميع أخطاء العميل على مستوى النطاق (ستُعيَّن على 4xx). */
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) { super(message); }
}
public class ResourceNotFoundException extends DomainException {
public ResourceNotFoundException(String resource, Object id) {
super(resource + " with id '" + id + "' was not found");
}
}
public class ConflictException extends DomainException {
public ConflictException(String message) { super(message); }
}
طبقة الخدمة — حيث تُرمى استثناءات النطاق
package com.example.catalog.product;
import com.example.catalog.category.CategoryRepository;
import com.example.catalog.error.ConflictException;
import com.example.catalog.error.ResourceNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ProductService {
private final ProductRepository products;
private final CategoryRepository categories;
public ProductService(ProductRepository products, CategoryRepository categories) {
this.products = products;
this.categories = categories;
}
public Product create(CreateProductRequest req) {
if (products.existsBySlug(req.slug())) {
throw new ConflictException("A product with slug '" + req.slug() + "' already exists");
}
var category = categories.findById(req.categoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category", req.categoryId()));
var product = new Product(null, req.name(), req.slug(), req.price(), category);
return products.save(product);
}
public Product findById(Long id) {
return products.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
}
public List<Product> findAll() {
return products.findAll();
}
}
أبقِ التحقق بعيدًا عن طبقة الخدمة. يجب أن تثق طبقة الخدمة بأن المدخلات سليمة هيكليًا — ذلك مسؤولية المتحكم عبر @Valid. قواعد النطاق (كالتفرد) تنتمي إلى الخدمة. هذا الفصل يُبقي كل طبقة مركّزة وقابلة للاختبار بصورة مستقلة.
المتحكم — رفيع ونظيف
package com.example.catalog.product;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping
public List<Product> list() {
return service.findAll();
}
@GetMapping("/{id}")
public Product get(@PathVariable Long id) {
return service.findById(id);
}
@PostMapping
public ResponseEntity<Product> create(
@Valid @RequestBody CreateProductRequest req,
UriComponentsBuilder ucb) {
Product saved = service.create(req);
var location = ucb.path("/api/products/{id}").buildAndExpand(saved.id()).toUri();
return ResponseEntity.created(location).body(saved);
}
}
المُعالج العالمي للاستثناءات
كل ترجمة للأخطاء تعيش في مكان واحد. لا تبني طبقتا المتحكم والخدمة استجابات HTTP للأخطاء أبدًا — بل ترميان استثناءات مُعبَّرًا عنها بأنواع.
package com.example.catalog.error;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.List;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
/** فشل قيود Jakarta Validation من @Valid. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
List<ApiError.FieldViolation> violations = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> new ApiError.FieldViolation(fe.getField(), fe.getDefaultMessage()))
.collect(Collectors.toList());
return ResponseEntity.unprocessableEntity().body(ApiError.validation(violations));
}
/** أخطاء النطاق - الموارد غير الموجودة. */
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiError.of(404, "Not Found", ex.getMessage()));
}
/** أخطاء النطاق - التعارض (مثل تكرار الـ slug). */
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ApiError> handleConflict(ConflictException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiError.of(409, "Conflict", ex.getMessage()));
}
/** شبكة الأمان: أي استثناء غير مُعالَج. */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleAll(Exception ex) {
// سجّل السبب الحقيقي — لا تُعيده للعميل أبدًا
return ResponseEntity.internalServerError()
.body(ApiError.of(500, "Internal Server Error",
"An unexpected error occurred. Please try again later."));
}
}
لا تكشف تتبعات المكدس أو رسائل الاستثناء الداخلية في استجابة 500. تكشف أسماء الحزم وإصدارات المكتبات وأحيانًا استعلامات SQL — وكلها ذات قيمة للمهاجم. سجّل الاستثناء الكامل من جانب الخادم (مع معرّف ارتباط) وأعد للعميل رسالة عامة فقط.
كيف تبدو استجابة الخطأ الحقيقية
طلب POST إلى /api/products باسم مفقود وـ slug غير صالح يُنتج:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"status": 422,
"error": "Unprocessable Entity",
"message": "Validation failed",
"violations": [
{ "field": "name", "message": "Name is required" },
{ "field": "slug", "message": "Slug must be lowercase letters, digits, and hyphens only" }
],
"timestamp": "2024-11-15T09:42:07.831Z"
}
طلب GET إلى /api/products/9999 لمنتج غير موجود يُنتج:
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"status": 404,
"error": "Not Found",
"message": "Product with id '9999' was not found",
"timestamp": "2024-11-15T09:43:11.204Z"
}
المقايضات وقرارات التصميم التي يجب معرفتها
- 422 مقابل 400 لإخفاقات التحقق: يوصي RFC 9110 بـ422 عندما تكون الصياغة صحيحة لكن القيمة غير صالحة دلاليًا. بعض الفرق يفضّل 400 للبساطة. اختر واحدًا وطبّقه باتساق.
- الفشل السريع مقابل التجميع: يجمع
@Valid جميع انتهاكات القيود في تمريرة واحدة ويُعيدها معًا. هذا أفضل تجربة مستخدم دائمًا من إرجاع الأخطاء واحدًا تلو الآخر، لأن العميل يمكنه إصلاح كل شيء في رحلة واحدة.
- التحقق على الـ DTO مقابل الكيان: تحقّق على كائن نقل البيانات. يجب أن تحتوي الكيانات دائمًا على حالة صالحة فقط — انتهاك قيد على الكيان هو خطأ برمجي لا خطأ مستخدم.
- مصادر الرسائل: أخرج رسائل القيود إلى
ValidationMessages.properties عند الحاجة إلى تعدد اللغات. السلاسل المضمّنة مقبولة عندما يكون المشروع صغيرًا.
اختبر مُعالج الأخطاء لا المسار السعيد فقط. اكتب اختبار @WebMvcTest يُرسل هيئة طلب غير صالحة ويؤكد شكل JSON الدقيق للاستجابة. إن تغيّر هيكل ApiError، سيرصد ذلك الاختبار التغيير قبل أن ينكسر عميل في الإنتاج.
الخلاصة
تتعامل الواجهة البرمجية الجاهزة للإنتاج مع الأخطاء في طبقتين مستقلتين: التحقق الهيكلي عند الحدود (قيود على كائنات نقل البيانات يفرضها @Valid) والتحقق من النطاق داخل الخدمة (قواعد الأعمال يفرضها رمي استثناءات مُعبَّر عنها بأنواع). يُعدّ المُعالج العالمي @RestControllerAdvice المترجم الوحيد بين استثناءات Java واستجابات HTTP، مما يضمن أن كل خطأ — سواء أكان فشل تحقق أم عدم إيجاد أم تعارض أم غير متوقع — يصل إلى العميل في نفس غلاف JSON المتوقع. بهذه الأنماط تصبح واجهتك البرمجية صادقة (تُخبر العميل دائمًا بما حدث وكيف يُصلحه) وآمنة (لا تُسرّب الحالة الداخلية أبدًا).