تطبيق CQRS & Event Sourcing لإنشاء منصة مزاد على الإنترنت

الزملاء ، مساء الخير! اسمي ميشا أعمل كمبرمج.



في هذه المقالة ، أود أن أتحدث عن كيف قرر فريقنا تطبيق نهج CQRS & Event Sourcing في مشروع يعد موقعًا للمزاد عبر الإنترنت. وأيضًا حول ما تمخض عن هذا ، ما هي الاستنتاجات التي يمكن استخلاصها من تجربتنا وما أشعل النار من المهم ألا نتقدم لأولئك الذين يذهبون إلى CQRS & ES.

صورة





مقدمة



بادئ ذي بدء ، القليل من التاريخ وخلفية العمل. جاء إلينا أحد العملاء بمنصة لإجراء ما يسمى بالمزادات الموقوتة ، والتي كانت قيد الإنتاج بالفعل والتي تم جمع قدر معين من التعليقات عليها. أراد العميل منا أن ننشئ منصة للمزادات الحية له.



الآن القليل من المصطلحات. المزاد هو عندما يتم بيع سلع معينة - الكثير ، ويقوم المشترون (المزايدين) بتقديم العطاءات. مالك الدفعة هو المشتري الذي عرض أعلى عطاء. المزاد المحدد الوقت هو عندما يكون لكل عقد وقت إغلاق محدد سلفا. يقوم المشترون بوضع الرهانات في وقت ما يتم إغلاق العقد. على غرار موقع ئي باي.



تم إنشاء منصة موقوتة بطريقة كلاسيكية ، باستخدام CRUD. تم إغلاق الكثير من خلال تطبيق منفصل ، بدءًا من جدول زمني. كل هذا لم يعمل بشكل موثوق للغاية: فقدت بعض الرهانات ، وبعضها تم إجراؤه كما لو كان نيابة عن المشتري الخطأ ، لم يتم إغلاق أو إغلاق الكثير عدة مرات.



المزاد المباشر هو فرصة للمشاركة في مزاد حقيقي بعيد عن بعد عبر الإنترنت. هناك غرفة (في مصطلحاتنا الداخلية - "غرفة") ، تحتوي على مضيف المزاد بمطرقة وجمهور ، وهناك بجوار الكمبيوتر المحمول يجلس كاتب ما يسمى ، الذي بالضغط على الأزرار في واجهته ، يبث مسار المزاد إلى الإنترنت ، ويرتبط بحلول وقت المزاد ، يرى المشترون عروض الأسعار التي يتم وضعها في وضع عدم الاتصال ويمكنهم تقديم عروضهم.



تعمل كلتا المنصتين من حيث المبدأ في الوقت الفعلي ، ولكن إذا كان المتسوقون في حالة توقيت في وضع متساوٍ ، فمن المهم للغاية في حالة البث المباشر أن يتمكن المتسوقون عبر الإنترنت من التنافس بنجاح مع أولئك الموجودين في الغرفة. أي أنه يجب أن يكون النظام سريعًا وموثوقًا به. أخبرتنا التجربة المحزنة للمنصة المحددة بعبارات لا لبس فيها أن CRUD الكلاسيكية لم تكن مناسبة لنا.



لم يكن لدينا تجربتنا الخاصة في العمل مع CQRS & ES ، لذلك تشاورنا مع الزملاء الذين لديهم (لدينا شركة كبيرة) ، وقدمنا ​​لهم حقائق أعمالنا وتوصلنا إلى استنتاج مفاده أن CQRS & ES يجب أن يناسبنا.



ما هي أيضًا تفاصيل المزادات عبر الإنترنت:



  • — . , « », , . — , 5 . .
  • , , .
  • — - , , — .
  • , .
  • يجب أن يكون الحل قابلاً للتطوير - يمكن إجراء العديد من المزادات في وقت واحد.


لمحة موجزة عن نهج CQRS & ES



لن أتطرق إلى النظر في نهج CQRS & ES ، فهناك مواد حول هذا على الإنترنت وبشكل خاص حول حبري (على سبيل المثال ، هنا: مقدمة إلى CQRS + Event Sourcing ). ومع ذلك ، سأذكرك باختصار بالنقاط الرئيسية:



  • الشيء الأكثر أهمية في تحديد مصادر الأحداث: لا يقوم النظام بتخزين البيانات ، ولكن تاريخ تغييرها ، أي الأحداث. يتم الحصول على الحالة الحالية للنظام من خلال التطبيق المتسلسل للأحداث.
  • ينقسم نموذج المجال إلى كيانات تسمى المجاميع. تحتوي الوحدة على إصدار. يتم تطبيق الأحداث على المجاميع. تطبيق حدث على مجموع يزيد نسخته.
  • write-. , .
  • . . , , . «» . .
  • , , - ( N- ) . «» . , .
  • - , , , , write-.
  • write-, read-, , . read- . Read- .
  • , — Command Query Responsibility Segregation (CQRS): , , write-; , , read-.






. .





من أجل توفير الوقت ، وكذلك بسبب عدم وجود خبرة محددة ، قررنا أننا بحاجة إلى استخدام نوع من إطار العمل لـ CQRS & ES.



بشكل عام ، فإن مجموعتنا التكنولوجية هي Microsoft ، أي .NET و C #. قاعدة البيانات - Microsoft SQL Server. يتم استضافة كل شيء في Azure. تم إنشاء منصة موقوتة على هذا المكدس ، كان من المنطقي إنشاء منصة حية عليها.



في ذلك الوقت ، كما أتذكر الآن ، كان Chinchilla هو الخيار الوحيد المناسب لنا من حيث المكدس التكنولوجي. لذلك أخذناها.



لماذا نحتاج إلى إطار عمل CQRS & ES على الإطلاق؟ يمكنه "خارج الصندوق" حل هذه المشاكل ودعم جوانب التنفيذ مثل:



  • تجميع الكيانات ، والأوامر ، والأحداث ، والإصدارات المجمعة ، والإماهة ، وآلية اللقطة.
  • واجهات للعمل مع DBMS مختلفة. حفظ / تحميل الأحداث ولقطات من المجاميع من / إلى قاعدة الكتابة (مخزن الأحداث).
  • واجهات للعمل مع قوائم الانتظار - إرسال الأوامر والأحداث إلى قوائم الانتظار المناسبة ، وأوامر القراءة والأحداث من قائمة الانتظار.
  • واجهة للعمل مع websockets.


وبالتالي ، مع مراعاة استخدام Chinchilla ، أضفنا إلى مجموعتنا:



  • Azure Service Bus كحافلة قيادة وأحداث ، تدعمه Chinchilla خارج الصندوق ؛
  • قواعد بيانات الكتابة والقراءة هي Microsoft SQL Server ، أي أنها قواعد بيانات SQL. لن أقول أن هذا نتيجة اختيار واعي ، بل لأسباب تاريخية.


نعم ، الواجهة الأمامية مصنوعة في Angular.



كما قلت من قبل ، فإن أحد متطلبات النظام هو أن يتعلم المستخدمون في أسرع وقت ممكن عن نتائج أفعالهم وأفعال المستخدمين الآخرين - وهذا ينطبق على كل من العملاء والكاتب. لذلك ، نستخدم SignalR و websockets لتحديث البيانات بسرعة على الواجهة الأمامية. شينشيلا يدعم تكامل SignalR.



اختيار الوحدات



من أول الأشياء التي يجب القيام بها عند تنفيذ نهج CQRS & ES هو تحديد كيفية تقسيم نموذج المجال إلى تجميعات.



في حالتنا ، يتكون نموذج المجال من عدة كيانات رئيسية ، شيء من هذا القبيل:



public class Auction
{
     public AuctionState State { get; private set; }
     public Guid? CurrentLotId { get; private set; }
     public List<Guid> Lots { get; }
}

public class Lot
{
     public Guid? AuctionId { get; private set; }
     public LotState State { get; private set; }
     public decimal NextBid { get; private set; }
     public Stack<Bid> Bids { get; }
}
 
public class Bid
{
     public decimal Amount { get; set; }
     public Guid? BidderId { get; set; }
}




حصلنا على مجموعتين: المزاد واللوت (مع العطاءات). بشكل عام ، هذا منطقي ، لكننا لم نأخذ في الاعتبار شيئًا واحدًا - حقيقة أنه مع هذا التقسيم ، تم نشر حالة النظام على وحدتين ، وفي بعض الحالات ، للحفاظ على الاتساق ، يجب علينا إجراء تغييرات على كلتا الوحدتين ، وليس على واحدة. على سبيل المثال ، يمكن إيقاف المزاد مؤقتًا. إذا تم إيقاف المزاد مؤقتًا ، فلا يمكنك المزايدة على الدفعة. قد يكون من الممكن إيقاف الدفعة نفسها مؤقتًا ، لكن المزاد المتوقف مؤقتًا لا يمكنه معالجة أي أوامر بخلاف "إلغاء الإيقاف المؤقت".



وبدلاً من ذلك ، يمكن عمل إجمالي واحد فقط ، وهو المزاد ، مع كل العقود والمزايدة في الداخل. لكن مثل هذا الشيء سيكون صعبًا للغاية ، لأنه يمكن أن يكون هناك عدة آلاف من القطع في المزاد ويمكن أن يكون هناك عدة عشرات من العروض لقطعة واحدة. خلال عمر المزاد ، سيكون لمثل هذا التجميع الكثير من الإصدارات ، وستستغرق إعادة تمييع هذا التجميع (التطبيق المتسلسل لجميع الأحداث على التجميع) ، إذا لم يتم عمل لقطات من المجاميع ، وقتًا طويلاً. وهو أمر غير مقبول بالنسبة لوضعنا. إذا كنت تستخدم اللقطات (نستخدمها) ، فإن اللقطات نفسها ستثقل الكثير.



من ناحية أخرى ، للتأكد من تطبيق التغييرات على مجموعتين داخل معالجة إجراء مستخدم واحد ، يجب عليك إما تغيير المجموعتين في نفس الأمر باستخدام المعاملة ، أو تنفيذ أمرين في نفس المعاملة. كلاهما ، على العموم ، انتهاك للعمارة.



يجب أن تؤخذ مثل هذه الظروف في الاعتبار عند تقسيم نموذج المجال إلى مجاميع.



في هذه المرحلة من تطور المشروع ، نعيش مع وحدتين ، المزاد واللوت ، ونكسر الهيكل عن طريق تغيير كلتا الوحدتين في بعض الأوامر.



تطبيق أمر على إصدار محدد من التجميع



إذا قام العديد من المشترين بتقديم مزايدة على نفس الدفعة في نفس الوقت ، أي أنهم يرسلون أمر "وضع عطاء" إلى النظام ، فلن ينجح إلا أحد العطاءات. الكثير هو مجموع ، ولديه نسخة. أثناء معالجة الأمر ، يتم إنشاء الأحداث ، وكل منها يزيد من إصدار التجميع. هناك طريقتان للذهاب:



  • إرسال أمر يحدد إصدار التجميع الذي نريد تطبيقه عليه. ثم يمكن لمعالج الأوامر مقارنة الإصدار الموجود في الأمر بالإصدار الحالي للوحدة على الفور وعدم المتابعة إذا لم يتطابق.
  • لا تحدد إصدار الوحدة في الأمر. ثم يتم إعادة معالجة التجميع مع بعض الإصدارات ، ويتم تنفيذ منطق الأعمال المقابل ، ويتم إنشاء الأحداث. وفقط عندما يتم حفظها ، يمكن أن ينفجر التنفيذ بأن مثل هذا الإصدار من الوحدة موجود بالفعل. لأن شخص آخر فعل ذلك في وقت سابق.


نستخدم الخيار الثاني. وهذا يعطي للفرق فرصة أفضل للتنفيذ. لأنه في جزء التطبيق الذي يرسل الأوامر (في حالتنا ، هذه هي الواجهة الأمامية) ، فإن الإصدار الحالي من المجمع مع بعض الاحتمالات سوف يتخلف عن الإصدار الحقيقي على الواجهة الخلفية. خاصة في الحالات التي يتم فيها إرسال الكثير من الأوامر ، ويتغير إصدار الوحدة بشكل متكرر.



أخطاء عند تنفيذ أمر باستخدام قائمة انتظار



في تطبيقنا ، مدفوعًا بشدة بـ Chinchilla ، يقرأ معالج الأوامر الأوامر من قائمة انتظار (Microsoft Azure Service Bus). نحن نميز بوضوح المواقف التي يفشل فيها الفريق لأسباب فنية (المهلات ، والأخطاء في الاتصال بقائمة انتظار / قاعدة) وعندما لأسباب تجارية (محاولة لتقديم عرض على الكثير من نفس المبلغ الذي تم قبوله بالفعل ، وما إلى ذلك). في الحالة الأولى ، تتكرر محاولة تنفيذ الأمر حتى يتم الوصول إلى عدد التكرارات المحددة في إعدادات قائمة الانتظار ، وبعد ذلك يتم إرسال الأمر إلى قائمة انتظار الرسائل الميتة (موضوع منفصل للرسائل غير المجهزة في ناقل خدمة Azure). في حالة تنفيذ العمل ، يتم إرسال الفريق إلى قائمة انتظار الأحرف الميتة على الفور.







أخطاء عند معالجة الأحداث باستخدام قائمة انتظار



يمكن أيضًا إرسال الأحداث التي تم إنشاؤها نتيجة تنفيذ الأمر ، اعتمادًا على التنفيذ ، إلى قائمة الانتظار وأخذها من قائمة الانتظار بواسطة معالجات الأحداث. وعند التعامل مع الأحداث ، تحدث الأخطاء أيضًا.



ومع ذلك ، على النقيض من الوضع مع الأمر غير المنفذة ، كل شيء أسوأ هنا - قد يحدث أن تم تنفيذ الأمر وكتبت الأحداث إلى قاعدة الكتابة ، لكن معالجتها من قبل المعالجات فشلت. وإذا قام أحد هذه المعالجات بتحديث قاعدة القراءة ، فلن يتم تحديث قاعدة القراءة. أي أنها ستكون في حالة غير متناسقة. نظرًا لآلية إعادة محاولة حدث القراءة ، يتم تحديث قاعدة البيانات دائمًا في النهاية تقريبًا ، ولكن احتمالية استمرار كسر كل المحاولات بعد كل المحاولات.







واجهنا هذه المشكلة في المنزل. ومع ذلك ، كان السبب يرجع إلى حد كبير إلى حقيقة أن لدينا بعض منطق الأعمال في معالجة الأحداث ، والتي ، مع تدفق مكثف من الرهانات ، لديها فرصة جيدة للفشل من وقت لآخر. لسوء الحظ ، أدركنا ذلك بعد فوات الأوان ، ولم يكن من الممكن إعادة تنفيذ الأعمال بسرعة وبساطة.



ونتيجة لذلك ، كإجراء مؤقت ، توقفنا عن استخدام حافلة خدمة Azure لنقل الأحداث من جزء الكتابة من التطبيق إلى جزء القراءة. بدلاً من ذلك ، يتم استخدام ما يسمى بـ In-Memory Bus ، مما يسمح لك بمعالجة الأمر والأحداث في معاملة واحدة ، وفي حالة الفشل ، استرجاع الأمر بأكمله.







لا يساهم مثل هذا الحل في قابلية التوسع ، ولكن من ناحية أخرى ، نستبعد المواقف عندما تتعطل قاعدة القراءة الخاصة بنا ، والتي بدورها تكسر الواجهات الأمامية ويصبح من المستحيل متابعة المزاد دون إعادة إنشاء قاعدة القراءة عن طريق إعادة تشغيل جميع الأحداث.



إرسال أمر ردا على حدث



هذا ، من حيث المبدأ ، مناسب ، ولكن فقط في الحالة التي لا يؤدي فيها الفشل في تنفيذ هذا الأمر الثاني إلى كسر حالة النظام.



التعامل مع الأحداث المتعددة لأمر واحد



بشكل عام ، ينتج عن تنفيذ أمر واحد عدة أحداث. يحدث أنه لكل حدث نحتاج إلى إجراء بعض التغيير في قاعدة بيانات القراءة. يحدث أيضًا أن تسلسل الأحداث مهم أيضًا ، وفي التسلسل الخاطئ ، لن تعمل معالجة الأحداث كما يجب. كل هذا يعني أنه لا يمكننا القراءة من قائمة الانتظار ومعالجة أحداث أمر واحد بشكل مستقل ، على سبيل المثال ، مع مثيلات مختلفة من التعليمات البرمجية التي تقرأ الرسائل من قائمة الانتظار. بالإضافة إلى ذلك ، نحتاج إلى ضمان أن الأحداث من قائمة الانتظار ستتم قراءتها بنفس التسلسل الذي تم إرسالها فيه. أو نحتاج أن نكون مستعدين لحقيقة أنه لن تتم معالجة جميع أحداث القيادة بنجاح في المحاولة الأولى.







معالجة حدث واحد باستخدام معالجات متعددة



إذا كان النظام بحاجة إلى تنفيذ عدة إجراءات مختلفة استجابة لحدث واحد ، فعادةً ما يتم إجراء العديد من معالجات هذا الحدث. يمكنهم العمل بالتوازي أو بالتتابع. في حالة الإطلاق المتسلسل ، إذا فشل أحد المعالجات ، يتم إعادة تشغيل التسلسل بأكمله (هذا في Chinchilla). مع مثل هذا التنفيذ ، من المهم أن تكون معالجات idempotent بحيث لا تفشل العملية الثانية من معالج تنفيذ مرة واحدة بنجاح. خلاف ذلك ، عندما يقع المعالج الثاني من السلسلة ، بالتأكيد ، لن تعمل السلسلة تمامًا ، لأن المعالج الأول سيسقط في المحاولات الثانية (واللاحقة).



على سبيل المثال ، يضيف معالج الأحداث في قاعدة القراءة عرض سعر للكثير من 5 روبل. ستكون المحاولة الأولى للقيام بذلك ناجحة ، والثانية لن تسمح بتنفيذ القيد في قاعدة البيانات.







الاستنتاجات / الخلاصة



الآن مشروعنا في مرحلة عندما ، كما يبدو لنا ، قد خطونا بالفعل على معظم المكابس القائمة ذات الصلة بخصائص أعمالنا. بشكل عام ، نعتبر تجربتنا ناجحة تمامًا ، CQRS & ES مناسب تمامًا لمجال موضوعنا. وينظر إلى مزيد من تطوير المشروع في التخلي عن شينشيلا لصالح إطار آخر يمنح المزيد من المرونة. ومع ذلك ، من الممكن أيضًا رفض استخدام الإطار على الإطلاق. ومن المحتمل أيضًا أنه ستكون هناك بعض التغييرات في اتجاه إيجاد توازن بين الموثوقية من ناحية وسرعة الحل وقابليته من جهة أخرى.



أما بالنسبة لمكون الأعمال ، فهناك أيضًا بعض الأسئلة التي لا تزال مفتوحة - على سبيل المثال ، تقسيم نموذج المجال إلى تجميعات.



أتمنى أن تكون تجربتنا مفيدة لشخص ما ، وتساعد على توفير الوقت وتجنب أشعل النار. شكرا على انتباهك.



All Articles