Oggi, nel nostro monorepo abbiamo circa 1,1 milioni di righe di codice TypeScript commit. Il suo ridimensionamento ha comportato una serie di sfide, come il rallentamento del controllo dei tipi, importazioni gonfiate e una tracciabilità del codice sempre più scarsa. Negli ultimi anni abbiamo esplorato e iterato vari modelli di progettazione per migliorare la scalabilità del nostro codice. Uno dei modelli che abbiamo scoperto ci ha aiutato ad alleviare molte delle nostre crescenti difficoltà nell'organizzazione del codice: i registri. È semplice e uno dei modelli più scalabili che abbiamo adottato.

Il nostro codice interno originale per la gestione degli eventi è un ottimo esempio di come questo modello abbia fatto una grande differenza. All'inizio era semplice, ma col tempo ha portato alla creazione di enormi unioni di tipi che rallentavano il controllo dei tipi, file barrel che importavano troppo codice e codice difficile da tracciare a causa dell'eccessiva concatenazione di stringhe. Infine, ha creato attriti tra gli sviluppatori nella manutenzione e nell'aggiunta di nuovi gestori di eventi.

Design originale

A livello generale, il nostro servizio eventi è basato su una coda di messaggi (MQ). Gli eventi vengono registrati e inviati alla MQ da qualsiasi punto del nostro backend (pod API, pod worker, ecc.) e elaborati da un worker una sola volta.

Il design in sé è molto semplice; l'unico vincolo principale è che tutti gli eventi devono essere completamente serializzabili sulla rete. La maggior parte della complessità e della manutenzione deriva dalla sicurezza dei tipi e dall'esperienza degli sviluppatori.


1. Definire un insieme di helper di base:

./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. Definire i tipi di evento in un file condiviso

./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. Definire i gestori di eventi nel proprio file

// ./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. Definire un file di rollup

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

5. Definire i gestori di eventi

./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. Definire una funzione comune per registrare gli eventi

// ./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. Esportare una funzione che elabora il gestore di eventi per la coda in un worker.

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

A prima vista, questo design sembra abbastanza soddisfacente. È sicuro dal punto di vista tipografico, facile da comprendere, semplice da utilizzare e chiaro su come creare nuovi eventi. Tuttavia, presentava diversi problemi:

Complessità del controllo dei tipi: Per ogni nuovo evento, il AppEvent il tipo diventa più grande. Singolarmente, una singola unione non è problematica, ma con l'aumentare dell'uso di questo modello nel codice (unioni di tipi multipli), le prestazioni del controllo dei tipi si riducono rapidamente.

Caricamento del modulo Eager: Il nostro modello di file rollup comportava che quasi tutti i moduli importassero l'intero codice base all'avvio, rendendo impossibile il caricamento lento. Ciò ci impediva anche di suddividere facilmente il server in bundle separati per diverse implementazioni. Sebbene il caricamento rapido dell'intero codice base fosse accettabile in produzione, influiva negativamente sullo sviluppo e sui test, richiedendo oltre 20-30 secondi solo per avviare il server di sviluppo.

Scarsa tracciabilità: Rispondere a domande semplici come "dove viene implementato questo evento?" o "dove viene chiamato questo gestore?" era difficile. Dovevamo affidarci a ricerche full-text di stringhe letterali. Ad esempio, se un ingegnere decideva di fare qualcosa del genere:

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

Sarebbe molto difficile risalire al fatto che il wire_sent e wire_created gli eventi vengono attivati da qui. Cercando la stringa "wire_sent" nel codice base non si otterrebbe questo risultato, poiché il nome è costruito dinamicamente. Di conseguenza, questa informazione diventa una conoscenza "tribale" oscura che finisce per rimanere nella mente di pochi ingegneri selezionati.

Mancanza di confini chiari tra i domini: Con l'introduzione di interfacce più omogenee (gestori di eventi, entità DB, controlli di integrità), è stato incoraggiato il raggruppamento di interfacce simili nella stessa cartella piuttosto che il raggruppamento in base alla logica di dominio. Ciò ha frammentato la nostra logica di business, rendendo il cambio di contesto specifico del dominio più frequente e complesso.

Iterazione su questo progetto

Nella configurazione sopra riportata, abbiamo inserito tutti i gestori di eventi in ./app-event-handler/<event_type>.ts</event_type> file. Pur facilitando la ricerca, il fatto di averli tutti in un'unica cartella non rispecchiava il modo in cui lavoravamo effettivamente. In pratica, raggruppare gli event handler con il resto della logica applicativa pertinente si è rivelato molto più utile che raggrupparli con altri handler.

È qui che nasce l'idea di aggiungere sottoestensioni ai file (.event-handler.ts) è entrato in gioco. Ci hanno permesso di raggruppare i file per dominio, consentendo comunque una facile individuazione tramite la ricerca dell'estensione. L'estensione dei file ci ha inoltre permesso di rimuovere i file di rollup gestiti manualmente, poiché potevamo cercare tutti i file corrispondenti all'estensione nel repository durante l'esecuzione.

Ecco una versione abbreviata del codice di base del registro e come funziona. caricaModuli eseguirà la scansione di tutti i file e registrerà tutti gli oggetti esportati con un $discriminatore proprietà corrispondente allo stesso simbolo passato in creaRegistro.

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

Ecco come appare il nostro gestore di eventi utilizzando i registri:

1. Definire un registro in un <name>.registro.ts</name> file:

./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. Definire gli handler di evento effettivi in .app-event-handler.ts file

// ./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. Definire una funzione comune per registrare gli eventi

./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. Esportare una funzione che elabora il gestore di eventi per la coda in un worker.

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

Alcune differenze importanti da notare:

La tracciabilità del codice è molto miglioreOgni volta che registri un evento, lo registri in questo modo:

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

Ciò significa che è facile rintracciare tutti i luoghi in cui cardCreatedEventHandler viene utilizzato utilizzando strumenti AST come "Trova tutti i riferimenti" (in VS Code). Al contrario, quando si vede un registraEvento chiamata, è possibile "Passare all'implementazione" con un solo clic per trovare la definizione dell'evento e il relativo gestore.

Niente più unioni di tipi: Piuttosto, stiamo usando tipi di base, che è qualcosa che TypeScript incoraggia a evitare i problemi di prestazioni legati al controllo dei tipi che comportano le unioni di grandi dimensioni.

I gestori di eventi sono collocati insieme alla logica specifica del dominio.: Gli handler degli eventi delle app non sono più memorizzati in un'unica cartella. Sono invece collocati insieme alla logica di business pertinente. Ad esempio, un servizio specifico per un dominio potrebbe avere un aspetto simile al seguente:

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

Lavorare con i registri oggi

Oggi collaboriamo con decine di registri per mantenere tutto il codice collocato insieme alla logica delle loro applicazioni. Alcuni dei più importanti sono:

  • .db.ts per la registrazione delle entità del database
  • .flussi di lavoro.ts e .attività.ts per la registrazione Temporale flussi di lavoro
  • .controlli.ts per la registrazione dei controlli sanitari (post sul blog)
  • .main.ts per la registrazione di servizi che raggruppano logiche aziendali specifiche di dominio
  • .permesso-ruolo.ts e .chiave-permesso.ts per definire le autorizzazioni RBAC nel nostro prodotto
  • .casella-posta.ts per registrare gestori che analizzano le e-mail in un account Gmail
  • .cron.ts per registrare i cron job
  • .saldo-contabile.ts per definire la nostra primitiva "registro" finanziario interno
  • .metriche.ts per definire le metriche Datadog

e diverse altre estensioni specifiche di dominio.

Al momento non abbiamo reso open source questo modello, ma speriamo che questo post possa fornire un'idea chiara di come possa essere implementato in altri codici. Se lo avete trovato utile, provate a implementarlo nei vostri progetti e fateci sapere come va!

Read more from us