يا جماعة الخير، السلام عليكم ورحمة الله.
بتذكر قبل كم سنة، كنا شغالين على نظام كبير ومعقد، نظام فيه من كل بحر قطرة: إدارة مستخدمين، طلبات، مخزون، تقارير، ولوحة تحكم تحليلية. في البداية، الأمور كانت “آخر حلاوة”. كنا مبسوطين على قاعدة البيانات العلائقية الموحدة تبعتنا، وكل شيء كان يمشي زي الساعة.
لكن مع الوقت، ومع زيادة تعقيد البزنس وزيادة عدد المستخدمين، بدأت المشاكل تظهر. صار نموذج البيانات تبعنا زي وحش “الهيدرا” الأسطوري، كل ما نحاول نصلح شغلة أو نضيف ميزة جديدة، بتطلع لنا عشر مشاكل من أماكن ما كنا نتوقعها. صار الفريق كله يشتكي، والمبرمج الجديد اللي ييجي يصفن فيه أسبوع بس عشان يفهم جدول واحد شو قصة أبوه. ليلة من الليالي، وأنا قاعد براجع كود وبشرب فنجان القهوة المُر، قلت لحالي: “يا أبو عمر، هيك ما بنفع. لازم نلاقي حل جذري، الشغلة مش ترقيع”. ومن هنا بدأت رحلتنا في ترويض هذا الوحش.
لماذا كان نموذج بياناتنا وحشًا متعدد الرؤوس؟
لفهم الحل، لازم نفهم أصل المشكلة. المشكلة ما كانت في قاعدة البيانات نفسها، بل في طريقة تصميمنا للنموذج اللي بيخدم كل أجزاء النظام بدون تمييز. كان نموذج واحد يخدم عمليات الكتابة (إضافة طلب جديد، تحديث مخزون) وعمليات القراءة (عرض قائمة المنتجات، توليد تقرير مبيعات سنوي). وهذا هو سبب الكارثة.
تعقيد القراءة والكتابة
عندما تصمم نموذجًا للكتابة، أنت تركز على “التطبيع” (Normalization) لضمان سلامة البيانات وتجنب التكرار. لكن هذا النموذج المُطبّع يصبح كابوسًا عند القراءة، حيث تحتاج إلى عمل `JOIN` بين عدد كبير من الجداول لجلب معلومة بسيطة، وهذا بطيء ومكلف.
بالمقابل، لو صممت نموذجًا للقراءة، ستركز على “اللامعيارية” (Denormalization) لتجميع كل البيانات التي تحتاجها شاشة معينة في مكان واحد، مما يجعل القراءة سريعة جدًا. لكن هذا النموذج سيجعل عمليات الكتابة والتحديث جحيمًا، لأنك ستحتاج لتحديث نفس المعلومة في أماكن متعددة.
باختصار، كنا نحاول نجبر نموذج واحد على أن يكون جيدًا في شيئين متضادين تمامًا. زي ما بنحكي، “صاحب بالين كذاب”.
مشاكل الأداء والتوسع
تخيل أن هناك ضغطًا كبيرًا على قراءة التقارير التحليلية. هذه العمليات المعقدة كانت تستهلك موارد قاعدة البيانات وتؤدي أحيانًا إلى “قفل” جداول معينة (Table Locks). في نفس الوقت، كان المستخدمون يحاولون إضافة طلبات جديدة (عمليات كتابة)، لكنها كانت تتأخر أو تفشل لأن الجداول مشغولة. النظام كله كان يصبح بطيئًا وغير مستجيب.
الحل الأول: فصل المهام بنمط CQRS
بعد بحث وقراءة، وجدنا ضالتنا في نمط معماري اسمه CQRS، وهو اختصار لـ Command and Query Responsibility Segregation. الاسم طويل ومعقد، لكن الفكرة بسيطة بشكل عبقري.
ما هو CQRS ببساطة؟
الفكرة هي أن تفصل بشكل كامل بين المسار الذي يغير بيانات النظام (الكتابة) والمسار الذي يقرأ بيانات النظام (القراءة).
- الـ Command (الأمر): هو كائن يمثل نية لتغيير شيء ما في النظام. مثلاً: `CreateOrderCommand` أو `UpdateProductPriceCommand`. الأوامر لا تُرجع بيانات، هي فقط تنفذ عملية.
- الـ Query (الاستعلام): هو طلب للحصول على بيانات. مثلاً: `GetOrderDetailsQuery` أو `GetAllProductsQuery`. الاستعلامات لا تغير أي شيء في النظام أبدًا، هي فقط تقرأ.
هذا الفصل يسمح لنا ببناء نموذجين مختلفين تمامًا: نموذج للكتابة (Write Model) وآخر للقراءة (Read Model).
كيف طبقنا CQRS؟
قمنا بتقسيم الكود إلى مسارين واضحين:
- مسار الأوامر (The Write Side):
- استخدمنا نموذج بيانات مُطبّع (Normalized) وغني بالمنطق البرمجي (Rich Domain Model).
- ركزنا على مفاهيم الـ Domain-Driven Design (DDD) مثل الـ Aggregates لضمان تناسق البيانات وصحة قواعد البزنس.
- كل أمر كان يتم معالجته بواسطة Handler خاص به، يقوم بالتحقق من الصلاحيات، وتطبيق قواعد البزنس، ثم تغيير حالة النظام.
- مسار الاستعلامات (The Read Side):
- أنشأنا نماذج بيانات منفصلة ومُحسّنة لكل شاشة أو تقرير. هذه النماذج كانت بسيطة جدًا وغير مُطبّعة (Denormalized).
- على سبيل المثال، كان لدينا جدول اسمه `OrderSummary` يحتوي على كل البيانات التي تحتاجها شاشة قائمة الطلبات، جاهزة للعرض بدون أي `JOIN`.
- الاستعلامات كانت بسيطة ومباشرة، تقرأ من هذه الجداول المُحسّنة بسرعة فائقة.
مثال بالكود (شبه كود):
// Command Side
public class PlaceOrderCommand {
public Guid CustomerId { get; set; }
public List<OrderItem> Items { get; set; }
}
public class PlaceOrderCommandHandler {
public void Handle(PlaceOrderCommand command) {
// 1. Validate the command
// 2. Check inventory
// 3. Calculate total price
// 4. Save the order to the "Write" database
// 5. ...
}
}
// Query Side
public class OrderSummaryQuery {
public Guid CustomerId { get; set; }
}
public class OrderSummaryQueryHandler {
public List<OrderSummaryDto> Handle(OrderSummaryQuery query) {
// Directly query the "Read" model (e.g., a simple table)
// SELECT * FROM OrderSummaries WHERE CustomerId = @CustomerId
// This is super fast!
return readDb.Query<OrderSummaryDto>(...);
}
}
هذا الفصل حلّ لنا مشاكل الأداء والتعقيد بشكل كبير. لكن بقيت مشكلة واحدة تؤرقنا: كيف نحافظ على تزامن البيانات بين نموذج الكتابة ونماذج القراءة المتعددة؟ هنا يأتي دور الساحر الآخر.
الترقية: Event Sourcing لتسجيل كل همسة ولمسة
تخيل معي لو أن قاعدة بياناتك لا تخزن الحالة النهائية للبيانات، بل تخزن كل تغيير حدث على هذه البيانات منذ نشأتها كسلسلة من الأحداث (Events). هذا هو جوهر الـ Event Sourcing.
ما هو الـ Event Sourcing؟
بدلًا من تخزين سجل “الطلب” في جدول الطلبات، نقوم بتخزين سلسلة من الأحداث التي أدت إلى تكوين هذا الطلب. مثلاً:
- `OrderPlaced` (تم إنشاء الطلب)
- `PaymentInfoAdded` (تم إضافة معلومات الدفع)
- `OrderConfirmed` (تم تأكيد الطلب)
- `OrderShipped` (تم شحن الطلب)
هذه الأحداث هي “مصدر الحقيقة” الوحيد (Single Source of Truth). الحالة الحالية لأي طلب هي ببساطة نتيجة تطبيق كل هذه الأحداث بالترتيب. إذا أردت معرفة حالة الطلب الآن، قم بتشغيل كل أحداثه من البداية للنهاية.
مزايا لا تقدر بثمن
- سجل تدقيق كامل (Full Audit Trail): أنت تملك تاريخًا كاملاً وغير قابل للتغيير لكل ما حدث في النظام. هذا كنز للمحللين، لخدمة العملاء، وللمبرمجين عند تتبع الأخطاء.
- استعلامات زمنية (Temporal Queries): يمكنك بسهولة الإجابة على أسئلة مثل: “كيف كان شكل هذا الطلب الأسبوع الماضي قبل أن يتم تعديله؟”. هذا شبه مستحيل في الأنظمة التقليدية.
- مرونة هائلة في نماذج القراءة: هل تريد تقريرًا جديدًا لم تفكر به من قبل؟ لا مشكلة! أنشئ نموذج قراءة جديد، وقم “بإسقاط” (Project) كل الأحداث من البداية عليه لتعبئته بالبيانات. لست مقيدًا بالماضي.
CQRS + Event Sourcing: الثنائي الذي لا يقهر
عندما ندمج النمطين معًا، نحصل على نظام قوي ومرن بشكل لا يصدق. إليك كيف يعمل التدفق:
- مستخدم يرسل `Command` (مثلاً، `PlaceOrderCommand`).
- الـ `CommandHandler` يستقبل الأمر، يقوم بالتحقق من المنطق، ويقرر إنشاء حدث `OrderPlaced`.
- هذا الحدث يتم حفظه بشكل دائم في “متجر الأحداث” (Event Store)، وهو قاعدة بيانات مُحسّنة لتخزين الأحداث.
- بمجرد حفظ الحدث، يتم نشره عبر ناقل رسائل (Message Bus).
- “المُسقِطات” (Projectors) تستمع لهذه الأحداث. كل مُسقِط مسؤول عن تحديث نموذج قراءة معين.
- مثلاً، لدينا `OrderSummaryProjector` يستمع لحدث `OrderPlaced` ويضيف سجلًا جديدًا في جدول `OrderSummaries` في قاعدة بيانات القراءة.
- عندما يطلب المستخدم عرض قائمة طلباته، يتم إرسال `Query` يقرأ مباشرة من جدول `OrderSummaries` السريع والمُحسّن.
بهذه الطريقة، أصبح نموذج الكتابة (Event Store) منفصلاً تمامًا عن نماذج القراءة، والتزامن بينهما يتم بشكل غير متزامن عبر الأحداث.
نصائح من خبرة أبو عمر
هذه الأنماط قوية، لكنها ليست حلاً سحريًا لكل المشاكل. إليك بعض النصائح من قلب الميدان:
ليس لكل مشروع
هذه المعمارية تضيف درجة من التعقيد. إذا كان مشروعك تطبيق CRUD بسيط، فاستخدامها سيكون مبالغة وهندسة زائدة عن الحاجة (Over-engineering). زي ما بنحكي، “ما بدك مدفع لقتل ناموسة”. استخدمها في الأنظمة ذات المنطق البرمجي المعقد والتي تحتاج إلى قابلية عالية للتوسع والتدقيق.
تقبّل الاتساق النهائي (Eventual Consistency)
بما أن تحديث نماذج القراءة يتم بشكل غير متزامن، هناك فاصل زمني (عادة أجزاء من الثانية) تكون فيه نماذج القراءة غير متزامنة مع نموذج الكتابة. هذا يسمى “الاتساق النهائي”. يجب أن تتأكد من أن هذا مقبول من ناحية البزنس. في معظم الحالات (مثل عرض قائمة طلبات أو تحديث إحصائيات)، هذا التأخير البسيط لا يمثل أي مشكلة.
أهمية البنية التحتية
تحتاج إلى بنية تحتية قوية لدعم هذه المعمارية: متجر أحداث جيد (مثل EventStoreDB أو حتى PostgreSQL مع بعض التعديلات)، وناقل رسائل موثوق (مثل RabbitMQ أو Kafka) للتعامل مع نشر الأحداث ومعالجتها.
الخلاصة: من وحش إلى رفيق درب 😉
رحلتنا في ترويض وحش البيانات كانت صعبة لكنها مجدية. الانتقال إلى CQRS و Event Sourcing لم يحل مشاكل الأداء والتعقيد فحسب، بل فتح لنا آفاقًا جديدة من المرونة والقدرة على فهم نظامنا بشكل أعمق من أي وقت مضى. أصبح لدينا نظام لا يخاف من المستقبل، نظام يمكنه النمو والتطور مع احتياجات البزنس المتغيرة.
نصيحتي الأخيرة لك: لا تخف من تحدي الوضع الراهن عندما تشعر أن نظامك أصبح يعيقك بدلاً من مساعدتك. أحيانًا، يكون أفضل حل هو التوقف، وأخذ نفس عميق، وإعادة التفكير في الأساسات. قد تكتشف أن الأدوات التي تحتاجها لترويض وحشك الخاص كانت موجودة طوال الوقت، تنتظر فقط من يكتشفها.
ودمتم سالمين.