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

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

التصميم الأصلي

على مستوى عالٍ، يتم إنشاء خدمة الأحداث الخاصة بنا على أساس قائمة انتظار الرسائل (MQ). يتم تسجيل الأحداث وإرسالها إلى قائمة انتظار الرسائل من أي مكان في الخلفية (API pods، worker pods، إلخ) ويتم معالجتها من قبل عامل مرة واحدة فقط.

التصميم بحد ذاته بسيط للغاية؛ والقيود الرئيسية الوحيدة هي أن جميع الأحداث يجب أن تكون قابلة للتسلسل بالكامل عبر الشبكة. تنبع معظم التعقيدات والصيانة من أمان الأنواع وتجربة المطورين.


1. حدد مجموعة من المساعدات الأساسية:

./app-event-handler/base.ts
export interface AppEventBase {
  type: string;
}
 
export interface AppEventHandler<Ev extends AppEventBase> {
  type: Ev['type'];
  handler: (data: Ev) => Promise<void>
}
 
export function createAppEventHandler<Ev extends AppEventBase>(
  event: Ev['type'],
  handler: (data: Ev) => Promise<void>
): AppEventHandler<Ev> {
  return { type: event, handler }
}

2. تحديد أنواع الأحداث في ملف مشترك

./app-event-handlers/types.ts
import type { AppEventBase } from '../base.ts';
 
export interface CardCreatedEvent extends AppEventBase {
  type: 'card_created';
  cardName: string;
}
 
export interface WireSentEvent extends AppEventBase {
  type: 'wire_sent';
  data: {
    amount: number;
  }
}
 
export type AppEvent = CardCreatedEvent | WireSentEvent;

3. تعريف معالجات الأحداث في ملفها الخاص

// ./app-event-handlers/card.ts    
import type { SpecificEventHandler } from './base.ts';
 
export const cardCreatedEventHandler: SpecificEventHandler<'card_created'> = async (ev) => {
  await sendNotificationToUsers();
}
 
// ./app-event-handlers/wire.ts
import type { SpecificEventHandler } from './base.ts';
 
export const wireSentEventHandler: SpecificEventHandler<'wire_sent'> = async (ev) => {
  await sendNotificationToUsers();
}

4. تعريف ملف تجميع

./app-event-handlers/rollup.ts
export * from './card.ts';
export * from './wire.ts';

5. تحديد معالجات الأحداث

./app-event-handlers/index.ts
import type { AppEventBase } from './base.ts';
import * as AppEventHandlers from './rollup.ts';
 
export type AppEvent = {
  [Key in keyof typeof EventHandlers]: 
    (typeof EventHandlers)[Key] extends AppEventHandler<infer Ev extends AppEventBase>
      ? Ev
      : never;
}[keyof typeof EventHandlers];
 
export { AppEventHandlers };

6. تحديد وظيفة مشتركة لتسجيل الأحداث

// ./record-app-event.ts
import { mqClient } from '@server/message-queue/client.ts';
import type { AppEvent } from './app-event-handlers'
 
export async function recordEvent<Ev extends AppEvent>(ev: Ev) {
  await mqClient.push('events', ev);
}
 
// ./some-random-service-file.ts
import { recordEvent } from './record-app-event.ts';
 
await recordEvent({ type: 'card_created', data: { cardName: 'John Doe' } });

7. تصدير وظيفة تعالج معالج الأحداث للطابور في عامل

./process-app-event.ts
import { AppEventHandlers, type AppEvent } from './app-event-handlers/index.ts';
 
const appEventHandlerMap = new Map(
  Object.values(AppEventHandlers).map(item => [item.type, item.handler])
);
 
export async function processAppEventHandler(ev: AppEvent) {
  switch (ev.type) {
    case 'card_created':
      await AppEventHandlers.cardCreatedEventHandler(ev);
      break;
    case 'wire_sent':
      await AppEventHandlers.wireSentEventHandler(ev);
      break;
    default:
      // Compile-time & runtime check to ensure this case is unreachable (aka never)
      assertUnreachable(ev.type);
  }
}

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

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

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

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

const eventName = `wire_${type}`; // Valid because type is a union between 'created' | 'sent', which are both defined events
 
await recordEvent({ type: eventName, data });

سيكون من الصعب للغاية تتبع ذلك wire_sent و wire_created يتم تشغيل الأحداث من هنا. لن يكشف البحث عن السلسلة "wire_sent" في قاعدة الكود عن هذا الاستخدام، لأن الاسم يتم إنشاؤه ديناميكيًا. ونتيجة لذلك، تصبح هذه المعلومات معرفة "قبلية" غامضة لا يعرفها سوى عدد قليل من المهندسين.

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

تكرار هذا التصميم

في الإعداد أعلاه، وضعنا جميع معالجات الأحداث في ./معالجات أحداث التطبيق/<event_type>.ts</event_type> الملفات. على الرغم من أن وجودها جميعًا في مجلد واحد سهّل العثور عليها، إلا أنه لم يعكس طريقة عملنا الفعلية. في الممارسة العملية، ثبت أن وضع معالجات الأحداث مع بقية منطق التطبيق ذي الصلة كان أكثر فائدة من تجميعها مع معالجات أخرى.

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

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

./registry.ts
interface Registry<T> {
  loadModules(): Promise<void>;
  get<Throws extends boolean>(key: string, options?: { throws?: Throws }): boolean extends Throws ? T | undefined : T;
}
export function createRegistry<T extends { $discriminator: symbol }>(options: {
  discriminator: T['$discriminator'];
  registryExtension: `.${string}.ts`;
  getKey: (value: T) => string;
}): Registry<T> {
  // implementation ...
}

الآن، فيما يلي شكل معالج الأحداث الخاص بنا باستخدام السجلات:

1. تعريف سجل في <name>.registry.ts</name> ملف:

./app-event-handler.registry.ts
import { createRegistry } from '@/registry';
 
const appEventHandlerDiscriminator = Symbol('app-event-handler');
export const appEventHandlerRegistry = createRegistry<AppEventHandler<AppEventBase>>({
  // When importing a module, a discriminator symbol to identify that an import is relevant to the registry
  discriminator: appEventHandlerDiscriminator,
  // The subextension to search for
  registryExtension: '.app-event-handler.ts',
  // Allows a lookup key to be derived from a module
  getKey: (mod) => mod.type,
});
 
export interface AppEventBase {
  type: string;
  data: unknown;
}
 
/**
  * Define an interface that exposes a `$discriminator` prop
  */
export interface AppEventHandler<Ev extends AppEventBase> {
  $discriminator: typeof appEventHandlerDiscriminator;
  type: Ev['type'];
  handler: (data: Ev['data']) => Promise<void>;
}
 
/**
  * Define the method we'll actually use to create each event handler
*/
export function createAppEventHandler<Ev extends AppEventBase>(
  event: Ev['type'],
  handler: (data: Ev['data']) => Promise<void>
): AppEventHandler<Ev> {
  return { $discriminator: appEventHandlerDiscriminator, type: event, handler }
}

2. حدد معالجات الأحداث الفعلية في .app-event-handler.ts الملفات

// ./card-service/card.app-event-handler.ts
import { createAppEventHandler } from '@/app-event-handler.registry';
 
interface CardCreatedEvent extends AppEventBase {
  type: 'card_created';
  data: {
    cardName: string;
  }
}
 
export const cardCreatedEventHandler = createAppEventHandler<CardCreatedEvent>(
  'card_created',
  async (ev) => {
    /**
    * ev looks like:
    * { 
    *   type: 'card_created',
    *   data: {
    *     cardName: string
    *   }
    * }
    **/
    await sendNotificationToUsers();
  }
);
 
// ./transfers/wire.app-event-handler.ts
import { createAppEventHandler } from '@/app-event-handler.registry';
 
interface WireSentEvent extends AppEventBase {
  type: 'wire_sent';
  data: {
    amount: number;
  }
}
 
export const wireSentEventHandler = createAppEventHandler<WireSentEvent>(
  'wire_sent',
  async (ev) => {
    // ...
  }
)

3. تحديد وظيفة مشتركة لتسجيل الأحداث

./record-app-event.ts
import { mqClient } from '@server/message-queue/client.ts';
import type { AppEvent } from './app-event-handlers'
 
export async function recordEvent<Ev extends AppEvent>(
  ev: AppEventHandler<Ev>, 
  data: Ev
) {
  await mqClient.push('events', { type: ev.type, data });
}

4. تصدير دالة تعالج معالج الأحداث للطابور في عامل

./process-app-event.ts
import { type AppEventBase, appEventHandlerRegistry } from './app-event-handler.registry';
 
export async function processAppEventHandler(ev: AppEventBase) {
  // Here, the registry allows us to look up by the serialized type name, and then call the corresponding handler.
  const item = appEventHandlerRegistry.get(ev.type, { throws: true });
 
  await item.handler(ev);
}

بعض الاختلافات المهمة التي يجب ملاحظتها:

تتبع الكود أفضل بكثير: كلما قمت بتسجيل حدث، قم بتسجيله على النحو التالي:

await recordEvent(cardCreatedEventHandler, { cardName: 'John Doe' })

هذا يعني أنه من السهل تتبع جميع الأماكن التي cardCreatedEventHandler يتم استخدامه باستخدام أدوات AST مثل "Find all references" (في VS Code). على العكس، عندما ترى تسجيل الحدث نداء، يمكنك "الانتقال إلى التنفيذ" بنقرة واحدة للعثور على تعريف الحدث ومعالجته.

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

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

/ services
  / card-service
    - card-service.main.ts
    - card-lifecycle.app-event-handler.ts
    - card.db.ts
    ...

العمل مع السجلات اليوم

اليوم، نحن نعمل مع العشرات من السجلات للحفاظ على جميع الأكواد في نفس مكان منطق التطبيقات. ومن أبرزها ما يلي:

  • .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

وعدة امتدادات أخرى خاصة بمجالات معينة.

في الوقت الحالي، لم ننشر هذا النمط كمصدر مفتوح، ولكن نأمل أن يوفر هذا المنشور فكرة واضحة عن كيفية تنفيذه في قواعد بيانات أخرى. إذا وجدت هذا مفيدًا، فجرّب تنفيذه في مشاريعك الخاصة وأخبرنا عن النتائج!

Read more from us