معمارية الخدمات المصغّرة وتصميمها

التواصل المتزامن مقابل غير المتزامن

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

التواصل المتزامن مقابل غير المتزامن

في معمارية الخدمات المصغّرة يُعدّ السؤال "كيف تتحدث الخدمات مع بعضها؟" من أكثر قرارات التصميم أثرًا. الخطأ فيه يحوّل النظام إلى مونوليث موزَّع — كل استدعاء يُعطّل، والأعطال تتسلسل، وتضيع مزايا المرونة التي وعدت بها الخدمات المصغّرة. يتناول هذا الدرس الأسلوبين الأساسيين — المتزامن (طلب/استجابة) وغير المتزامن (حدث/رسالة) — ويشرح مقايضاتهما، ويعرض كودًا اصطلاحيًا بـ Spring Boot 3 لكليهما.

التواصل المتزامن: REST وgRPC

في التواصل المتزامن ترسل الخدمة المُستدعِية طلبًا وتنتظر الاستجابة قبل المتابعة. أسلوب REST المستند إلى HTTP الذي تعرفه هو الشكل الأكثر شيوعًا. يُعدّ RestClient (المُقدَّم في Spring 6.1) والأقدم WebClient من WebFlux الأدواتَ المعيارية.

افترض وجود OrderService يحتاج إلى جلب تفاصيل المنتج من ProductService قبل تأكيد الطلب:

// OrderService — تستدعي ProductService بشكل متزامن عبر RestClient @Service public class OrderService { private final RestClient restClient; public OrderService(RestClient.Builder builder, @Value("${services.product.base-url}") String productBaseUrl) { this.restClient = builder.baseUrl(productBaseUrl).build(); } public OrderConfirmation placeOrder(OrderRequest request) { // GET متزامن — الخيط ينتظر هنا ProductDto product = restClient.get() .uri("/api/products/{id}", request.productId()) .retrieve() .body(ProductDto.class); if (product == null || product.stock() < request.quantity()) { throw new InsufficientStockException(request.productId()); } Order order = orderRepository.save(new Order(request, product.price())); return new OrderConfirmation(order.getId(), product.name()); } }

يُعرَّف إعداد عنوان URL الأساسي في application.yml حتى تستطيع البيئات المختلفة (محلي، تجريبي، إنتاج) توصيل عنوان الخدمة الحقيقي أو نموذج اختبار وهمي دون لمس الكود:

# application.yml services: product: base-url: http://product-service:8081
اكتشاف الخدمات: في مجموعة حقيقية لن تُضمّن product-service:8081 مباشرةً. يحلّ Spring Cloud LoadBalancer الاسم المنطقي للخدمة إلى نسخة حية عبر Eureka أو DNS الخاص بـ Kubernetes. يبقى الكود متطابقًا — ويتغير عنوان URL الأساسي فقط إلى http://product-service وتُضاف التعليمة @LoadBalanced إلى الـ bean الخاص بـ RestClient.Builder.

متى يكون التواصل المتزامن منطقيًا

  • تحتاج النتيجة فورًا — مثلًا: استجابة المُستدعي تعتمد على بيانات من خدمة أخرى.
  • الاستدعاء للقراءة فحسب وسريع — جلب سعر المنتج للعرض منخفض الخطورة؛ وإذا فشل يمكن إعادة خطأ أو قيمة مؤقتة.
  • يُشترط التناسق القوي — لا يمكن المتابعة دون معرفة الحالة الراهنة للمورد البعيد.
  • دلالات طلب/استجابة بسيطة — بوابة API تواجه العملاء وتجمّع بيانات من خدمات داخلية تُمثّل حالة استخدام طبيعية.

التكلفة الخفية: الاقتران الزمني

تخلق الاستدعاءات المتزامنة اقترانًا زمنيًا: إذا كانت ProductService بطيئة أو غير متاحة، فإن OrderService ستكون كذلك. في سلسلة من ثلاث قفزات متزامنة بتوافرية 99.9% لكل منها تنخفض التوافرية المركّبة إلى ~99.7%. ومع عشر قفزات تهبط إلى ما دون 99%. أضف تضخيم زمن الاستجابة واستنزاف الخيوط (كل خيط منتظر يحتجز اتصالًا) وسترى لماذا سلاسل المتزامن الصرف هشّة عند الحجم الكبير.

لا تُسلسل الاستدعاءات المتزامنة أبدًا دون مهلة زمنية وقاطع دائرة (circuit breaker). خدمة upstream تتوقف عن الاستجابة — دون إغلاق الاتصال — ستُبقي كل خيط في الخدمة الأدنى رهينةً. Spring Cloud Circuit Breaker (المدعوم بـ Resilience4j) يُغطَّى في درس المرونة؛ طبّقه دائمًا على استدعاءات HTTP بين الخدمات.

التواصل غير المتزامن: المراسلة

في التواصل غير المتزامن ينشر المُرسِل رسالةً (أو حدثًا) في وسيط ويكمل فورًا — دون انتظار معالجة المستقبِل. يستهلك المستقبِل الرسالة بوتيرته الخاصة. هذا يفصل الخدمات زمنيًا: لا يهتم المُرسِل بما إذا كان المستقبِل يعمل أو مشغولًا أو يُعاد نشره.

يتكامل Spring Boot مع Apache Kafka عبر spring-kafka ومع RabbitMQ (AMQP) عبر spring-boot-starter-amqp. في المثال التالي ينشر OrderService حدث OrderPlaced، وتستهلكه InventoryService المستقلة لتقليل المخزون.

أضف التبعية:

<!-- pom.xml --> <dependency> <groupId>org.springframework.kafka</groupId> <artifactId>spring-kafka</artifactId> </dependency>

عرِّف سجلّ الحدث (record في Java 16+ — ثابت حكمًا):

public record OrderPlacedEvent(String orderId, String productId, int quantity, Instant occurredAt) {}

النشر من OrderService:

@Service public class OrderService { private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate; private final OrderRepository orderRepository; // حقن المنشئ مُختصر للإيجاز public String placeOrder(OrderRequest request) { Order order = orderRepository.save(new Order(request)); OrderPlacedEvent event = new OrderPlacedEvent( order.getId(), request.productId(), request.quantity(), Instant.now()); // غير محجوب: يعود فورًا؛ Kafka يُسلّم بشكل غير متزامن kafkaTemplate.send("orders.placed", order.getId(), event); return order.getId(); } }

الاستهلاك في InventoryService:

@Component public class InventoryEventHandler { private final InventoryRepository inventoryRepository; @KafkaListener(topics = "orders.placed", groupId = "inventory-service") public void onOrderPlaced(OrderPlacedEvent event) { inventoryRepository.decrementStock(event.productId(), event.quantity()); log.info("تم تقليل المخزون للمنتج {} بمقدار {}", event.productId(), event.quantity()); } }

يُهيَّأ وسيط Kafka واسم الموضوع واستراتيجية التسلسل في application.yml:

spring: kafka: bootstrap-servers: kafka:9092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer consumer: key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer properties: spring.json.trusted.packages: "com.example.events"
استخدم موضوع الرسائل الميتة (DLT). إذا رمى onOrderPlaced استثناءً، يمكن ضبط DefaultErrorHandler في Spring Kafka على إعادة المحاولة عددًا محددًا من المرات ثم إعادة توجيه الرسالة الفاشلة إلى موضوع orders.placed.DLT. بدون DLT، ستُوقف رسالة "سمّ الاستهلاك" المستهلكَ إلى الأبد.

متى يكون التواصل غير المتزامن منطقيًا

  • سير عمل "أطلق وانسَ" — إرسال بريد تأكيد، تحديث فهرس البحث، إخطار التحليلات. لا يحتاج أي منها إلى تعطيل طلب المستخدم.
  • التناسق النهائي مقبول — سيُحدَّث المخزون بعد وقت قصير من وضع الطلب؛ التأخر البسيط مقبول.
  • الحمل العالي أو المتقطع — يمتص الوسيط الارتفاعات المفاجئة؛ يعالج المستهلكون بوتيرة ثابتة.
  • العمليات طويلة الأمد — ترميز الفيديو، إنشاء التقارير، ملفات PDF للفواتير — العمل الذي يستغرق ثوانٍ أو دقائق لا ينبغي أن يحجب خيط HTTP.
  • المروحة (Fan-out) — حدث OrderPlaced واحد يُحفّز المخزون والفوترة والتنفيذ والتحليلات باستقلالية، دون أن تعرف OrderService عن أي منها.

الآثار الأمنية

لكلا الأسلوبين مخاوف أمنية مميزة يسهل إغفالها في سياق الخدمات المصغّرة.

المتزامن (HTTP): مرّر JWT الخاص بالمُستدعي إلى الخدمات الداخلية باستخدام ClientHttpRequestInterceptor على RestClient. لا تُعيد إصدار رمز على مستوى الخدمة يحمل صلاحيات أوسع مما يمتلكه المستخدم الأصلي — هذا ثغرة تصعيد صلاحيات.

// انقل رمز Bearer من الطلب الوارد إلى الاستدعاءات الصادرة @Bean public RestClient.Builder restClientBuilder( ObjectProvider<HttpServletRequest> requestProvider) { return RestClient.builder() .requestInterceptor((request, body, execution) -> { HttpServletRequest incoming = requestProvider.getIfAvailable(); if (incoming != null) { String auth = incoming.getHeader(HttpHeaders.AUTHORIZATION); if (auth != null) request.getHeaders().set(HttpHeaders.AUTHORIZATION, auth); } return execution.execute(request, body); }); }

غير المتزامن (Kafka/RabbitMQ): الوسيط مورد شبكي — تعامل معه كقاعدة بيانات. استخدم TLS للنقل وSASL/SCRAM أو mTLS للمصادقة وقوائم ACL حتى تستطيع OrderService فحسب إنتاج الرسائل في مواضيع orders.* دون استهلاكها أو إدارتها. أدرج correlationId ومُطالبة userId بسيطة في كل حمولة حدث لأغراض تسجيل التدقيق، لكن لا تضمّن رموز JWT الكاملة — فهي تنتهي صلاحيتها ولم تُصمَّم للتخزين.

الاختيار بين الأسلوبين: إطار اتخاذ القرار

  • اسأل: هل يحتاج المُستدعي الإجابة للمتابعة؟ نعم ← متزامن. لا ← غير متزامن.
  • اسأل: هل يمكن للمستقبِل أن يكون غير متاح مؤقتًا؟ يجب أن يكون متاحًا دائمًا ← متزامن مع قاطع دائرة. يمكنه اللحاق لاحقًا ← غير متزامن.
  • اسأل: كم خدمة تهتم بهذا الحدث؟ خدمة واحدة محددة ← استدعاء متزامن. خدمات عديدة ← انشر حدثًا ودعها تشترك.
  • معظم الأنظمة الحقيقية تستخدم الأسلوبين معًا: بوابة REST للتفاعل المواجه للمستخدم، مع أحداث مروحية داخلية للتأثيرات الجانبية.
نمط Saga (يُغطّى في دروس لاحقة) ينسّق المعاملات التجارية متعددة الخطوات عبر الخدمات باستخدام تنسيق من الأحداث غير المتزامنة — تنشر كل خدمة حدث نجاح أو فشل وتتفاعل الخطوة التالية وفقًا لذلك. إنه الإجابة المعيارية للمعاملات الموزَّعة، ولا يمكن تحقيقه إلا بفضل المراسلة غير المتزامنة.

الخلاصة

استدعاءات REST/gRPC المتزامنة بسيطة وتُقدّم استجابات فورية لكنها تخلق اقترانًا زمنيًا وخطر الأعطال المتسلسلة. المراسلة غير المتزامنة عبر Kafka أو RabbitMQ تفصل الخدمات زمنيًا وتمتص ارتفاعات الحمل وتُتيح أنماط المروحة، بتكلفة التناسق النهائي والبنية التحتية الإضافية. يدعم Spring Boot 3 كلا الأسلوبين بتكاملات من الدرجة الأولى: RestClient لـ HTTP المتزامن وspring-kafka / spring-amqp للمراسلة. يعتمد الخيار الصحيح على ما إذا كان المُستدعي يحتاج الإجابة فورًا، وعدد المستهلكين، وضمانات التناسق التي يتطلبها العمل.