بناء الخدمات المصغّرة بـ Spring Boot

تصميم واجهات برمجة الخدمات وكائنات نقل البيانات

18 دقيقة الدرس 2 من 12

تصميم واجهات برمجة الخدمات وكائنات نقل البيانات

تعيش الخدمة المصغّرة أو تموت تبعًا لجودة عقدها العام. كل نقطة نهاية (endpoint) تعرضها هي وعدٌ لكل خدمة أخرى تعتمد عليك — وعد سيكلّف جهدًا حقيقيًا كسره. يعلّمك هذا الدرس كيفية تصميم ذلك العقد بصورة مدروسة: أي دلالات HTTP تستخدم، وكيف تشكّل أجسام الطلبات والاستجابات باستخدام كائنات نقل البيانات (DTOs)، وكيف تتحقق من صحة المدخلات مبكرًا، وكيف تُصدر إصدارات API حتى لا يتحوّل التطوير إلى كارثة.

لماذا لا تستطيع الاستغناء عن كائنات DTO

الإغراء هو كشف كيان JPA مباشرةً. فهو موجود بالفعل؛ تضيف إليه تعليقات Jackson وتعيده من المتحكّم (controller). لا تفعل ذلك. كيانك مرتبط بمخطط قاعدة البيانات، والعلاقات ذات التحميل الكسول (lazy-loaded)، وتعليقات الثبات (persistence). تسريبه للعالم الخارجي ينشئ اقترانًا في الاتجاهين: يتعطّل كود المُستدعي حين تعيد تسمية عمود قاعدة بيانات، ويصبح مخطط قاعدة بياناتك مقيّدًا بما يتوقعه المستدعون الخارجيون.

الـ DTO كائن Java بسيط (سجل record أو فئة class) مهمته الوحيدة نقل البيانات عبر الحدود. يحتوي تحديدًا على الحقول التي يحتاجها المستدعي — لا أكثر. ولا يحتوي على تعليقات JPA، ولا منطق أعمال، ولا مراجع دائرية تتسبّب في تسلسل JSON لا نهائي.

قاعدة الإبهام: كل حقل تضعه في DTO الاستجابة يصبح جزءًا من عقد API العام. أدرج فقط ما يحتاجه المستدعون فعلًا. يمكنك دائمًا إضافة حقول لاحقًا؛ أما إزالة حقل فهي تغيير كاسر.

سجلات Java كـ DTOs

تُعدّ سجلات Java 16+ الأسلوبَ الأنظف لكتابة DTOs في مشاريع Spring Boot 3 الحديثة. فهي ثابتة (immutable)، وتولّد equals/hashCode/toString تلقائيًا، وتدعمها Jackson بشكل أصلي دون أي إعداد إضافي.

// DTO الاستجابة — يعرض فقط ما يحتاجه المستدعون public record ProductResponse( Long id, String name, String description, java.math.BigDecimal price, String category, boolean available ) {}
// DTO الطلب — ما يجب أن يرسله المستدعي لإنشاء منتج public record CreateProductRequest( String name, String description, java.math.BigDecimal price, String category ) {}

لاحظ أن CreateProductRequest لا يحتوي على حقل id. يعيّن الخادم المعرّف؛ يجب ألا يوفّره المستدعي. هذا قرار عقدي مقصود.

التحقق من صحة الطلبات باستخدام Bean Validation

لا تثق أبدًا بالمستدعي. كل بيانات تصل عبر الشبكة يجب التحقق منها قبل أن تلمس منطق النطاق أو قاعدة البيانات. يدمج Spring Boot 3 التحقق من Jakarta Bean Validation (المعروف سابقًا بـ javax) افتراضيًا عبر تبعية spring-boot-starter-validation.

<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>

أضف قيود التحقق إلى DTO الطلب. ونظرًا لدعم السجلات، استخدم التحقق عبر المنشئ المضغوط:

import jakarta.validation.constraints.*; public record CreateProductRequest( @NotBlank(message = "name is required") @Size(max = 120, message = "name must be 120 characters or fewer") String name, @Size(max = 1000) String description, @NotNull(message = "price is required") @DecimalMin(value = "0.01", message = "price must be positive") java.math.BigDecimal price, @NotBlank String category ) {}

ثم أضف @Valid إلى معامل الطريقة في المتحكّم. سيرفض Spring الطلب بخطأ HTTP 400 تلقائيًا إذا فشلت أي قيد:

import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @PostMapping @ResponseStatus(HttpStatus.CREATED) public ProductResponse create(@Valid @RequestBody CreateProductRequest request) { return productService.create(request); } @GetMapping("/{id}") public ProductResponse findById(@PathVariable Long id) { return productService.findById(id); } }
أعد جسم خطأ منظّم عند فشل التحقق. افتراضيًا تعيد Spring حمولة 400 مطوّلة. تجاوز MethodArgumentNotValidException في @RestControllerAdvice لإعادة شكل خطأ متسق وآمن — لا يسرّب أسماء الحقول الداخلية للمستدعين الخارجيين.

التعيين بين الكيانات وكائنات DTO

يجب على أحدهم الترجمة بين كيان JPA وكائنات DTO الخاصة بك. لديك ثلاثة خيارات: طرق تعيين يدوية في فئة الخدمة، أو طريقة مصنع ثابتة على DTO نفسها، أو مكتبة تعيين مثل MapStruct. بالنسبة لمعظم الخدمات، تكون طريقة مساعدة خاصة ثابتة داخل الخدمة واضحة ولا تتطلب أي تبعيات إضافية:

@Service public class ProductService { private final ProductRepository repository; public ProductService(ProductRepository repository) { this.repository = repository; } public ProductResponse create(CreateProductRequest request) { Product entity = new Product(); entity.setName(request.name()); entity.setDescription(request.description()); entity.setPrice(request.price()); entity.setCategory(request.category()); entity.setAvailable(true); Product saved = repository.save(entity); return toResponse(saved); } public ProductResponse findById(Long id) { Product entity = repository.findById(id) .orElseThrow(() -> new ProductNotFoundException(id)); return toResponse(entity); } // تعيين بحت — بلا آثار جانبية، سهل الاختبار private static ProductResponse toResponse(Product p) { return new ProductResponse( p.getId(), p.getName(), p.getDescription(), p.getPrice(), p.getCategory(), p.isAvailable() ); } }
لا تكشف رسائل الاستثناءات الداخلية لمستدعي API أبدًا. تتبّع مكدس EntityNotFoundException أو رسالة خطأ SQL تخبر المهاجم بمخططك. التقط استثناءات النطاق في @RestControllerAdvice وأعد فقط رسالة عامة آمنة مع رمز حالة HTTP المناسب.

دلالات HTTP مهمة

طريقة HTTP التي تختارها جزء من عقدك. إساءة استخدامها يخلق ارتباكًا ويكسر التخزين المؤقت والوكلاء وأدوات الأمان. اتبع هذه الاتفاقيات بدقة:

  • GET — قراءة فقط. يجب أن تكون آمنة (بلا آثار جانبية) وذات قيمة ثابتة (idempotent). لا تستخدم GET لتشغيل تعديل.
  • POST — إنشاء مورد جديد. ليست ذات قيمة ثابتة؛ قد تنشئ الطلبات المكررة موارد مكررة. أعد 201 Created مع رأس Location يشير إلى المورد الجديد.
  • PUT — استبدال كامل لمورد موجود. ذات قيمة ثابتة. يرسل المستدعي الحالة الجديدة الكاملة.
  • PATCH — تحديث جزئي. استخدمه حين يجب على المستدعين تغيير حقول معينة فقط.
  • DELETE — حذف مورد. ذات قيمة ثابتة. أعد 204 No Content عند النجاح.

استراتيجية إصدار API

تتطوّر الخدمات المصغّرة باستقلالية، والتغييرات الكاسرة تحدث. أكثر استراتيجيات الإصدار عملية للتواصل بين الخدمات هي إصدار مسار URL — ضع الإصدار في المسار الأساسي (مثل /api/v1/products). فهو مرئي، وسهل التوجيه على مستوى البوابة، ولا يحتمل الغموض في السجلات.

القاعدة الأساسية: لا تكسر الإصدارات الموجودة. أضف نقطة نهاية جديدة على /api/v2/products حين تحتاج إلى تغيير العقد. شغّل الإصدارين جنبًا إلى جنب حتى تهاجر جميع المستدعين، ثم أوقف v1 بعد فترة إشعار إهمال.

التغييرات الإضافية غير كاسرة. إضافة حقل اختياري جديد إلى DTO الاستجابة، أو معامل استعلام اختياري جديد، لا تستلزم رفع إصدار — شريطة أن يكون كود المستدعين مكتوبًا لتجاهل الحقول غير المعروفة. تفعل Jackson ذلك افتراضيًا (تُتجاهل الخصائص غير المعروفة بصمت). إزالة حقل أو إعادة تسميته أو تغيير نوعه يستلزم دائمًا إصدارًا جديدًا.

الخلاصة

تصميم API الخدمة الجيد يعني إنشاء عقد مستقر وبسيط وصادق. استخدم سجلات DTO مخصصة لأشكال الطلب والاستجابة؛ لا تكشف كيانات JPA أبدًا. تحقق من صحة جميع البيانات الواردة عند حدود المتحكّم باستخدام @Valid. عيّن بين الكيانات وكائنات DTO في طبقة خدمة رفيعة. استخدم أفعال HTTP الصحيحة ورموز الحالة. ضع إصدارًا لـ API منذ اليوم الأول بادئة /api/v1 حتى لا يتحوّل التطوير إلى طوارئ. في الدرس القادم ستشاهد كيف تستدعي خدمة خدمة أخرى باستخدام WebClient.