المرونة والمراسلة والرصد

الخدمات المصغّرة المدفوعة بالأحداث

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

الخدمات المصغّرة المدفوعة بالأحداث

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

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

الأحداث مقابل الأوامر مقابل الاستعلامات

قبل كتابة الكود يستحقّ الأمر التمييز بين ثلاثة أشكال للرسائل:

  • الأمر (Command) — توجيه موجَّه لخدمة بعينها: "عالِج الدفع للطلب 42." المُرسِل يعرف المستقبِل ويتوقع منه أن يتصرف.
  • الاستعلام (Query) — طلب للبيانات: "ما رصيد الحساب 7؟" متزامن ويحمل استجابة.
  • الحدث (Event) — إشعار بأن شيئًا ما قد وقع: "تم تقديم الطلب 42." لا يُملي الناشر ما يحدث بعد ذلك. الأحداث وقائع ثابتة بصيغة الماضي.

الأحداث هي لبنة البناء في الخدمات المصغّرة المدفوعة بالأحداث. صمّم أسماء أحداثك بصيغة الماضي (OrderPlaced، PaymentSucceeded، InventoryReserved) ودَع المستهلِكين يقرّرون باستقلالية كيفية التفاعل.

حافلة الأحداث في Spring (داخل العملية الواحدة)

يأتي Spring مزوّدًا بآلية أحداث متزامنة داخل العملية الواحدة مضمّنة في ApplicationContext. هي ليست وسيط رسائل — فالأحداث لا تعبر حدود العمليات — لكنها طريقة نظيفة لفصل المكوّنات داخل خدمة واحدة وتعلّم النمط قبل إضافة البنية التحتية.

// الحدث — سجل Java بسيط (Spring Boot 3) public record OrderPlacedEvent(Long orderId, String customerId, java.math.BigDecimal total) {} // الناشر — أي bean في Spring @Service @RequiredArgsConstructor public class OrderService { private final ApplicationEventPublisher publisher; private final OrderRepository repo; @Transactional public Order placeOrder(CreateOrderRequest req) { Order order = repo.save(Order.from(req)); publisher.publishEvent(new OrderPlacedEvent(order.getId(), order.getCustomerId(), order.getTotal())); return order; } } // المستهلِك — أي bean في Spring @Component public class NotificationListener { @EventListener public void onOrderPlaced(OrderPlacedEvent event) { System.out.println("إرسال بريد تأكيد للعميل " + event.customerId()); } }

ضَع تعليق @EventListener على طريقة المستمع وسيحقن Spring الحدث بالنوع. أضف @Async معه لتنفيذ المستمع في مجموعة مؤشرات ترابط منفصلة — لكن لاحظ أنه مع الأحداث غير المتزامنة داخل العملية قد يُفقد الحدث غير المنشور عند تعطّل الناشر.

عبور حدود العمليات بوسيط الرسائل

للخدمات المصغّرة الحقيقية تحتاج الأحداث إلى التنقّل بين عمليات JVM مختلفة. يعمل وسيط الرسائل (RabbitMQ أو Apache Kafka أو AWS SNS/SQS) بوصفه وسيطًا متينًا. يوفّر Spring Cloud Stream نموذجًا برمجيًا موحّدًا يعمل مع أي رابط مدعوم.

أضف رابط RabbitMQ إلى pom.xml:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-stream-rabbit</artifactId> </dependency>

مع Spring Cloud Stream تُنمذَج الرسائل كدوال Java عادية. المورّد (Supplier) ينتج الأحداث؛ والمستهلِك (Consumer) يتفاعل معها؛ والدالة (Function) تحوّلها. يربط Spring Cloud Stream هذه الدوال بقنوات الوسيط عبر الإعداد.

// في OrderService استخدم StreamBridge للنشر الأمري: @Service @RequiredArgsConstructor public class OrderService { private final StreamBridge streamBridge; private final OrderRepository repo; @Transactional public Order placeOrder(CreateOrderRequest req) { Order order = repo.save(Order.from(req)); streamBridge.send("order-placed-out-0", new OrderPlacedEvent(order.getId(), order.getCustomerId(), order.getTotal())); return order; } }
# application.yml — order-service spring: cloud: stream: bindings: order-placed-out-0: destination: orders.placed # اسم التبادل / الموضوع على الوسيط content-type: application/json

تُعلن الخدمة المستهلِكة عن bean بسيطة من نوع Consumer<T>:

// خدمة المستهلِك — notification-service @Configuration public class OrderEventHandlers { @Bean public Consumer<OrderPlacedEvent> handleOrderPlaced() { return event -> { // إرسال بريد إلكتروني أو إشعار دفع، إلخ. System.out.println("إشعار العميل " + event.customerId() + " للطلب " + event.orderId()); }; } }
# application.yml — notification-service spring: cloud: stream: bindings: handleOrderPlaced-in-0: destination: orders.placed # يجب أن يطابق وجهة المنتِج group: notification-service # مجموعة المستهلِكين — معالجة كل رسالة بواسطة نسخة واحدة فقط content-type: application/json
عيّن دائمًا group لروابط المستهلِكين. بدون مجموعة تستقبل كل نسخة من الخدمة كل رسالة (دلالات البثّ). مع مجموعة، يوزّع الوسيط الحِمل بين النسخ — وهذا تمامًا ما تريده لطوابير العمل.

نمط صندوق الصادر — ضمان التسليم مرة واحدة على الأقل

النمط المضاد الأشدّ خطورة في الأنظمة المدفوعة بالأحداث هو نشر حدث بعد إيداع معاملة قاعدة البيانات في خطوتين منفصلتين. إذا تعطّلت الخدمة بين الإيداع ومكالمة الوسيط فُقد الحدث للأبد — طلب وهمي بلا إشعار.

يحلّ نمط صندوق الصادر المعاملاتي (Transactional Outbox) هذه المشكلة: اكتب الحدث في جدول outbox ضمن نفس معاملة قاعدة البيانات التي تحتوي البيانات التجارية. يقرأ برنامج استطلاع منفصل (أو Debezium CDC) الصفوف غير المعالَجة وينشرها في الوسيط ثم يُعلّمها كمُرسَلة.

// 1. في نفس طريقة @Transactional احفظ التجميع وصف صندوق الصادر معًا: @Transactional public Order placeOrder(CreateOrderRequest req) { Order order = repo.save(Order.from(req)); OutboxEvent outbox = new OutboxEvent(); outbox.setAggregateType("Order"); outbox.setEventType("OrderPlaced"); outbox.setPayload(serialize(new OrderPlacedEvent(order.getId(), order.getCustomerId(), order.getTotal()))); outboxRepo.save(outbox); // نفس المعاملة — ذري! return order; } // 2. برنامج استطلاع @Scheduled (أو Debezium) يقرأ صندوق الصادر وينشر: @Scheduled(fixedDelay = 500) @Transactional public void publishPendingEvents() { outboxRepo.findTop100ByPublishedFalse().forEach(e -> { streamBridge.send("order-placed-out-0", e.getPayload()); e.setPublished(true); }); }
المستهلِكون المتكاملون الوظيفة غير قابلين للتفاوض. يضمن صندوق الصادر التسليم مرة واحدة على الأقل — فعطل أثناء الاستطلاع قد يُعيد تشغيل الحدث نفسه. يجب على كل مستهلِك الكشف عن التكرارات وتجاهلها (تخزين معرّفات الأحداث المعالَجة في جدول إلغاء التكرار، أو استخدام عمليات مضمونة الوحدانية كـ upsert).

إصدارات الأحداث وتطوّر المخطّط

الأحداث واجهة برمجية عامة. قد يعتمد مستهلِكون لا تتحكّم فيهم على شكل OrderPlacedEvent. تطوير هذا الشكل دون كسر المستهلِكين يتطلّب انضباطًا:

  • التغييرات الإضافية آمنة — أضف حقولًا اختيارية؛ يتجاهل المستهلِكون الحقول غير المعروفة (مع إعداد Jackson الافتراضي FAIL_ON_UNKNOWN_PROPERTIES = false في Spring Boot).
  • حذف الحقول أو إعادة تسميتها يكسر التوافق — افصلها أولًا وانتظر حتى تُهاجر جميع المستهلِكين، ثم احذفها.
  • إصدار أنواع أحداثك — استخدم حقل version أو وجّه التغييرات الكاسِرة إلى موضوع جديد (orders.placed.v2).

اعتبارات الأمان

للأنظمة المدفوعة بالأحداث سطح هجوم أوسع من استدعاءات REST المباشرة:

  • اعتمد هوية المنتِجين — هيّئ SASL/TLS على الوسيط. أي شخص يستطيع الكتابة في orders.placed يستطيع حقن أحداث احتيالية.
  • تحقّق من كل حدث — عامِل الرسائل الواردة بنفس الحذر الذي تعامل به طلبات HTTP. حمولة مشوّهة قد تُعطّل المستهلِك؛ وحمولة محقونة قد تُفسد البيانات.
  • لا تضمّن البيانات الحساسة — تجنّب وضع معلومات تعريف شخصية أو بيانات بطاقات الدفع في الأحداث. انشر مرجعًا (معرّف الطلب) ودَع المستهلِك يجلب التفاصيل الحساسة من الخدمة المالكة عبر استدعاء API مؤمَّن.
  • انقل سياق التتبع — ضمّن ترويسة W3C traceparent في بيانات وصف الرسالة حتى يستطيع التتبع الموزّع (المُغطَّى في الدرس 8) ربط التدفق الكامل عبر الخدمات.

الخلاصة

تستبدل الخدمات المصغّرة المدفوعة بالأحداث الاستدعاءات المتزامنة الهشّة بأحداث متينة يتوسّط فيها الوسيط. داخل الخدمة استخدم ApplicationEventPublisher و@EventListener من Spring لفصل المكوّنات. عبر الخدمات يوفّر Spring Cloud Stream مع رابط RabbitMQ أو Kafka نموذجًا قائمًا على الدوال حيث تكتب beans Java عادية من نوع Supplier وFunction وConsumer. استخدم نمط صندوق الصادر المعاملاتي لضمان التسليم مرة واحدة على الأقل دون إخفاق الكتابة المزدوجة. صمّم المستهلِكين ليكونوا مضمونَي الوحدانية، وطوّر مخطّطات أحداثك بصورة إضافية، وأمّن وصول الوسيط دائمًا. في الدرس 7 ستتعمّق في دلالات البثّ اللوغاريتمي في Kafka.

ES
Edrees Salih
منذ ساعة

We are still cooking the magic in the way!