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:
2. Ereignistypen in einer gemeinsam genutzten Datei definieren
3. Definieren Sie Ereignisbehandler in einer eigenen Datei.
4. Rollup-Datei definieren
5. Ereignisbehandler definieren
6. Definieren Sie eine gemeinsame Funktion zum Aufzeichnen von Ereignissen.
7. Exportieren Sie eine Funktion, die den Ereignis-Handler für die Warteschlange in einem Worker verarbeitet.
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:
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.
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:
2. Definieren Sie die tatsächlichen Ereignisbehandler in .app-event-handler.ts Dateien
3. Definieren Sie eine gemeinsame Funktion zum Aufzeichnen von Ereignissen.
4. Exportieren Sie eine Funktion, die den Ereignis-Handler für die Warteschlange in einem Worker verarbeitet.
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:
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:
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.tsfür die Registrierung von Datenbankentitäten.workflows.tsund.Aktivitäten.tsfür die Registrierung Zeitlich Arbeitsabläufe.checks.tsfür die Registrierung von Gesundheitschecks (Blogbeitrag).main.tsfür die Registrierung von Diensten, die domänenspezifische Geschäftslogik zusammenfassen.berechtigungsrolle.tsund.permission-key.tszur Definition von RBAC-Berechtigungen in unserem Produkt.email-box.tszum Registrieren von Handlern, die E-Mails in einem Gmail-Konto analysieren.cron.tsfür die Registrierung von Cron-Jobs.ledger-balance.tszur Definition unserer internen Finanz-„Hauptbuch“-Primitive.metriken.tszur 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!