Hari ini, kami memiliki sekitar 1,1 juta baris kode TypeScript yang telah dikomitmen di monorepo kami. Skalabilitasnya membawa berbagai tantangan, seperti pengecekan tipe yang lambat, impor yang membengkak, dan traceability kode yang semakin buruk. Selama beberapa tahun terakhir, kami telah mengeksplorasi dan menguji berbagai pola desain untuk meningkatkan skalabilitas basis kode kami. Salah satu pola yang kami temukan membantu meredakan banyak masalah organisasi kode yang semakin kompleks: Registries. Pola ini sederhana dan merupakan salah satu pola paling skalabel yang kami adopsi.

Kode penanganan acara internal asli kami merupakan contoh yang baik tentang bagaimana pola ini membuat perbedaan yang signifikan. Awalnya sederhana, tetapi seiring waktu, hal ini menyebabkan union tipe yang besar yang mengakibatkan pemeriksaan tipe yang lambat, berkas barrel yang mengimpor terlalu banyak kode, dan kode yang sulit dilacak akibat penggabungan string yang berlebihan. Terakhir, hal ini menimbulkan gesekan bagi pengembang dalam memelihara dan menambahkan penangan acara baru.

Desain asli

Secara umum, layanan acara kami dibangun di atas antrian pesan (MQ). Acara-acara direkam dan dikirim ke MQ dari mana saja di backend kami (pod API, pod pekerja, dll.) dan diproses oleh pekerja tepat sekali.

Desainnya sendiri sangat sederhana; satu-satunya batasan utama adalah bahwa semua peristiwa harus sepenuhnya dapat diserialisasikan melalui jaringan. Sebagian besar kompleksitas dan pemeliharaan berasal dari keamanan tipe dan pengalaman pengembang.


1. Tentukan sekumpulan fungsi bantu dasar:

./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. Tentukan jenis acara dalam berkas bersama

./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. Tentukan penangan acara (event handlers) dalam berkas tersendiri.

// ./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. Tentukan berkas rollup

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

5. Tentukan penangan acara

./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. Tentukan fungsi umum untuk mencatat peristiwa.

// ./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. Ekspor fungsi yang memproses penangan acara untuk antrian di pekerja.

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

Secara sekilas, desain ini terlihat cukup baik. Desain ini aman tipe, mudah dipahami, sederhana untuk digunakan, dan jelas tentang cara membuat acara baru. Namun, ada beberapa masalah:

Kompleksitas pengecekan tipe: Untuk setiap acara baru, Acara Aplikasi Tipe menjadi lebih besar. Secara individual, satu union tidak menjadi masalah, tetapi seiring dengan meningkatnya penggunaan pola ini di seluruh basis kode (beberapa union tipe), kinerja pengecekan tipe menurun dengan cepat.

Pemuatan modul yang antusias: Pola file rollup kami menyebabkan hampir setiap modul mengimpor seluruh basis kode saat startup, sehingga memuat secara malas (lazy-loading) menjadi tidak mungkin. Hal ini juga menghalangi kami untuk dengan mudah membagi server menjadi bundel terpisah untuk deployment yang berbeda. Meskipun memuat seluruh basis kode secara langsung (eager-loading) dapat diterima di lingkungan produksi, hal ini sangat mempengaruhi pengembangan dan pengujian, dengan waktu startup server pengembangan mencapai lebih dari 20–30 detik.

Kurangnya jejak yang dapat dilacak: Menjawab pertanyaan sederhana seperti "di mana implementasi acara ini?" atau "di mana penangan ini dipanggil?" sangat sulit. Kami harus mengandalkan pencarian teks lengkap pada literal string. Misalnya, jika seorang insinyur memutuskan untuk melakukan hal seperti ini:

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

Sangat sulit untuk melacak bahwa kabel_terkirim dan kabel_dibuat Acara-acara dipicu dari sini. Mencari string "wire_sent" dalam kode sumber tidak akan mengungkapkan penggunaan ini, karena nama tersebut dibentuk secara dinamis. Akibatnya, informasi ini menjadi pengetahuan "tribal" yang tersembunyi dan hanya diketahui oleh sejumlah kecil insinyur terpilih.

Ketiadaan batas domain yang jelas: Seiring dengan pengenalan antarmuka yang lebih homogen (penanganan peristiwa, entitas basis data, pemeriksaan kesehatan), hal ini mendorong penempatan antarmuka serupa dalam folder yang sama daripada mengelompokkannya berdasarkan logika domain. Hal ini menyebabkan fragmentasi logika bisnis kami, sehingga perpindahan konteks yang spesifik domain menjadi lebih sering dan kompleks.

Mengembangkan desain ini secara berulang

Dalam konfigurasi di atas, kami menempatkan semua penangan acara di ./app-event-handlers/<event_type>.ts</event_type> Berkas-berkas. Meskipun menyimpan semua berkas dalam satu folder memudahkan pencarian, hal itu tidak mencerminkan cara kerja kami yang sebenarnya. Dalam praktiknya, menempatkan pemroses acara (event handlers) bersama dengan logika aplikasi yang relevan lainnya terbukti jauh lebih berguna daripada mengelompokkannya dengan pemroses acara lainnya.

Itulah ide untuk menambahkan subekstensi ke file (.event-handler.ts) masuk. Mereka mengizinkan kami untuk menempatkan server secara bersama-sama berdasarkan domain sambil tetap memudahkan penemuan dengan mencari ekstensi file. Ekstensi file tersebut juga memungkinkan kami untuk menghapus file rollup yang dikelola secara manual, karena kami dapat memindai semua file yang sesuai dengan ekstensi tersebut di repositori pada saat runtime.

Berikut ini adalah versi ringkas dari kode registri dasar dan cara kerjanya. memuat modul akan memindai semua file dan mendaftarkan semua objek yang diekspor dengan sebuah $diskriminator properti yang sesuai dengan simbol yang sama yang dimasukkan ke dalam Buat Registri.

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

Sekarang, berikut ini adalah cara membuat event handler menggunakan Registries:

1. Tentukan sebuah registri dalam sebuah <name>.registry.ts</name> berkas:

./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. Tentukan penangan acara yang sebenarnya dalam .app-event-handler.ts berkas

// ./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. Tentukan fungsi umum untuk mencatat peristiwa

./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. Ekspor fungsi yang memproses penangan acara untuk antrian di pekerja.

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

Beberapa perbedaan penting yang perlu diperhatikan:

Traceabilitas kode jauh lebih baik.Setiap kali Anda merekam suatu peristiwa, Anda merekamnya seperti ini:

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

Ini berarti bahwa mudah untuk melacak semua tempat di mana Penerima Acara Pembuatan Kartu digunakan dengan menggunakan alat AST seperti "Temukan semua referensi" (di VS Code). Sebaliknya, ketika Anda melihat sebuah catatAcara Anda dapat mengklik "Buka implementasi" untuk menemukan definisi acara dan penanganannya.

Tidak ada lagi serikat pekerjaSebaliknya, kami menggunakan tipe dasar, yang merupakan sesuatu yang TypeScript Mendorong untuk menghindari masalah kinerja yang disebabkan oleh pengecekan tipe pada union besar.

Penanganan peristiwa ditempatkan bersama dengan logika spesifik domain.Penanganan acara aplikasi tidak lagi disimpan dalam satu folder. Sebaliknya, mereka ditempatkan bersama dengan logika bisnis yang relevan. Misalnya, layanan khusus domain mungkin terlihat seperti ini:

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

Bekerja dengan Registri saat ini

Hari ini, kami bekerja sama dengan puluhan registri untuk memastikan semua kode disimpan bersama dengan logika aplikasi mereka. Beberapa di antaranya yang menonjol meliputi:

  • .db.ts untuk mendaftarkan entitas basis data
  • .workflows.ts dan .aktivitas.ts untuk mendaftar Waktu alur kerja
  • .checks.ts untuk mendaftarkan pemeriksaan kesehatan (artikel blog)
  • .utama.ts untuk mendaftarkan layanan yang mengelompokkan logika bisnis spesifik domain
  • .permission-role.ts dan .permission-key.ts untuk mendefinisikan izin RBAC dalam produk kami
  • .kotak-email.ts Untuk mendaftarkan penangan yang memproses email di akun Gmail.
  • .cron.ts untuk mendaftarkan tugas cron
  • .saldo-buku-besar.ts untuk mendefinisikan primitif "buku besar" keuangan internal kami
  • metrik.ts untuk mendefinisikan metrik Datadog

dan beberapa ekstensi khusus domain lainnya.

Saat ini, kami belum membuka sumber kode pola ini, tetapi semoga posting ini dapat memberikan gambaran yang jelas tentang cara mengimplementasikannya di kode sumber lain. Jika Anda menemukan ini bermanfaat, cobalah mengimplementasikannya di proyek Anda sendiri dan beritahu kami bagaimana hasilnya!

Read more from us