يا أهلاً وسهلاً بالجميع، معكم أخوكم أبو عمر.
بتذكر قبل كم سنة، كنا شغالين على نظام إدارة محتوى ضخم لأحد العملاء. في البداية، كانت الأمور “زي الحلاوة”. النظام بسيط، قاعدة بيانات واحدة، ونماذج (Models) واضحة بتتعامل مع كل إشي: قراءة، كتابة، تعديل، حذف… ما يسمى بالـ CRUD التقليدي. كنا مبسوطين وشايفين حالنا.
لكن مع الوقت، كبر النظام… وكبرت معه المشاكل. زاد عدد المستخدمين، وزادت البيانات، وصارت لوحة التحكم (الداشبورد) اللي بتعرض إحصائيات وتقارير معقدة، بطيئة بشكل لا يطاق. الصفحة اللي كانت تفتح في ثانية صارت بدها عشر ثواني وأكثر. والمصيبة الأكبر، لما يكون في ضغط على عمليات القراءة (عرض التقارير)، عمليات الكتابة (زي إضافة مقال جديد) كانت تتأثر وتصير بطيئة هي كمان! صرنا زي اللي بحرث في البحر، كل ما نضيف Index لقاعدة البيانات أو نعمل Caching في مكان، تطلع لنا مشكلة في مكان ثاني. ولّعت معنا وصارت الاجتماعات كلها صراخ ونقاشات حادة. الفريق محبط، والعميل بلش يزهق.
في ليلة من هالليالي وأنا قاعد بقلّب في الأكواد والـ “Query plans” المعقدة، خطرت في بالي فكرة كنت قرأت عنها زمان ورميتها ورا ظهري: “ليش لازم نفس النموذج اللي بستخدمه عشان أكتب البيانات يكون هو نفسه اللي بقرأ منه؟ شو هالحكي؟”. هاي الفكرة البسيطة كانت بداية الخيط لإنقاذ المشروع كله. كانت بوابتنا لعالم الـ CQRS.
الجحيم الذي كنا فيه: مشكلة النموذج الواحد (Single Model)
قبل ما نحكي عن الحل، خلونا نفهم أصل المشكلة. في معظم الأنظمة التقليدية، نستخدم نفس النموذج (Model) لتمثيل الكيان (Entity) في كل العمليات. لنأخذ مثال بسيط: منتج (Product) في متجر إلكتروني.
النموذج الواحد يبدو هكذا في الكود (كمثال توضيحي):
// هذا النموذج يُستخدم لكل شيء!
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
public int CategoryId { get; set; }
// ... وغيره من الخصائص
}
هذا النموذج البريء يُستخدم في:
- عملية الكتابة (Write): عند إضافة منتج جديد، نحتاج لكل هذه الحقول مع قواعد تحقق (Validation) معقدة (مثلاً، السعر يجب أن يكون أكبر من صفر، الاسم لا يمكن أن يكون مكرراً).
- عملية القراءة (Read): عند عرض قائمة المنتجات في الصفحة الرئيسية، قد نحتاج فقط للاسم والصورة والسعر (
Name,ImageUrl,Price). لكننا نضطر لجلب النموذج كاملاً أو عمل “Projection” معقدة من قاعدة البيانات. وعند عرض صفحة تفاصيل المنتج، نحتاج لكل شيء تقريباً.
المشكلة تظهر عندما تتعقد الأمور. نموذج القراءة يحتاج إلى بيانات من جداول أخرى (Joins)، فيصبح الاستعلام بطيئاً. ونموذج الكتابة يحتاج إلى منطق عمل (Business Logic) وقواعد تحقق صارمة. عندما نجمع الاثنين في نموذج واحد، نحصل على وحش هجين:
- معقد: النموذج يصبح مليئاً بالخصائص والوظائف التي لا تحتاجها كل العمليات.
- بطيء: استعلامات القراءة تصبح معقدة وبطيئة لأنها تحاول بناء “شكل” معين للبيانات من نموذج مصمم بالأصل للكتابة (Normalized Form).
- صعب التطوير: أي تغيير في النموذج لخدمة القراءة قد يكسر منطق الكتابة، والعكس صحيح. الفريقان (فريق الواجهات وفريق الخلفية) يصبحان في صراع دائم.
هذا بالضبط ما كان يحدث معنا. تقاريرنا كانت تحتاج لـ 5-6 Joins مع تجميع (Aggregation) للبيانات، وهذا كان يضع قفلاً (Lock) على الجداول، مما يبطئ عمليات إضافة المنتجات الجديدة. كنا في جحيم حقيقي.
المنقذ: نمط CQRS (Command Query Responsibility Segregation)
الـ CQRS هو مبدأ معماري بسيط لكنه قوي جدًا. اسمه الطويل قد يكون مخيفًا، لكن فكرته بسيطة: “افصل المسؤوليات: مسؤولية تغيير البيانات عن مسؤولية قراءة البيانات”.
بدلاً من نموذج واحد لكل شيء، سيكون لدينا نموذجان مختلفان تماماً:
- نموذج الكتابة (Write Model): هذا النموذج مسؤول عن تنفيذ الأوامر (Commands) التي تغير حالة النظام. مثل
CreateProductCommandأوUpdateProductPriceCommand. هذا النموذج يهتم بالمنطق المعقد وقواعد التحقق. - نموذج القراءة (Read Model): هذا النموذج مسؤول عن تنفيذ الاستعلامات (Queries) التي تقرأ البيانات. مثل
GetProductDetailsQuery. هذا النموذج مصمم خصيصاً ليكون سريعاً وفعالاً في عرض البيانات، حتى لو كانت البيانات مكررة أو غير طبيعية (Denormalized).
تخيلها مثل مطعم: المطبخ (Write Model) هو المكان الذي تحدث فيه الفوضى والعمليات المعقدة لطبخ الوجبة. أما قائمة الطعام وصالة التقديم (Read Model) فهي منظمة وبسيطة ومصممة لراحة الزبون. لا يمكنك أن تطلب من الزبون أن يدخل المطبخ ليختار أكله!
كيف يعمل هذا على أرض الواقع؟
دعنا نرى كيف يمكن أن تبدو الأوامر والاستعلامات في الكود.
جانب الأوامر (The Command Side)
الأمر هو كائن يصف نية لتغيير شيء ما. لا يحتوي على منطق، فقط بيانات.
// الأمر: مجرد حامل بيانات يصف النية
public class UpdateProductPriceCommand
{
public int ProductId { get; set; }
public decimal NewPrice { get; set; }
public string UserId { get; set; } // من قام بالتغيير؟
}
// معالج الأمر: هنا يوجد المنطق الحقيقي
public class UpdateProductPriceCommandHandler
{
private readonly IProductRepository _writeRepository;
public UpdateProductPriceCommandHandler(IProductRepository writeRepository)
{
_writeRepository = writeRepository;
}
public void Handle(UpdateProductPriceCommand command)
{
// 1. جلب المنتج من قاعدة البيانات للكتابة
var product = _writeRepository.GetById(command.ProductId);
// 2. تطبيق قواعد العمل والتحقق
if (product == null) throw new Exception("المنتج غير موجود!");
if (command.NewPrice <= 0) throw new Exception("السعر غير صالح!");
// ... المزيد من القواعد
// 3. تغيير الحالة
product.ChangePrice(command.NewPrice);
// 4. حفظ التغييرات
_writeRepository.Save(product);
}
}
لاحظ أن معالج الأمر لا يُرجع أي بيانات. مهمته فقط تنفيذ الأمر بنجاح أو إطلاق خطأ. هذا يجعله بسيطاً ومركزاً.
جانب الاستعلامات (The Query Side)
الاستعلام هو طلب للبيانات. ومعالجه مصمم ليكون سريعاً جدًا.
// نموذج بيانات للقراءة فقط (DTO) مصمم خصيصًا للواجهة
public class ProductDetailsViewModel
{
public string Name { get; set; }
public string Description { get; set; }
public string FormattedPrice { get; set; } // e.g., "$99.99"
public string CategoryName { get; set; }
public bool IsInStock { get; set; }
}
// معالج الاستعلام: سريع ومباشر
public class GetProductDetailsQueryHandler
{
private readonly IReadDatabaseConnection _dbConnection;
public GetProductDetailsQueryHandler(IReadDatabaseConnection dbConnection)
{
_dbConnection = dbConnection;
}
public ProductDetailsViewModel Handle(int productId)
{
// استعلام SQL بسيط ومباشر على نموذج قراءة مُحسَّن
// قد يكون هذا النموذج جدولاً منفصلاً أو View في قاعدة البيانات
string sql = @"SELECT p.Name, p.Description, p.Price, c.Name as CategoryName, p.Stock
FROM Read_Products p
JOIN Read_Categories c ON p.CategoryId = c.Id
WHERE p.ProductId = @productId";
var rawData = _dbConnection.QuerySingle(sql, new { productId });
// تحويل البيانات إلى النموذج المطلوب
return new ProductDetailsViewModel {
Name = rawData.Name,
Description = rawData.Description,
FormattedPrice = $"{rawData.Price:C}",
CategoryName = rawData.CategoryName,
IsInStock = rawData.Stock > 0
};
}
}
لاحظ الجمال هنا: جانب القراءة لم يعد يهتم بنماذج العمل المعقدة (Domain Models). يمكنه القراءة من جداول “مُسطّحة” (Denormalized) ومُحسّنة مسبقاً، مما يلغي الحاجة لعمليات الربط (Joins) المعقدة في وقت التشغيل.
التحدي الأكبر: كيف نحافظ على التزامن بين النموذجين؟
هذا هو السؤال الذي يسأله الجميع. إذا غيرنا البيانات في جانب الكتابة، كيف يتم تحديث جانب القراءة؟
الجواب هو: الاتساق النهائي (Eventual Consistency).
عندما يقوم معالج الأمر (CommandHandler) بحفظ التغيير في قاعدة البيانات الأساسية (Write DB)، فإنه يقوم أيضًا بنشر حدث (Event) مثل ProductPriceUpdated. هذا الحدث يوضع في قائمة انتظار (Message Queue).
هناك خدمة صغيرة أخرى (Event Handler) تستمع لهذه الأحداث. عندما يصلها حدث ProductPriceUpdated، تقوم بتحديث نموذج القراءة (Read Model) المُحسّن. قد يكون هذا التحديث بسيطًا مثل تحديث حقل في جدول، أو إعادة بناء مستند JSON بالكامل في قاعدة بيانات NoSQL.
وهنا تأتي النصيحة من القلب:
يا جماعة، “Eventual Consistency” مش كلمة سحرية. بدك تفهم تبعاتها. يعني ممكن المستخدم يحدّث السعر وما يشوف التغيير فورًا على صفحة العرض (قد يكون هناك تأخير أجزاء من الثانية). هذا قرار بزنس لازم الكل يكون واعي إله ويوافق عليه. في 99% من الحالات، هذا التأخير مقبول تمامًا وغير ملحوظ.
متى تستخدم CQRS ومتى تبتعد عنه؟
مثل أي أداة، CQRS ليس حلاً لكل المشاكل. “ما تستخدم مدفع لتقتل ناموسة”.
استخدمه عندما:
- لديك أجزاء من النظام ذات منطق عمل معقد (Complex Business Logic).
- متطلبات الأداء للقراءة والكتابة مختلفة بشكل كبير (مثلاً، آلاف القراءات لكل عملية كتابة).
- تحتاج إلى توسيع (Scale) جانب القراءة وجانب الكتابة بشكل مستقل.
- تعمل في بيئة تعاونية حيث يقوم العديد من المستخدمين بتعديل نفس البيانات.
ابتعد عنه عندما:
- نظامك هو تطبيق CRUD بسيط.
– منطق العمل بسيط ومباشر.
– فريقك غير معتاد على هذه المفاهيم، حيث يضيف CQRS تعقيدًا مبدئيًا على المشروع.
نصيحة أبو عمر الذهبية: ليس عليك تطبيق CQRS على النظام بأكمله. يمكنك البدء بتطبيقه على الجزء الأكثر إيلامًا في نظامك فقط (ما يسمى بـ Bounded Context). في حالتنا، طبقناه أولاً على نظام التقارير وإدارة المنتجات، وتركنا الأجزاء البسيطة الأخرى كما هي. هذا أعطانا 80% من الفائدة بـ 20% من المجهود.
الخلاصة: نظرة من بعيد
التحول إلى CQRS لم يكن سهلاً، وتطلب تغييرًا في طريقة تفكير الفريق بأكمله. لكن النتائج كانت مذهلة. لوحة التحكم أصبحت صاروخية في السرعة، وعمليات الكتابة أصبحت أكثر استقرارًا وموثوقية لأنها أصبحت معزولة. استطعنا توسيع خوادم القراءة بشكل مستقل لمواجهة الضغط دون التأثير على عمليات الكتابة.
CQRS ليس مجرد نمط تقني، بل هو طريقة تفكير تجبرك على فصل الاهتمامات بشكل واضح وحقيقي. يحررك من قيود النموذج الواحد ويفتح لك أبوابًا واسعة لتحسين الأداء وقابلية التوسع.
فإذا وجدت نفسك يومًا في “جحيم النموذج الواحد”، تذكر قصة أبو عمر، وفكر في فصل استعلاماتك عن أوامرك. قد تكون هذه هي الخطوة التي تنقذ مشروعك. ✅
ودمتم سالمين يا مبرمجين.