如今,我们在单仓库中已提交约110万行TypeScript代码。随着规模扩大,我们面临着诸多挑战:类型检查缓慢、导入文件臃肿、代码可追溯性日益恶化。 过去几年间,我们尝试了多种设计模式以优化代码库的可扩展性。其中"注册表"模式意外缓解了我们日益严重的代码组织难题——它不仅简单易用,更是我们采用过的最具扩展性的解决方案之一。

我们最初的内部事件处理代码正是该模式产生巨大影响的典型案例。它最初设计简洁,但随着时间推移,逐渐演变成庞大的类型联合体导致类型检查缓慢,导入过多代码的桶状文件,以及因过度字符串拼接而难以追踪的代码。最后,它还给开发者在维护和添加新事件处理程序时带来了摩擦。

原创设计

从高层次来看,我们的事件服务构建在消息队列(MQ)之上。事件从后端任意位置(API pod、worker pod等)被记录并发送至MQ,随后由worker进行精确一次的处理。

设计本身非常简单;唯一的限制是所有事件在传输过程中都必须完全可序列化。大部分复杂性和维护工作源于类型安全和开发者体验。


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

乍看之下,这个设计还算不错。它具备类型安全特性,易于理解,操作简便,且创建新事件的方式清晰明确。然而,其中存在若干问题:

类型检查复杂度: 对于每个新事件, 应用事件 类型联合体规模不断扩大。单个联合体本身并无问题,但当这种模式在代码库中大量使用(多个类型联合体)时,类型检查性能会迅速下降。

热模块加载: 我们的卷入文件模式意味着几乎每个模块在启动时都会导入整个代码库,这使得延迟加载变得不可能。这也阻碍了我们将服务器轻松拆分为不同部署场景的独立包。虽然在生产环境中加载完整代码库尚可接受,但它严重影响了开发和测试流程——仅启动开发服务器就需要耗费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他们允许我们按域名进行托管,同时仍能通过扩展名轻松发现文件。文件扩展名还让我们得以移除手动维护的汇总文件,因为运行时可扫描仓库中所有匹配该扩展名的文件。

以下是基础注册表代码的简化版本及其工作原理。 加载模块 将扫描所有文件,并为所有导出对象注册一个 $鉴别器 与传递入的相同符号匹配的属性 创建注册表.

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

这意味着很容易追踪所有出现的位置。 卡创建事件处理器 通过使用AST工具(如VS Code中的"查找所有引用")来实现。反之,当你看到一个 记录事件 调用时,您可通过单击“转到实现”快速定位事件定义及其处理程序。

不再有类型联合: 相反,我们使用的是基础类型,这是一种 TypeScript 鼓励避免大型联合类型带来的类型检查性能问题。

事件处理程序与领域特定逻辑共存应用程序事件处理程序不再存储在单个文件夹中,而是与相关业务逻辑共同存放。例如,一个领域特定服务可能如下所示:

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

今日与注册机构合作

如今,我们与数十个注册表合作,确保所有代码与应用程序逻辑保持同地部署。其中一些值得注意的包括:

  • .db.ts 用于注册数据库实体
  • .工作流.ts 以及 .activities.ts 用于注册 时间性的 工作流
  • .检查.ts 用于注册健康检查(博客文章)
  • .main.ts 用于注册将特定于域的业务逻辑组合在一起的服务
  • .权限角色.ts 以及 .权限密钥.ts 用于在我们的产品中定义基于角色的访问控制权限
  • .email-box.ts 用于在Gmail账户中注册解析电子邮件的处理程序
  • .cron.ts 用于注册cron作业
  • .账本余额.ts 用于定义我们内部财务的"分类账"基本单元
  • .metrics.ts 用于定义 Datadog 指标

以及其他若干特定领域的扩展。

目前我们尚未将此模式开源,但希望本文能清晰阐述其在其他代码库中的实现方式。若您觉得本文有所帮助,不妨在自己的项目中尝试实现,并随时告知我们进展!

Read more from us