Hiện tại, chúng tôi có khoảng 1,1 triệu dòng mã TypeScript đã được cam kết trong kho mã đơn (monorepo) của mình. Việc mở rộng quy mô đã mang lại nhiều thách thức như kiểm tra kiểu chậm, các import phình to và khả năng theo dõi mã ngày càng kém. Trong vài năm qua, chúng tôi đã nghiên cứu và thử nghiệm nhiều mẫu thiết kế khác nhau để cải thiện khả năng mở rộng của kho mã. Một mẫu thiết kế mà chúng tôi tình cờ phát hiện ra đã giúp giảm bớt nhiều vấn đề về tổ chức mã nguồn ngày càng phức tạp: Registries. Đây là một mẫu thiết kế đơn giản và là một trong những mẫu thiết kế có khả năng mở rộng nhất mà chúng tôi đã áp dụng.

Mã xử lý sự kiện nội bộ ban đầu của chúng tôi là một ví dụ điển hình về nơi mẫu thiết kế này đã tạo ra sự khác biệt lớn. Ban đầu nó rất đơn giản, nhưng theo thời gian đã dẫn đến các kiểu dữ liệu hợp nhất khổng lồ gây chậm quá trình kiểm tra kiểu, các tệp barrel nhập quá nhiều mã, và mã khó theo dõi do việc nối chuỗi quá mức. Cuối cùng, nó gây ra sự cản trở cho nhà phát triển trong việc duy trì và thêm các trình xử lý sự kiện mới.

Thiết kế gốc

Ở mức cao, dịch vụ sự kiện của chúng tôi được xây dựng dựa trên một hàng đợi tin nhắn (MQ). Các sự kiện được ghi lại và gửi đến MQ từ bất kỳ đâu trong hệ thống backend của chúng tôi (API pods, worker pods, v.v.) và được xử lý bởi một worker chính xác một lần.

Thiết kế này rất đơn giản; yêu cầu chính duy nhất là tất cả các sự kiện phải được truyền tải một cách tuần tự qua mạng. Hầu hết sự phức tạp và công việc bảo trì xuất phát từ tính an toàn kiểu dữ liệu và trải nghiệm của nhà phát triển.


1. Xác định một tập hợp các hàm trợ giúp cơ bản:

./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. Định nghĩa các loại sự kiện trong một tệp chia sẻ.

./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. Định nghĩa các trình xử lý sự kiện trong tệp riêng của chúng.

// ./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. Định nghĩa tệp tổng hợp

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

5. Định nghĩa các trình xử lý sự kiện

./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. Định nghĩa một hàm chung để ghi lại các sự kiện.

// ./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. Xuất khẩu một hàm xử lý trình xử lý sự kiện cho hàng đợi trong một công nhân.

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

Nhìn chung, thiết kế này trông khá ổn. Nó an toàn về kiểu dữ liệu, dễ hiểu, dễ làm việc và rõ ràng về cách tạo sự kiện mới. Tuy nhiên, có một số vấn đề:

Độ phức tạp của kiểm tra kiểu: Đối với mỗi sự kiện mới, Sự kiện Ứng dụng Loại dữ liệu trở nên phức tạp hơn. Mỗi liên hợp loại dữ liệu riêng lẻ không gây vấn đề, nhưng khi việc sử dụng mẫu này tăng lên trong toàn bộ mã nguồn (nhiều liên hợp loại dữ liệu), hiệu suất kiểm tra loại dữ liệu nhanh chóng suy giảm.

Tải mô-đun nhanh chóng: Mô hình tệp rollup của chúng tôi khiến gần như mọi mô-đun đều nhập toàn bộ mã nguồn khi khởi động, khiến việc tải chậm (lazy-loading) trở nên bất khả thi. Điều này cũng ngăn cản chúng tôi chia tách máy chủ thành các gói riêng biệt cho các triển khai khác nhau. Mặc dù việc tải toàn bộ mã nguồn (eager-loading) là chấp nhận được trong môi trường sản xuất, nó đã ảnh hưởng nghiêm trọng đến quá trình phát triển và kiểm thử, mất hơn 20–30 giây chỉ để khởi động máy chủ phát triển.

Khả năng truy xuất nguồn gốc kém: Trả lời các câu hỏi đơn giản như "sự kiện này được thực thi ở đâu?" hoặc "hàm xử lý này được gọi ở đâu?" là rất khó khăn. Chúng tôi phải dựa vào việc tìm kiếm toàn văn bản của các chuỗi văn bản. Ví dụ, nếu một kỹ sư quyết định làm điều gì đó như sau:

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

Sẽ rất khó để xác định rằng dây đã gửidây_được_tạo Các sự kiện được kích hoạt từ đây. Việc tìm kiếm chuỗi "wire_sent" trong mã nguồn sẽ không hiển thị cách sử dụng này, vì tên được tạo động. Kết quả là, thông tin này trở thành kiến thức "bí truyền" chỉ được một số ít kỹ sư biết đến.

Thiếu ranh giới rõ ràng giữa các miền: Khi chúng tôi giới thiệu các giao diện đồng nhất hơn (người xử lý sự kiện, thực thể cơ sở dữ liệu, kiểm tra sức khỏe), điều này khuyến khích việc đặt các giao diện tương tự vào cùng một thư mục thay vì nhóm theo logic miền. Điều này đã làm phân mảnh logic kinh doanh của chúng tôi, khiến việc chuyển đổi ngữ cảnh theo miền trở nên thường xuyên và phức tạp hơn.

Tiếp tục hoàn thiện thiết kế này

Trong cấu hình trên, chúng tôi đặt tất cả các trình xử lý sự kiện vào ./app-event-handlers/<event_type>.ts</event_type> Tệp tin. Mặc dù việc lưu trữ tất cả tệp tin trong một thư mục giúp việc tìm kiếm trở nên dễ dàng, nhưng điều này không phản ánh cách chúng ta thực sự làm việc. Trên thực tế, việc đặt các trình xử lý sự kiện cùng với phần logic ứng dụng liên quan đã chứng minh là hữu ích hơn nhiều so với việc nhóm chúng với các trình xử lý khác.

Đó là nơi nảy sinh ý tưởng thêm các phần mở rộng con vào tệp (.event-handler.ts) đã được triển khai. Họ cho phép chúng tôi chia sẻ không gian lưu trữ theo tên miền đồng thời vẫn cho phép tìm kiếm dễ dàng bằng cách tra cứu phần mở rộng. Phần mở rộng tệp cũng cho phép chúng tôi loại bỏ các tệp tổng hợp được quản lý thủ công vì chúng tôi có thể quét tất cả các tệp khớp với phần mở rộng trong kho lưu trữ tại thời điểm chạy.

Dưới đây là phiên bản tóm tắt của mã đăng ký cơ sở và cách thức hoạt động của nó. Tải các mô-đun Sẽ quét tất cả các tệp và đăng ký tất cả các đối tượng được xuất với một $phân biệt Tài sản khớp với cùng biểu tượng được truyền vào Tạo sổ đăng ký.

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

Dưới đây là cách xây dựng trình xử lý sự kiện của chúng ta bằng cách sử dụng Registries:

1. Định nghĩa một sổ đăng ký trong một <name>.registry.ts</name> tệp:

./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. Định nghĩa các trình xử lý sự kiện thực tế trong .app-event-handler.ts tệp tin

// ./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. Định nghĩa một hàm chung để ghi lại các sự kiện.

./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. Xuất khẩu một hàm xử lý trình xử lý sự kiện cho hàng đợi trong một công nhân.

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

Một số điểm khác biệt quan trọng cần lưu ý:

Khả năng truy vết mã nguồn được cải thiện đáng kể.: Bất cứ khi nào bạn ghi lại một sự kiện, bạn ghi lại nó như sau:

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

Điều này có nghĩa là việc theo dõi tất cả các vị trí nơi Xử lý sự kiện được tạo bởi thẻ được sử dụng bằng cách sử dụng các công cụ AST như "Tìm tất cả các tham chiếu" (trong VS Code). Ngược lại, khi bạn thấy một Ghi lại sự kiện Khi gọi, bạn có thể nhấp vào "Đi đến triển khai" để tìm định nghĩa sự kiện và trình xử lý của nó.

Không còn các liên minh ngành nghềThay vào đó, chúng tôi đang sử dụng các loại cơ sở, đây là một khái niệm mà... TypeScript Khuyến khích tránh các vấn đề về hiệu suất do việc kiểm tra kiểu gây ra trong các union lớn.

Các trình xử lý sự kiện được đặt cùng với logic cụ thể của miền.Các trình xử lý sự kiện ứng dụng không còn được lưu trữ trong một thư mục duy nhất. Thay vào đó, chúng được đặt cùng với logic kinh doanh liên quan. Ví dụ, một dịch vụ chuyên biệt cho một miền có thể trông giống như sau:

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

Làm việc với các cơ quan đăng ký hiện nay

Hiện nay, chúng tôi hợp tác với hàng chục hệ thống đăng ký để đảm bảo rằng tất cả mã nguồn được lưu trữ cùng với logic ứng dụng của họ. Một số hệ thống đáng chú ý bao gồm:

  • .db.ts Để đăng ký các thực thể cơ sở dữ liệu
  • .workflows.ts.hoạt động.ts Để đăng ký Thời gian Quy trình làm việc
  • .kiểm tra.ts Để đăng ký kiểm tra sức khỏe (bài đăng trên blog)
  • .main.ts Để đăng ký các dịch vụ nhóm lại các logic kinh doanh chuyên biệt theo lĩnh vực.
  • .quyền-vai-trò.ts.permission-key.ts Để định nghĩa quyền RBAC trong sản phẩm của chúng tôi
  • .email-box.ts Để đăng ký các trình xử lý (handlers) phân tích email trong tài khoản Gmail.
  • .cron.ts Để đăng ký các tác vụ cron
  • .sổ cái - số dư.ts Để định nghĩa nguyên thủy sổ cái tài chính nội bộ của chúng ta.
  • .metrics.ts Để định nghĩa các chỉ số Datadog

và một số tiện ích mở rộng chuyên dụng khác.

Tại thời điểm này, chúng tôi chưa công bố mã nguồn mở cho mẫu thiết kế này, nhưng hy vọng bài viết này sẽ cung cấp một cái nhìn rõ ràng về cách nó có thể được triển khai trong các dự án khác. Nếu bạn thấy điều này hữu ích, hãy thử triển khai nó trong các dự án của riêng bạn và cho chúng tôi biết kết quả như thế nào!

Read more from us