يا جماعة الخير، السلام عليكم ورحمة الله وبركاته. معكم أخوكم أبو عمر.
قبل كم سنة، كنا شغالين على مشروع كبير، منصة تجارة إلكترونية. في البداية، الأمور كانت “آخر حلاوة”. خدمات قليلة، كل شيء واضح. كان عنا خدمة للمستخدمين (Users)، وخدمة للمنتجات (Products)، وخدمة للطلبات (Orders). لما مستخدم جديد يسجل، كانت خدمة المستخدمين تتواصل مباشرة مع خدمة الإيميلات عشان تبعتله إيميل ترحيب. ولما يصير طلب جديد، خدمة الطلبات كانت تكلم خدمة المخزون عشان تنقّص المنتج، وتكلم خدمة الدفع عشان تعالج الفلوس، وتكلم خدمة الشحن عشان تجهز الشحنة. كنا مبسوطين على حالنا، شغل مرتب وسريع.
لكن مع الوقت، المشروع كبر. أضفنا خدمة تحليلات (Analytics)، وخدمة توصيات (Recommendations)، وخدمة ولاء العملاء (Loyalty). وهنا “ولعت الدنيا”. صارت خدمة الطلبات لازم تكلم خمس خدمات ثانية عشان تخلص شغلها. لو خدمة الإيميلات عطّلت شوي، عملية الطلب كلها بتوقف! لو بدنا نغير شغلة بسيطة في خدمة المخزون، لازم نعدّل ونختبر خدمة الطلبات وكل الخدمات اللي بتتعامل معها. صرنا زي اللي ماسك صحن فيه 10 كاسات شاي وبيمشي على حبل رفيع، أي هزة صغيرة وكل شيء بيوقع. الاجتماعات صارت كلها صراخ ومشاكل: “ليش خدمتكم عطلانة؟”، “مين اللي عمل push للكود وخرّب الدنيا؟”.
في ليلة من الليالي، وأنا قاعد بصلّح مشكلة للساعة 3 الفجر سببها إن خدمة بسيطة وقفت فسببت شلل كامل للنظام، قلت لحالي: “خلص، بكفي! لازم نلاقي حل جذري لهي الفوضى”. ومن هنا بدأت رحلتنا مع ما يسمى بـ “المعمارية القائمة على الأحداث” أو Event-Driven Architecture.
ما هو جحيم “الاقتران المحكم” (Tightly Coupled)؟
قبل ما نحكي عن الحل، خلينا نفهم أصل المشكلة. المشكلة اللي كنا فيها اسمها “الاقتران المحكم”. تخيل معي إن كل خدمة في نظامك مرتبطة بالثانية بحبل قصير وقوي. إذا خدمة (أ) بدها معلومة من خدمة (ب)، بتتصل فيها مباشرة وبتستنى الرد. هذا اسمه اتصال متزامن (Synchronous Communication).
هذا الأسلوب بسيط في البداية، لكنه كارثي على المدى الطويل للأسباب التالية:
- قلة الصمود (Low Resilience): إذا تعطلت خدمة واحدة (مثلاً خدمة إرسال الإيميلات)، كل الخدمات التي تعتمد عليها بشكل مباشر ستتعطل أو ستفشل عملياتها. هذا ما يسمى بـ “الفشل المتتالي” (Cascading Failures).
- بطء التطوير (Slow Development): أي تغيير في خدمة (ب) قد يتطلب تغييرات في خدمة (أ) وكل الخدمات التي تتصل بها. هذا يجعل إضافة ميزات جديدة أو تعديل الميزات الحالية عملية مؤلمة وبطيئة.
- صعوبة التوسع (Difficult to Scale): إذا كانت خدمة الطلبات تتلقى ضغطًا كبيرًا، فستقوم بالضغط على كل الخدمات التي تتصل بها (المخزون، الدفع، الشحن). قد لا تحتاج خدمة الشحن إلى التوسع بنفس القدر، لكنها مجبرة على التعامل مع كل الطلبات القادمة من خدمة الطلبات.
باختصار، النظام بصير زي بيت العنكبوت، أي لمسة في أي مكان بتهز البيت كله.
المنقذ: المعمارية القائمة على الأحداث (Event-Driven Architecture)
المعمارية القائمة على الأحداث (EDA) هي نمط معماري يقلب الطاولة تمامًا. الفكرة الأساسية بسيطة جدًا: الخدمات لا تتحدث مع بعضها البعض مباشرة.
كيف يعني؟ بدلًا من أن تقوم خدمة (أ) بالاتصال مباشرة بخدمة (ب)، تقوم خدمة (أ) بالإعلان عن “حدث” (Event) معين قد وقع. ثم تقوم الخدمات الأخرى المهتمة بهذا الحدث بالاستماع له والتفاعل معه، كل واحدة على حدة وبشكل مستقل.
أفضل تشبيه هو “لوحة الإعلانات” في الجامعة. قسم القبول والتسجيل لا يتصل بكل طالب ليخبره بنتيجة القبول. بل يقوم بنشر إعلان على اللوحة (هذا هو الحدث). الطلاب المهتمون (المستهلكون) يذهبون إلى اللوحة ويقرأون الإعلان ويتصرفون بناءً عليه. قسم القبول لا يعرف، ولا يهتم، بمن قرأ الإعلان. هو فقط أعلن عن حدوث شيء مهم.
الأركان الأساسية لـ EDA
هذه المعمارية تقوم على ثلاثة أركان رئيسية:
- الحدث (Event): هو سجل لحقيقة أو تغيير مهم في حالة النظام. على سبيل المثال:
OrderPlaced,UserRegistered,PaymentFailed. الحدث هو شيء حدث في الماضي، ولا يمكن تغييره. عادة ما يحتوي على معلومات حول ما حدث. - مُنتِج الحدث (Event Producer): هو الخدمة أو المكون الذي يكتشف وقوع الحدث ويقوم بإنشائه وإرساله إلى “وسيط الأحداث”.
- مُستهلِك الحدث (Event Consumer): هو الخدمة أو المكون الذي يشترك (subscribes) في أنواع معينة من الأحداث. عندما يتلقى حدثًا يهمه، يقوم بتنفيذ منطق معين.
- وسيط الأحداث (Event Broker / Message Broker): هو “لوحة الإعلانات” أو ساعي البريد. هو نظام وسيط يستقبل الأحداث من المنتجين ويقوم بتوجيهها إلى المستهلكين المهتمين. من أشهر الأمثلة: Apache Kafka, RabbitMQ, AWS SQS/SNS, Google Pub/Sub.
مثال عملي: من الفوضى إلى النظام
دعنا نعود إلى مثال “تسجيل مستخدم جديد” في منصتنا لنرى الفرق بشكل عملي.
الطريقة القديمة (الاقتران المحكم)
كانت العملية تسير كالتالي:
- المستخدم يملأ نموذج التسجيل ويضغط “إرسال”.
- خدمة الواجهة الأمامية (Frontend) ترسل طلب HTTP POST إلى خدمة المصادقة (Auth Service).
- خدمة المصادقة تقوم بـ:
- حفظ المستخدم في قاعدة البيانات.
- الاتصال مباشرة (Synchronous Call) بخدمة الإيميلات (Email Service) لإرسال إيميل ترحيبي.
- الانتظار…
- الاتصال مباشرة (Synchronous Call) بخدمة التحليلات (Analytics Service) لتسجيل مستخدم جديد.
- الانتظار…
- إرجاع رد ناجح للمستخدم.
لو تعطلت خدمة الإيميلات في الخطوة (b)، فإن عملية التسجيل كلها ستفشل وسيرى المستخدم رسالة خطأ. يا لها من كارثة!
الطريقة الجديدة (المعمارية القائمة على الأحداث)
بعد تطبيق EDA، أصبحت العملية أكثر أناقة ومرونة:
- المستخدم يملأ نموذج التسجيل ويضغط “إرسال”.
- خدمة الواجهة الأمامية (Frontend) ترسل طلب HTTP POST إلى خدمة المصادقة (Auth Service).
- خدمة المصادقة تقوم بـ:
- حفظ المستخدم في قاعدة البيانات.
- إنشاء حدث اسمه
UserRegisteredيحتوي على بيانات المستخدم (مثل ID, email, name). - نشر (Publish) هذا الحدث إلى وسيط الأحداث (مثلاً Kafka topic اسمه `user_events`).
- إرجاع رد ناجح للمستخدم فورًا.
وهنا يحدث السحر. خدمة المصادقة انتهى دورها! والآن، بشكل مستقل وغير متزامن (Asynchronously):
- خدمة الإيميلات (Email Service)، التي تستمع إلى topic الـ `user_events`، تستقبل حدث
UserRegisteredوتقوم بإرسال الإيميل الترحيبي. - خدمة التحليلات (Analytics Service)، التي تستمع لنفس الـ topic، تستقبل نفس الحدث وتقوم بتحديث لوحات المعلومات الخاصة بها.
- خدمة جديدة (مثلاً Profile Service) يمكننا إضافتها لاحقًا، يمكنها الاستماع لنفس الحدث لإنشاء ملف شخصي افتراضي للمستخدم الجديد، دون الحاجة لتعديل سطر واحد في خدمة المصادقة!
مثال كود (بسيط جدًا)
لنفترض أننا نستخدم Node.js و Kafka. الكود قد يبدو كالتالي:
في خدمة المصادقة (المنتج – Producer):
// auth-service.js
const { Kafka } = require('kafkajs');
const kafka = new Kafka({ clientId: 'auth-app', brokers: ['kafka:9092'] });
const producer = kafka.producer();
async function registerUser(userData) {
// 1. Save user to database...
const savedUser = await db.users.save(userData);
// 2. Produce an event
await producer.connect();
await producer.send({
topic: 'user_events',
messages: [
{
key: savedUser.id,
value: JSON.stringify({
eventType: 'UserRegistered',
payload: {
userId: savedUser.id,
email: savedUser.email,
name: savedUser.name,
timestamp: new Date().toISOString()
}
})
},
],
});
console.log('UserRegistered event produced!');
await producer.disconnect();
// 3. Return success to the user immediately
return { success: true, userId: savedUser.id };
}
في خدمة الإيميلات (المستهلك – Consumer):
// email-service.js
const { Kafka } = require('kafkajs');
const kafka = new Kafka({ clientId: 'email-app', brokers: ['kafka:9092'] });
const consumer = kafka.consumer({ groupId: 'email-group' });
async function start() {
await consumer.connect();
await consumer.subscribe({ topic: 'user_events', fromBeginning: true });
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
const event = JSON.parse(message.value.toString());
if (event.eventType === 'UserRegistered') {
const { userId, email, name } = event.payload;
console.log(`Received UserRegistered event for user ${userId}. Sending welcome email to ${email}...`);
// Logic to send the actual email
// sendWelcomeEmail(email, name);
}
},
});
}
start().catch(console.error);
نصائح أبو عمر العملية من قلب الميدان
الانتقال إلى EDA ليس مجرد تغيير في الكود، بل هو تغيير في طريقة التفكير. وهذه بعض النصائح التي تعلمتها بالطريقة الصعبة:
- ابدأ صغيرًا: لا تحاول تحويل نظامك بالكامل دفعة واحدة. اختر عملية واحدة غير حرجة (non-critical) في نظامك، مثل “تحديث صورة الملف الشخصي”، وقم بتطبيق نمط EDA عليها. تعلم من التجربة ثم توسع.
- عرّف عقود الأحداث (Event Contracts) بوضوح: تعامل مع بنية الحدث (Schema) كأنها واجهة برمجة تطبيقات (API) عامة. يجب أن تكون موثقة ومستقرة. استخدم أدوات مثل Avro أو Protobuf مع Schema Registry لفرض هذه العقود وإدارة إصداراتها. هذا يمنع الفوضى عندما تتغير الأحداث.
- فكّر في الـ Idempotency: “اللامتغيرية” أو “Idempotency” تعني أن تنفيذ نفس العملية عدة مرات يعطي نفس النتيجة كتنفيذها مرة واحدة. في عالم الأنظمة الموزعة، قد يستلم المستهلك نفس الحدث مرتين (بسبب أخطاء الشبكة أو إعادة المحاولة). يجب أن يكون منطقك قادرًا على التعامل مع هذا. على سبيل المثال، قبل إرسال إيميل ترحيبي، تحقق أولاً إذا تم إرساله بالفعل لهذا المستخدم.
- المراقبة والتتبع (Monitoring & Tracing) هي مفتاحك: لقد انتقلت من نظام بسيط يمكن تتبعه (call stack) إلى نظام موزع معقد. أنت بحاجة ماسة إلى أدوات تتبع موزعة (Distributed Tracing) مثل Jaeger أو Zipkin لترى كيف ينتقل الحدث من منتج إلى مستهلك عبر النظام بأكمله. بدونها، ستكون كمن يبحث عن إبرة في كومة قش عند حدوث خطأ.
- اختر الوسيط المناسب: ليست كل وسطاء الرسائل متشابهة. Kafka ممتاز للتعامل مع كميات هائلة من البيانات (high-throughput) والاحتفاظ بها لفترات طويلة (log). RabbitMQ هو وسيط رسائل تقليدي رائع يوفر أنماط توجيه معقدة. اختر الأداة التي تناسب احتياجاتك من حيث الضمانات (ordering, delivery guarantees) والأداء.
الخلاصة يا جماعة الخير 🏁
المعمارية القائمة على الأحداث ليست حلاً سحريًا لكل المشاكل، وتأتي مع تعقيداتها الخاصة (خاصة في المراقبة وإدارة الأخطاء). لكنها بالنسبة لنا، كانت طوق النجاة الذي أنقذنا من جحيم الاقتران المحكم.
لقد أعطتنا القدرة على بناء نظام مرن (resilient)، قابل للتوسع (scalable)، ورشيق (agile). أصبحنا نضيف ميزات جديدة بسرعة دون الخوف من كسر أجزاء أخرى من النظام. صحيح أن الطريق في البداية كان صعبًا، لكن الفوائد على المدى الطويل كانت تستحق كل دقيقة من التعب والجهد.
نصيحتي الأخيرة لك: لا تخف من تجربة هذا النمط المعماري. ابدأ بفهمه جيدًا، طبقه على جزء صغير، وتعلم. ستكتشف قوة هائلة في فك ارتباط خدماتك والسماح لها بالنمو والتطور بشكل مستقل. يلا، شدوا حيلكم وخلونا نبني أنظمة أفضل! 💪