วันนี้ เรามีโค้ด TypeScript ที่ถูกคอมมิตแล้วประมาณ 1.1 ล้านไลน์ในโมโนรีโปของเรา การขยายขนาดนี้มาพร้อมกับความท้าทายหลากหลาย เช่น การตรวจสอบประเภทข้อมูลที่ช้า การนำเข้าไฟล์ที่มากเกินไป และการติดตามโค้ดที่ยากขึ้นเรื่อย ๆ ในช่วงไม่กี่ปีที่ผ่านมา เราได้สำรวจและปรับปรุงรูปแบบการออกแบบต่างๆ เพื่อขยายขนาดโค้ดเบสของเราให้ดียิ่งขึ้น รูปแบบหนึ่งที่พวกเราได้พบโดยบังเอิญช่วยบรรเทาปัญหาการจัดระเบียบโค้ดที่เพิ่มขึ้นของเราได้มากมาย: ระบบทะเบียน (Registries) มันเรียบง่ายและเป็นหนึ่งในรูปแบบที่ขยายขนาดได้ดีที่สุดที่เราได้นำมาใช้

โค้ดการจัดการเหตุการณ์ภายในต้นฉบับของเราเป็นตัวอย่างที่ดีของที่รูปแบบนี้สร้างความแตกต่างอย่างมาก มันเริ่มต้นอย่างง่าย แต่เมื่อเวลาผ่านไปก็กลายเป็นยูเนียนประเภทขนาดใหญ่ที่ทำให้การตรวจสอบประเภทช้าลง ไฟล์ขนาดใหญ่ที่นำเข้าโค้ดมากเกินไป และโค้ดที่ยากต่อการติดตามเนื่องจากการต่อสตริงมากเกินไป สุดท้าย มันยังสร้างความขัดแย้งให้กับนักพัฒนาในการบำรุงรักษาและเพิ่มตัวจัดการเหตุการณ์ใหม่

การออกแบบดั้งเดิม

ในระดับสูง บริการอีเวนต์ของเราถูกสร้างขึ้นบนระบบคิวข้อความ (MQ) อีเวนต์จะถูกบันทึกและส่งไปยัง 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);
  }
}

เมื่อมองแวบแรก การออกแบบนี้ดูโอเคดี มันปลอดภัยทางประเภท เข้าใจง่าย ใช้งานสะดวก และชัดเจนเกี่ยวกับวิธีการสร้างเหตุการณ์ใหม่ อย่างไรก็ตาม มีปัญหาหลายประการ:

ความซับซ้อนของการตรวจสอบประเภท: สำหรับแต่ละเหตุการณ์ใหม่, แอปอีเวนต์ ประเภทจะใหญ่ขึ้น เมื่อพิจารณาเป็นรายบุคคล การรวมประเภทเดียวไม่ใช่ปัญหา แต่เมื่อการใช้งานรูปแบบนี้เพิ่มขึ้นทั่วทั้งโค้ดเบส (การรวมประเภทหลายรายการ) ประสิทธิภาพของการตรวจสอบประเภทจะลดลงอย่างรวดเร็ว

การโหลดโมดูลแบบกระตือรือร้น: รูปแบบของไฟล์ rollup ของเราทำให้เกือบทุกโมดูลนำเข้าโค้ดทั้งหมดตั้งแต่เริ่มต้น ซึ่งทำให้การโหลดแบบ lazy-loading เป็นไปไม่ได้ นอกจากนี้ยังทำให้เราไม่สามารถแยกเซิร์ฟเวอร์ออกเป็นชุดย่อยสำหรับการปรับใช้ที่แตกต่างกันได้อย่างง่ายดาย แม้ว่าการโหลดโค้ดทั้งหมดแบบ eager-loading จะยอมรับได้ในสภาพแวดล้อมการผลิต แต่มันส่งผลกระทบอย่างรุนแรงต่อการพัฒนาและการทดสอบ โดยใช้เวลาเกิน 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" ในโค้ดเบสจะไม่พบการใช้งานนี้ เนื่องจากชื่อถูกสร้างขึ้นแบบไดนามิก ส่งผลให้ข้อมูลนี้กลายเป็นความรู้เฉพาะกลุ่มที่คลุมเครือซึ่งอยู่ในหัวของวิศวกรเพียงไม่กี่คนเท่านั้น

การขาดขอบเขตของโดเมนที่ชัดเจน: เมื่อเราแนะนำอินเทอร์เฟซที่มีความเหมือนกันมากขึ้น (เช่น ตัวจัดการเหตุการณ์, เอนทิตีฐานข้อมูล, การตรวจสอบสถานะ) มันส่งเสริมให้มีการจัดวางอินเทอร์เฟซที่คล้ายกันไว้ในโฟลเดอร์เดียวกันแทนที่จะจัดกลุ่มตามตรรกะของโดเมน สิ่งนี้ทำให้ตรรกะทางธุรกิจของเราแตกเป็นเสี่ยง ๆ ส่งผลให้การสลับบริบทเฉพาะโดเมนเกิดขึ้นบ่อยและซับซ้อนมากขึ้น

การปรับปรุงการออกแบบนี้

ในการตั้งค่าข้างต้น เราได้วางตัวจัดการเหตุการณ์ทั้งหมดไว้ใน ./app-event-handlers/<event_type>.ts</event_type> ไฟล์. แม้ว่าการเก็บไฟล์ทั้งหมดไว้ในโฟลเดอร์เดียวจะช่วยให้ค้นหาได้ง่าย แต่ก็ไม่สะท้อนถึงวิธีการทำงานของเราจริง ๆ ในทางปฏิบัติ การจัดวางตัวจัดการเหตุการณ์ไว้กับตรรกะการทำงานที่เกี่ยวข้องของแอปพลิเคชันนั้น ๆ ปรากฏว่ามีประโยชน์มากกว่าการจัดกลุ่มไว้กับตัวจัดการอื่น ๆ

นั่นคือที่มาของแนวคิดในการเพิ่มส่วนขยายย่อยให้กับไฟล์ (.event-handler.ts) เข้ามา พวกเขาอนุญาตให้เราใช้โดเมนร่วมกันได้ ในขณะที่ยังคงสามารถค้นหาได้ง่ายโดยการดูส่วนต่อท้ายของชื่อไฟล์ ส่วนต่อท้ายของไฟล์ยังช่วยให้เราสามารถลบไฟล์รวมที่จัดการด้วยตนเองออกได้ เนื่องจากเราสามารถสแกนหาไฟล์ทั้งหมดที่ตรงกับส่วนต่อท้ายของชื่อไฟล์ในคลังข้อมูลได้ในขณะทำงาน

นี่คือเวอร์ชันย่อของรหัสทะเบียนฐานและวิธีการทำงาน โหลดโมดูล จะสแกนไฟล์ทั้งหมดและลงทะเบียนวัตถุที่ส่งออกทั้งหมดกับ $discriminator ทรัพย์สินที่ตรงกับสัญลักษณ์เดียวกันที่ถูกส่งเข้ามา สร้างทะเบียน.

./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 ...
}

ต่อไปนี้คือลักษณะของตัวจัดการเหตุการณ์ที่เราสร้างขึ้นโดยใช้ Registries:

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' })

ซึ่งหมายความว่าสามารถติดตามทุกสถานที่ได้อย่างง่ายดาย เหตุการณ์การ์ดสร้างขึ้น_Handler ใช้โดยใช้เครื่องมือ AST เช่น "ค้นหาการอ้างอิงทั้งหมด" (ใน VS Code) ในทางกลับกัน เมื่อคุณเห็น บันทึกเหตุการณ์ เรียก, คุณสามารถ "ไปที่การใช้งาน" ได้ในคลิกเดียวเพื่อค้นหาการกำหนดเหตุการณ์และผู้จัดการของมัน.

ไม่ต้องใช้การรวมประเภทอีกต่อไป: แต่เราใช้ประเภทพื้นฐาน ซึ่งเป็นสิ่งที่ TypeScript สนับสนุนให้หลีกเลี่ยงปัญหาด้านประสิทธิภาพที่เกิดจากการตรวจสอบประเภทข้อมูลซึ่งการรวมข้อมูลขนาดใหญ่ก่อให้เกิด

ตัวจัดการเหตุการณ์จะอยู่ร่วมกับตรรกะเฉพาะโดเมน: ตัวจัดการเหตุการณ์ของแอปจะไม่ถูกเก็บไว้ในโฟลเดอร์เดียวอีกต่อไป แต่จะถูกจัดเก็บไว้เคียงข้างกับตรรกะทางธุรกิจที่เกี่ยวข้องแทน ตัวอย่างเช่น บริการเฉพาะโดเมนอาจมีลักษณะดังนี้:

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

การทำงานกับทะเบียนในวันนี้

วันนี้ เราทำงานร่วมกับทะเบียนหลายสิบแห่งเพื่อให้แน่ใจว่าโค้ดทั้งหมดถูกจัดเก็บไว้ร่วมกับตรรกะของแอปพลิเคชันของพวกเขา บางแห่งที่น่าสนใจได้แก่:

  • .db.ts สำหรับการลงทะเบียนเอนทิตีฐานข้อมูล
  • .workflows.ts และ กิจกรรม.ts สำหรับการลงทะเบียน ชั่วคราว เวิร์กโฟลว์
  • .ตรวจสอบ.ts สำหรับการลงทะเบียนการตรวจสุขภาพ (โพสต์บล็อก)
  • .main.ts สำหรับการลงทะเบียนบริการที่รวบรวมตรรกะทางธุรกิจเฉพาะโดเมนเข้าด้วยกัน
  • .permission-role.ts และ .permission-key.ts สำหรับการกำหนดสิทธิ์ RBAC ในผลิตภัณฑ์ของเรา
  • .กล่องจดหมาย.ts สำหรับการลงทะเบียนตัวจัดการที่แยกวิเคราะห์อีเมลในบัญชี Gmail
  • .cron.ts สำหรับการลงทะเบียนงาน cron
  • .ledger-balance.ts สำหรับการกำหนด "บัญชีแยกประเภท" ภายในทางการเงินของเรา
  • .เมตริกส์.ts สำหรับการกำหนดเมตริกของ Datadog

และส่วนขยายเฉพาะโดเมนอื่น ๆ อีกหลายรายการ

ณ จุดนี้ เรายังไม่ได้เปิดเผยรูปแบบนี้แบบโอเพนซอร์ส แต่หวังว่าโพสต์นี้จะให้แนวคิดที่ชัดเจนเกี่ยวกับวิธีการนำไปใช้ในโค้ดเบสอื่นๆ หากคุณพบว่าข้อมูลนี้มีประโยชน์ ลองนำไปใช้ในโปรเจกต์ของคุณเองและแจ้งให้เราทราบผลลัพธ์ด้วย!

Read more from us