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

التعيين والعقود

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

التعيين والعقود

تكشف الخدمة المصغّرة واجهتها العامة عبر كائنات نقل البيانات (DTOs)، وتستهلك خدمات أخرى من خلالها كذلك. الفجوة بين تلك الأشكال العامة وكيانات JPA الداخلية التي تقف خلفها يجب أن تُجسَر بعناية — هذا الجسر هو تعيين الكائنات (object mapping). إتقانه يحدد استقرار عقد خدمتك وأمان بياناتك وقابلية صيانة قاعدة كودك. يغطّي هذا الدرس التقنيات التي يحتاجها كل مطوّر Spring Boot: المعيّنات المكتوبة يدويًا، والأدوات المساعدة، ومقدمة لـ MapStruct للفرق التي تريد الأمان وقت الترجمة على نطاق واسع.

لماذا يجب أن لا تكشف الكيانات مباشرةً

الاختصار الأكثر شيوعًا في الكود المبكّر للخدمات المصغّرة هو إعادة كيانات JPA مباشرةً من @RestController. هذا يُنشئ ثلاث مشاكل خطيرة:

  • تسرّب البيانات الأمنية: غالبًا ما تحمل الكيانات حقولًا لا يجب أن تغادر الخدمة — تجزئات كلمات المرور، وأعمدة التدقيق الداخلية، ومعرّفات المفاتيح الخارجية التي تكشف مخطّطك. حقل واحد مضاف إلى الكيان يصبح بصمت جزءًا من الواجهة البرمجية العامة.
  • الاقتران الوثيق: أي مستهلك لواجهتك البرمجية يصبح الآن معتمدًا على نموذج بياناتك الداخلي. إعادة تسمية عمود في قاعدة البيانات يُفسد كل عميل دون أي تحذير وقت الترجمة.
  • آثار جانبية للتسلسل: ترابطات JPA المُحمَّلة بشكل كسول تُطلق استعلامات N+1 حين يحاول Jackson تسلسلها، أو ما هو أسوأ، تُرمى LazyInitializationException خارج حدود المعاملة.
لا تسلسل كيان JPA في استجابة REST أبدًا. إن استطاع Jackson رؤية مجموعة @OneToMany، فسيحاول تحميلها. ضع على فئة الكيان @JsonIgnoreProperties({"hibernateLazyInitializer","handler"}) كشبكة أمان، لكن استخدم DTO دائمًا بدلًا منه.

طبقة DTO — شكلها

كائن نقل البيانات (DTO) هو فئة Java بسيطة تحمل بالضبط الحقول التي يحتاجها المستهلك، مسمّاةً بمصطلحاته. تستخدم فرق Spring Boot الحديثة records Java لكائنات DTO غير قابلة للتغيير لأنها موجزة وتتسلسل بشكل نظيف مع Jackson:

// الكيان — داخلي، يمتلكه طبقة المثابرة @Entity @Table(name = "orders") public class OrderEntity { @Id private Long id; private String customerId; private BigDecimal totalAmount; private String internalCostCentreCode; // لا تكشفه أبدًا private Instant createdAt; private Instant lastModifiedAt; @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) private List<OrderLineEntity> lines; // getters / setters محذوفة }
// DTO الاستجابة — عقد عام، آمن للتسلسل public record OrderResponse( String orderId, // مشفّر، ليس المفتاح الأساسي الخام String customerId, BigDecimal total, List<OrderLineResponse> lines, Instant createdAt ) {} public record OrderLineResponse( String productId, int quantity, BigDecimal unitPrice ) {}

لاحظ أن internalCostCentreCode و lastModifiedAt غائبان كليًا. يُحوَّل id النوع Long الخام إلى سلسلة مشفّرة قبل أن تغادر الخدمة. هذه القرارات تُتّخذ في المعيّن وليس في الكيان.

المعيّنات المكتوبة يدويًا — الأسلوب الأساسي

المعيّن المكتوب يدويًا هو @Component Spring بسيط يحمل منطق التحويل الصريح. هو الافتراضي الصحيح للحالات البسيطة ويمنحك تحكمًا كاملًا في التحويل:

import org.springframework.stereotype.Component; import java.util.List; @Component public class OrderMapper { private final HashIdService hashIdService; public OrderMapper(HashIdService hashIdService) { this.hashIdService = hashIdService; } public OrderResponse toResponse(OrderEntity entity) { List<OrderLineResponse> lineResponses = entity.getLines().stream() .map(this::toLineResponse) .toList(); return new OrderResponse( hashIdService.encode(entity.getId()), // إخفاء المفتاح الأساسي الخام entity.getCustomerId(), entity.getTotalAmount(), lineResponses, entity.getCreatedAt() ); } private OrderLineResponse toLineResponse(OrderLineEntity line) { return new OrderLineResponse( hashIdService.encode(line.getProductId()), line.getQuantity(), line.getUnitPrice() ); } // الاتجاه الداخلي: طلب DTO → كيان جديد (بلا id ولا حقول تدقيق) public OrderEntity toEntity(CreateOrderRequest request) { OrderEntity entity = new OrderEntity(); entity.setCustomerId(request.customerId()); // الأسطر تُعيَّن بشكل منفصل، الإجمالي يُحسب في طبقة الخدمة return entity; } }
أبقِ منطق الأعمال خارج المعيّنات. حسابات الأسعار والتحقق من الصحة وفحوصات الترخيص تنتمي إلى طبقة الخدمة. يجب أن يترجم المعيّن أسماء الحقول وأنواعها فحسب. إن كبر متن دالة المعيّن إلى ما يتجاوز ~15 سطرًا، استخرج المنطق إلى الخدمة ومرّر كائنًا أكثر ثراءً إلى المعيّن.

تعيين الطلبات الواردة بأمان

يستلزم التعيين من DTO وارد إلى كيان عناية إضافية. يجب ألا يُتاح للعميل مطلقًا تعيين الحقول التي يتحكّم فيها الخادم — كـ id و createdAt وحقل حالة داخلي. تجاهل الخصائص غير المعروفة أمر بالغ الأهمية أيضًا لتجنّب كسر العملاء الحاليين عند إضافة حقول جديدة:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties; // DTO للطلب الوارد — الحقول التي يُسمح للمُستدعي بتوفيرها فحسب @JsonIgnoreProperties(ignoreUnknown = true) public record CreateOrderRequest( @NotBlank String customerId, @NotEmpty List<OrderLineRequest> lines ) {}

استخدام @JsonIgnoreProperties(ignoreUnknown = true) على كائنات DTO للطلبات هو عقد التوافق مع الإصدارات السابقة: إن أرسل العميل حقولًا إضافية (ربما لأنه يتحدّث مع إصدار أحدث من خدمتك)، تُحذف بصمت بدلًا من إحداث خطأ 400.

اعتبار الأنظمة الموزّعة — تطوّر المخطّط

في شبكة الخدمات المصغّرة، تُنشر الخدمات بشكل مستقل. قد تعمل خدمة الطلبات لديك على الإصدار 1.2 بينما خدمة المخزون التي تستهلك أحداثها لا تزال على الإصدار 1.1. هذا يعني أن حقول DTO لا يمكن إعادة تسميتها أو حذفها بأمان؛ بل يمكن فقط إضافتها. المستهلكون الذين يستخدمون @JsonIgnoreProperties(ignoreUnknown = true) سيتجاهلون الحقول الجديدة بشكل لطيف. هذا المبدأ — كن محافظًا فيما ترسل، ومرنًا فيما تقبل — يُسمى أحيانًا قانون Postel وهو أساس تصميم واجهات برمجية متوافقة مع الإصدارات السابقة.

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

مقدمة إلى MapStruct

تعمل المعيّنات المكتوبة يدويًا بشكل جيد إلى حدٍّ معيّن. بمجرد أن تمتلك الخدمة عشرات من أزواج الكيانات وكائنات DTO، يصبح الكود المتكرّر مملًّا وعرضةً للأخطاء. MapStruct هو معالج تعليقات توضيحية يُولّد تطبيقات المعيّنات وقت الترجمة — بلا انعكاس ولا عبء وقت التشغيل، مع دعم كامل لـ IDE وأمان الأنواع.

أضف التبعية إلى pom.xml:

<dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>1.5.5.Final</version> </dependency> <!-- معالج التعليقات التوضيحية — مطلوب لتوليد الكود --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <annotationProcessorPaths> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>1.5.5.Final</version> </path> </annotationProcessorPaths> </configuration> </plugin>

عرّف واجهة معيّن — MapStruct يُولّد التطبيق:

import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; @Mapper(componentModel = MappingConstants.ComponentModel.SPRING) public interface ProductMapper { // أسماء الحقول متطابقة — يُولَّد تلقائيًا ProductResponse toResponse(ProductEntity entity); // تجاهل الحقول التي يجب ألا يعيّنها المُستدعي @Mapping(target = "id", ignore = true) @Mapping(target = "createdAt", ignore = true) ProductEntity toEntity(CreateProductRequest request); }

مع componentModel = SPRING، يُسجّل MapStruct الفئة المُولَّدة كحبّة Spring bean، فيمكنك حقنها مثل أي @Component أخرى. حين تختلف أسماء الحقول في المصدر والهدف، استخدم @Mapping(source = "entityField", target = "dtoField").

استخدم MapStruct لأي خدمة تضمّ أكثر من خمسة أزواج كيان/DTO. الميزة الرئيسية ليست فقط قلة الكود — بل أن إعادة تسمية حقل مصدر يُنتج خطأ وقت الترجمة بدلًا من قيمة null صامتة في الإنتاج. عامل الكود المُولَّد على أنه للقراءة فقط؛ واجهة المعيّن هي مصدر الحقيقة الوحيد.

الصورة الكاملة — طبقة الخدمة

تُنسّق طبقة الخدمة: تستدعي المستودع، وتمرّر الكيان عبر المعيّن، وتُعيد كائن DTO. يظل المتحكّم نحيلًا:

@RestController @RequestMapping("/orders") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @GetMapping("/{hashId}") public ResponseEntity<OrderResponse> getOrder(@PathVariable String hashId) { return ResponseEntity.ok(orderService.findByHashId(hashId)); } @PostMapping public ResponseEntity<OrderResponse> createOrder( @Valid @RequestBody CreateOrderRequest request) { OrderResponse created = orderService.create(request); URI location = URI.create("/orders/" + created.orderId()); return ResponseEntity.created(location).body(created); } }
@Service @Transactional public class OrderService { private final OrderRepository repository; private final OrderMapper mapper; private final HashIdService hashIdService; // حقن المنشئ محذوف للإيجاز public OrderResponse findByHashId(String hashId) { Long id = hashIdService.decode(hashId); OrderEntity entity = repository.findById(id) .orElseThrow(() -> new EntityNotFoundException("Order not found: " + hashId)); return mapper.toResponse(entity); // كيان → DTO داخل المعاملة } public OrderResponse create(CreateOrderRequest request) { OrderEntity entity = mapper.toEntity(request); OrderEntity saved = repository.save(entity); return mapper.toResponse(saved); } }

يحدث تحويل الكيان إلى DTO داخل حدود المعاملة في دالة الخدمة. هذا مهم: إن وصل المعيّن إلى مجموعة كسولة التحميل، فستكون ضمن جلسة نشطة ولن تُرمى LazyInitializationException.

الخلاصة

طبقة التعيين هي حارس عقد خدمتك. أبقِ الكيانات داخلية صارمًا وعرّف كائنات DTO تمثّل الواجهة البرمجية العامة التي أنت مستعد للحفاظ عليها. المعيّنات المكتوبة يدويًا تمنحك تحكمًا كاملًا للحالات البسيطة؛ وMapStruct يُوسّع هذا النهج لقواعد الكود الكبيرة بأمان وقت الترجمة. لا تسمح أبدًا للمُستدعين بتعيين الحقول التي يتحكّم فيها الخادم، وعلّم دائمًا كائنات DTO للطلبات بـ @JsonIgnoreProperties(ignoreUnknown = true)، وعامل كل حقل DTO منشور باعتباره التزامًا يخضع للإصدارات. هذه العادات تمنع الكسور الموزّعة الصامتة التي يصعب تشخيصها في نظام الخدمات المصغّرة.