Heute haben wir etwa 1,1 Millionen Zeilen festgeschriebenen TypeScript-Code in unserem Monorepo. Die Skalierung brachte eine Reihe von Herausforderungen mit sich, wie langsame Typprüfung, aufgeblähte Importe und zunehmend schlechte Code-Rückverfolgbarkeit. In den letzten Jahren haben wir verschiedene Entwurfsmuster untersucht und iteriert, um unsere Codebasis besser skalieren zu können. Ein Muster, auf das wir gestoßen sind, hat uns dabei geholfen, viele unserer wachsenden Probleme bei der Code-Organisation zu lindern: Registries. Es ist einfach und eines der skalierbarsten Muster, die wir eingeführt haben.

Unser ursprünglicher interner Code zur Ereignisbehandlung ist ein gutes Beispiel dafür, wo dieses Muster einen großen Unterschied gemacht hat. Es begann ganz einfach, führte aber im Laufe der Zeit zu riesigen Typvereinigungen, die eine langsame Typprüfung verursachten, zu Barrel-Dateien, die zu viel Code importierten, und zu schwer nachverfolgbarem Code aufgrund übermäßiger Zeichenverkettung. Schließlich führte es zu Reibungsverlusten bei den Entwicklern bei der Pflege und Hinzufügung neuer Ereignisbehandler.

Originaldesign

Auf hoher Ebene basiert unser Ereignisdienst auf einer Nachrichtenwarteschlange (MQ). Ereignisse werden aufgezeichnet und von überall in unserem Backend (API-Pods, Worker-Pods usw.) an die MQ gesendet und von einem Worker genau einmal verarbeitet.

Das Design selbst ist sehr einfach; die einzige wesentliche Einschränkung besteht darin, dass alle Ereignisse vollständig über die Leitung serialisierbar sein müssen. Der Großteil der Komplexität und Wartungsaufwand ergibt sich aus der Typsicherheit und der Entwicklererfahrung.


1. Definieren Sie eine Reihe von Basis-Helfern:

./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. Ereignistypen in einer gemeinsam genutzten Datei definieren

./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. Definieren Sie Ereignisbehandler in einer eigenen Datei.

// ./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. Rollup-Datei definieren

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

5. Ereignisbehandler definieren

./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. Definieren Sie eine gemeinsame Funktion zum Aufzeichnen von Ereignissen.

// ./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. Exportieren Sie eine Funktion, die den Ereignis-Handler für die Warteschlange in einem Worker verarbeitet.

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

Auf den ersten Blick sieht dieses Design ganz gut aus. Es ist typsicher, leicht verständlich, einfach zu handhaben und klar in Bezug auf die Erstellung neuer Ereignisse. Es gab jedoch mehrere Probleme:

Komplexität der Typprüfung: Für jedes neue Ereignis wird die AppEvent Der Typ wird größer. Einzeln betrachtet ist eine einzelne Vereinigung kein Problem, aber wenn die Verwendung dieses Musters im gesamten Code zunimmt (mehrere Typvereinigungen), verschlechtert sich die Leistung der Typprüfung schnell.

Eifriges Laden von Modulen: Unser Rollup-Datei-Muster führte dazu, dass fast jedes Modul beim Start die gesamte Codebasis importierte, was ein verzögertes Laden unmöglich machte. Dies hinderte uns auch daran, den Server einfach in separate Bundles für verschiedene Bereitstellungen aufzuteilen. Während das sofortige Laden der gesamten Codebasis in der Produktion akzeptabel war, hatte es erhebliche Auswirkungen auf die Entwicklung und das Testen, da allein das Starten des Entwicklungsservers 20 bis 30 Sekunden dauerte.

Schlechte Rückverfolgbarkeit: Einfache Fragen wie „Wo wird dieses Ereignis implementiert?“ oder „Wo wird dieser Handler aufgerufen?“ zu beantworten, war schwierig. Wir mussten uns auf Volltextsuchen nach String-Literalen verlassen. Wenn ein Entwickler beispielsweise Folgendes tun wollte:

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

Es wäre sehr schwer nachzuweisen, dass die Draht gesendet und Draht erstellt Ereignisse werden von hier aus ausgelöst. Eine Suche nach der Zeichenfolge „wire_sent” im Code würde diese Verwendung nicht aufdecken, da der Name dynamisch erstellt wird. Infolgedessen wird diese Information zu obskurem „Stammeswissen”, das nur in den Köpfen einiger weniger Ingenieure existiert.

Fehlende klare Domänengrenzen: Durch die Einführung homogenerer Schnittstellen (Ereignisbehandler, DB-Entitäten, Zustandsprüfungen) wurden ähnliche Schnittstellen eher im selben Ordner zusammengefasst, anstatt sie nach Domänenlogik zu gruppieren. Dies führte zu einer Fragmentierung unserer Geschäftslogik, wodurch domänenspezifische Kontextwechsel häufiger und komplexer wurden.

Iteration dieses Designs

In der obigen Konfiguration haben wir alle Ereignisbehandler in ./App-Ereignisbehandler/<event_type>.ts</event_type> Dateien. Obwohl es die Suche erleichterte, alle Dateien in einem Ordner zu speichern, spiegelte dies nicht unsere tatsächliche Arbeitsweise wider. In der Praxis erwies es sich als wesentlich nützlicher, Ereignisbehandler zusammen mit der übrigen relevanten Anwendungslogik zu speichern, als sie mit anderen Handlern zu gruppieren.

Daher kam die Idee, Dateien mit Untererweiterungen zu versehen (.event-handler.ts) kam hinzu. Sie ermöglichten uns die Zusammenlegung nach Domänen und gleichzeitig eine einfache Suche anhand der Erweiterung. Dank der Dateierweiterung konnten wir manuell gepflegte Rollup-Dateien entfernen, da wir zur Laufzeit alle Dateien im Repository scannen konnten, die der Erweiterung entsprachen.

Hier finden Sie eine gekürzte Version des Basis-Registrierungscodes und eine Beschreibung seiner Funktionsweise. Module laden alle Dateien scannen und alle exportierten Objekte mit einem $Diskriminator Eigenschaft, die mit dem gleichen Symbol übereinstimmt, das an createRegistry.

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

So sieht nun die Erstellung unseres Ereignis-Handlers unter Verwendung von Registries aus:

1. Definieren Sie eine Registrierung in einer <name>.registry.ts</name> Datei:

./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. Definieren Sie die tatsächlichen Ereignisbehandler in .app-event-handler.ts Dateien

// ./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. Definieren Sie eine gemeinsame Funktion zum Aufzeichnen von Ereignissen.

./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. Exportieren Sie eine Funktion, die den Ereignis-Handler für die Warteschlange in einem Worker verarbeitet.

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

Einige wichtige Unterschiede, die zu beachten sind:

Die Rückverfolgbarkeit des Codes ist viel besser.: Wenn Sie ein Ereignis aufzeichnen, tun Sie dies wie folgt:

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

Das bedeutet, dass es einfach ist, alle Orte zu verfolgen, an denen cardCreatedEventHandler wird mithilfe von AST-Tools wie „Alle Referenzen suchen“ (in VS Code) verwendet. Umgekehrt, wenn Sie ein Ereignis aufzeichnen Aufruf können Sie mit einem Klick „Zur Implementierung gehen“ auswählen, um die Ereignisdefinition und ihren Handler zu finden.

Keine Typvereinigungen mehrVielmehr verwenden wir Basistypen, was etwas ist, das TypeScript empfiehlt, Leistungsprobleme bei der Typprüfung zu vermeiden, die durch große Vereinigungen entstehen.

Ereignisbehandler befinden sich zusammen mit domänenspezifischer Logik an einem Ort.App-Ereignisbehandler werden nicht mehr in einem einzigen Ordner gespeichert. Stattdessen werden sie zusammen mit der entsprechenden Geschäftslogik abgelegt. Ein domänenspezifischer Dienst könnte beispielsweise wie folgt aussehen:

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

Arbeiten mit Registern heute

Heute arbeiten wir mit Dutzenden von Registern zusammen, um den gesamten Code zusammen mit ihrer Anwendungslogik zu speichern. Zu den bekanntesten gehören:

  • .db.ts für die Registrierung von Datenbankentitäten
  • .workflows.ts und .Aktivitäten.ts für die Registrierung Zeitlich Arbeitsabläufe
  • .checks.ts für die Registrierung von Gesundheitschecks (Blogbeitrag)
  • .main.ts für die Registrierung von Diensten, die domänenspezifische Geschäftslogik zusammenfassen
  • .berechtigungsrolle.ts und .permission-key.ts zur Definition von RBAC-Berechtigungen in unserem Produkt
  • .email-box.ts zum Registrieren von Handlern, die E-Mails in einem Gmail-Konto analysieren
  • .cron.ts für die Registrierung von Cron-Jobs
  • .ledger-balance.ts zur Definition unserer internen Finanz-„Hauptbuch“-Primitive
  • .metriken.ts zur Definition von Datadog-Metriken

und mehrere andere domänenspezifische Erweiterungen.

Zum jetzigen Zeitpunkt haben wir dieses Muster noch nicht als Open Source veröffentlicht, aber hoffentlich vermittelt dieser Beitrag eine klare Vorstellung davon, wie es in anderen Codebasen implementiert werden kann. Wenn Sie dies nützlich fanden, probieren Sie es doch einmal in Ihren eigenen Projekten aus und teilen Sie uns Ihre Erfahrungen mit!

Read more from us