اليوم، لدينا حوالي 1.1 مليون سطر من كود TypeScript الملتزم به في مستودعنا الأحادي. وقد ترافق توسيع نطاقه مع مجموعة من التحديات مثل بطء فحص الأنواع، وزيادة الاستيرادات، وتدهور تتبع الكود بشكل متزايد. على مدار السنوات القليلة الماضية، قمنا باستكشاف وتكرار أنماط تصميم مختلفة لتحسين توسيع قاعدة الكود لدينا. أحد الأنماط التي عثرنا عليها ساعدنا في التخفيف من العديد من الصعوبات المتزايدة في تنظيم الكود: السجلات. إنه نمط بسيط وواحد من أكثر الأنماط قابلية للتوسيع التي اعتمدناها.
يعد كود معالجة الأحداث الداخلي الأصلي لدينا مثالاً جيدًا على الأماكن التي أحدث فيها هذا النمط فرقًا كبيرًا. بدأ الأمر بسيطًا، ولكن مع مرور الوقت أدى إلى اتحادات أنواع ضخمة تسببت في إبطاء عملية التحقق من الأنواع، وملفات برميلية استوردت الكثير من الكود، وكود يصعب تتبعه بسبب التسلسل المفرط للسلاسل. وأخيرًا، تسبب ذلك في خلافات بين المطورين بشأن صيانة معالجات الأحداث الجديدة وإضافتها.
التصميم الأصلي
على مستوى عالٍ، يتم إنشاء خدمة الأحداث الخاصة بنا على أساس قائمة انتظار الرسائل (MQ). يتم تسجيل الأحداث وإرسالها إلى قائمة انتظار الرسائل من أي مكان في الخلفية (API pods، worker pods، إلخ) ويتم معالجتها من قبل عامل مرة واحدة فقط.
التصميم بحد ذاته بسيط للغاية؛ والقيود الرئيسية الوحيدة هي أن جميع الأحداث يجب أن تكون قابلة للتسلسل بالكامل عبر الشبكة. تنبع معظم التعقيدات والصيانة من أمان الأنواع وتجربة المطورين.
1. حدد مجموعة من المساعدات الأساسية:
2. تحديد أنواع الأحداث في ملف مشترك
3. تعريف معالجات الأحداث في ملفها الخاص
4. تعريف ملف تجميع
5. تحديد معالجات الأحداث
6. تحديد وظيفة مشتركة لتسجيل الأحداث
7. تصدير وظيفة تعالج معالج الأحداث للطابور في عامل
للوهلة الأولى، يبدو هذا التصميم جيدًا. فهو آمن من حيث النوع، وسهل الفهم، وبسيط في الاستخدام، وواضح في كيفية إنشاء أحداث جديدة. ومع ذلك، كانت هناك عدة مشكلات:
تعقيد فحص النوع: لكل حدث جديد، فإن AppEvent يصبح النوع أكبر. على المستوى الفردي، لا يمثل اتحاد واحد مشكلة، ولكن مع زيادة استخدام هذا النمط عبر قاعدة الكود (اتحادات أنواع متعددة)، يتدهور أداء فحص النوع بسرعة.
تحميل الوحدة النمطية Eager: كان نمط ملفات التجميع لدينا يعني أن كل وحدة تقريبًا تستورد قاعدة الكود بالكامل عند بدء التشغيل، مما يجعل التحميل المتأخر مستحيلًا. كما منعنا ذلك من تقسيم الخادم بسهولة إلى حزم منفصلة لنشرات مختلفة. في حين أن التحميل المتحمس لقاعدة الكود بالكامل كان مقبولًا في الإنتاج، إلا أنه أثر بشكل كبير على التطوير والاختبار، حيث استغرق الأمر أكثر من 20 إلى 30 ثانية فقط لبدء تشغيل خادم التطوير.
ضعف التتبع: كان من الصعب الإجابة على أسئلة بسيطة مثل "أين يتم تنفيذ هذا الحدث؟" أو "أين يتم استدعاء هذا المعالج؟". كان علينا الاعتماد على البحث عن النص الكامل للسلسلة النصية. على سبيل المثال، إذا قرر مهندس القيام بشيء مثل هذا:
سيكون من الصعب للغاية تتبع ذلك wire_sent و wire_created يتم تشغيل الأحداث من هنا. لن يكشف البحث عن السلسلة "wire_sent" في قاعدة الكود عن هذا الاستخدام، لأن الاسم يتم إنشاؤه ديناميكيًا. ونتيجة لذلك، تصبح هذه المعلومات معرفة "قبلية" غامضة لا يعرفها سوى عدد قليل من المهندسين.
عدم وجود حدود واضحة للمجال: مع إدخالنا لمزيد من الواجهات المتجانسة (معالجات الأحداث، كيانات قواعد البيانات، فحوصات الصحة)، شجعنا ذلك على وضع الواجهات المتشابهة في نفس المجلد بدلاً من تجميعها حسب منطق المجال. أدى ذلك إلى تجزئة منطق أعمالنا، مما جعل تبديل السياق الخاص بالمجال أكثر تكرارًا وتعقيدًا.
تكرار هذا التصميم
في الإعداد أعلاه، وضعنا جميع معالجات الأحداث في ./معالجات أحداث التطبيق/<event_type>.ts</event_type> الملفات. على الرغم من أن وجودها جميعًا في مجلد واحد سهّل العثور عليها، إلا أنه لم يعكس طريقة عملنا الفعلية. في الممارسة العملية، ثبت أن وضع معالجات الأحداث مع بقية منطق التطبيق ذي الصلة كان أكثر فائدة من تجميعها مع معالجات أخرى.
وهنا جاءت فكرة إضافة امتدادات فرعية للملفات (.event-handler.ts) دخلت. سمحوا لنا بتجميع الملفات حسب المجال مع تمكين سهولة العثور عليها من خلال البحث عن الامتداد. كما سمح لنا امتداد الملف بإزالة الملفات المجمعة التي يتم صيانتها يدويًا، حيث أصبح بإمكاننا البحث عن جميع الملفات التي تطابق الامتداد في المستودع أثناء وقت التشغيل.
فيما يلي نسخة مختصرة من كود التسجيل الأساسي وكيفية عمله. تحميل الوحدات النمطية سيقوم بمسح جميع الملفات وتسجيل جميع الكائنات المصدرة بـ $التمييز الملكية المطابقة للرمز نفسه الذي تم تمريره إلى إنشاء السجل.
الآن، فيما يلي شكل معالج الأحداث الخاص بنا باستخدام السجلات:
1. تعريف سجل في <name>.registry.ts</name> ملف:
2. حدد معالجات الأحداث الفعلية في .app-event-handler.ts الملفات
3. تحديد وظيفة مشتركة لتسجيل الأحداث
4. تصدير دالة تعالج معالج الأحداث للطابور في عامل
بعض الاختلافات المهمة التي يجب ملاحظتها:
تتبع الكود أفضل بكثير: كلما قمت بتسجيل حدث، قم بتسجيله على النحو التالي:
هذا يعني أنه من السهل تتبع جميع الأماكن التي cardCreatedEventHandler يتم استخدامه باستخدام أدوات AST مثل "Find all references" (في VS Code). على العكس، عندما ترى تسجيل الحدث نداء، يمكنك "الانتقال إلى التنفيذ" بنقرة واحدة للعثور على تعريف الحدث ومعالجته.
لا مزيد من اتحادات الأنواع: بل نحن نستخدم أنواع أساسية، وهو أمر TypeScript يشجع على تجنب مشاكل أداء فحص الأنواع التي تسببها الاتحادات الكبيرة.
توجد معالجات الأحداث في نفس مكان المنطق الخاص بالمجال: لم تعد معالجات أحداث التطبيق مخزنة في مجلد واحد. بدلاً من ذلك، يتم وضعها جنبًا إلى جنب مع منطق الأعمال ذي الصلة. على سبيل المثال، قد تبدو الخدمة الخاصة بالمجال كما يلي:
العمل مع السجلات اليوم
اليوم، نحن نعمل مع العشرات من السجلات للحفاظ على جميع الأكواد في نفس مكان منطق التطبيقات. ومن أبرزها ما يلي:
.db.tsلتسجيل كيانات قاعدة البيانات.workflows.tsو.الأنشطة.tsللتسجيل زمني سير العمل.checks.tsلتسجيل الفحوصات الصحية (منشور مدونة).main.tsلتسجيل الخدمات التي تجمع بين منطق الأعمال الخاص بالمجال.permission-role.tsو.مفتاح-الإذن.tsلتحديد أذونات RBAC في منتجنا.email-box.tsلتسجيل المعالجات التي تقوم بتحليل رسائل البريد الإلكتروني في حساب Gmail.cron.tsلتسجيل مهام cron.ledger-balance.tsلتعريف "دفتر الأستاذ" المالي الداخلي الأساسي.metrics.tsلتعريف مقاييس Datadog
وعدة امتدادات أخرى خاصة بمجالات معينة.
في الوقت الحالي، لم ننشر هذا النمط كمصدر مفتوح، ولكن نأمل أن يوفر هذا المنشور فكرة واضحة عن كيفية تنفيذه في قواعد بيانات أخرى. إذا وجدت هذا مفيدًا، فجرّب تنفيذه في مشاريعك الخاصة وأخبرنا عن النتائج!