Vandaag hebben we ongeveer 1,1 miljoen regels aan vastgelegde TypeScript-code in onze monorepo. Het opschalen ervan ging gepaard met een reeks uitdagingen, zoals trage typecontrole, opgeblazen imports en een steeds slechtere traceerbaarheid van de code. De afgelopen jaren hebben we verschillende ontwerppatronen onderzocht en getest om onze codebase beter te kunnen schalen. Eén patroon dat we tegenkwamen, hielp veel van onze groeiende problemen met codeorganisatie te verlichten: registries. Het is eenvoudig en een van de meest schaalbare patronen die we hebben toegepast.
Onze oorspronkelijke interne code voor het afhandelen van gebeurtenissen is een goed voorbeeld van een situatie waarin dit patroon een enorm verschil heeft gemaakt. Het begon eenvoudig, maar leidde na verloop van tijd tot gigantische type-unions die trage typecontrole veroorzaakten, barrel-bestanden die te veel code importeerden en moeilijk traceerbare code als gevolg van overmatige stringconcatenatie. Ten slotte zorgde het voor wrijving tussen ontwikkelaars bij het onderhouden en toevoegen van nieuwe gebeurtenishandlers.
Origineel ontwerp
Op hoog niveau is onze evenementenservice gebouwd op basis van een berichtenwachtrij (MQ). Evenementen worden geregistreerd en vanuit elke locatie in onze backend (API-pods, worker-pods, enz.) naar de MQ verzonden en precies één keer door een worker verwerkt.
Het ontwerp zelf is heel eenvoudig; de enige belangrijke beperking is dat alle gebeurtenissen volledig serialiseerbaar moeten zijn via de kabel. De meeste complexiteit en onderhoudswerkzaamheden vloeien voort uit typeveiligheid en de ervaring van ontwikkelaars.
1. Definieer een set basishelpers:
2. Definieer gebeurtenistypen in een gedeeld bestand
3. Definieer gebeurtenishandlers in hun eigen bestand
4. Definieer een rollup-bestand
5. Definieer gebeurtenishandlers
6. Definieer een algemene functie om gebeurtenissen vast te leggen.
7. Exporteer een functie die de gebeurtenishandler voor de wachtrij in een worker verwerkt.
Op het eerste gezicht ziet dit ontwerp er prima uit. Het is typeveilig, gemakkelijk te begrijpen, eenvoudig om mee te werken en duidelijk over hoe nieuwe gebeurtenissen moeten worden aangemaakt. Er waren echter verschillende problemen:
Complexiteit van typecontrole: Voor elk nieuw evenement, de AppEvent type wordt groter. Op zichzelf is een enkele union niet problematisch, maar naarmate het gebruik van dit patroon in de codebase toeneemt (meerdere type unions), verslechtert de prestatie van de typecontrole snel.
Eager module laden: Ons patroon van rollup-bestanden betekende dat bijna elke module bij het opstarten de volledige codebase importeerde, waardoor lazy loading onmogelijk was. Dit belette ons ook om de server gemakkelijk op te splitsen in afzonderlijke bundels voor verschillende implementaties. Hoewel het eager laden van de volledige codebase aanvaardbaar was in productie, had het een ernstige impact op de ontwikkeling en het testen, waarbij het meer dan 20-30 seconden duurde om alleen al de ontwikkelingsserver op te starten.
Slechte traceerbaarheid: Het was moeilijk om eenvoudige vragen te beantwoorden, zoals "waar wordt deze gebeurtenis geïmplementeerd?" of "waar wordt deze handler aangeroepen?". We moesten vertrouwen op full-text zoekopdrachten van string-literals. Als een engineer bijvoorbeeld besloot om iets als dit te doen:
Het zou erg moeilijk zijn om na te gaan dat de wire_sent en draad_aangemaakt gebeurtenissen worden vanaf hier geactiveerd. Als je in de codebase zoekt naar de tekenreeks "wire_sent", zul je dit gebruik niet vinden, omdat de naam dynamisch wordt samengesteld. Daardoor wordt deze informatie obscure "tribale" kennis die alleen in de hoofden van een select aantal ingenieurs blijft hangen.
Gebrek aan duidelijke domeingrenzen: Toen we meer homogene interfaces introduceerden (event handlers, DB-entiteiten, health checks), stimuleerde dit om vergelijkbare interfaces in dezelfde map te plaatsen in plaats van ze te groeperen op basis van domeinlogica. Dit versnipperde onze bedrijfslogica, waardoor domeinspecifieke contextwisselingen frequenter en complexer werden.
Dit ontwerp herhalen
In de bovenstaande configuratie hebben we alle gebeurtenishandlers in ./app-event-handlers/<event_type>.ts</event_type> bestanden. Hoewel het handig was om ze allemaal in één map te hebben, weerspiegelde dit niet hoe we daadwerkelijk werkten. In de praktijk bleek het veel nuttiger om event handlers bij de rest van de relevante applicatielogica te plaatsen dan ze bij andere handlers te groeperen.
Daar kwam het idee vandaan om subextensies aan bestanden toe te voegen (.event-handler.ts) kwam binnen. Ze lieten ons per domein samenwerken, terwijl we toch gemakkelijk konden zoeken door de extensie op te zoeken. Dankzij de bestandsextensie konden we handmatig onderhouden rollup-bestanden verwijderen, omdat we tijdens runtime konden scannen op alle bestanden die overeenkwamen met de extensie in de repository.
Hier volgt een verkorte versie van de basisregistratiecode en hoe deze werkt. modules laden zal alle bestanden scannen en alle geëxporteerde objecten registreren met een $discriminator eigenschap die overeenkomt met hetzelfde symbool dat is doorgegeven aan register aanmaken.
Hieronder ziet u hoe onze gebeurtenishandler eruitziet wanneer we Registries gebruiken:
1. Definieer een register in een <name>.registry.ts</name> bestand:
2. Definieer de daadwerkelijke gebeurtenishandlers in .app-event-handler.ts bestanden
3. Definieer een gemeenschappelijke functie om gebeurtenissen vast te leggen
4. Exporteer een functie die de gebeurtenishandler voor de wachtrij in een worker verwerkt.
Enkele belangrijke verschillen om op te merken:
De traceerbaarheid van code is veel beter.: Telkens wanneer u een gebeurtenis vastlegt, doet u dat als volgt:
Dit betekent dat het eenvoudig is om alle plaatsen te traceren waar cardCreatedEventHandler wordt gebruikt met behulp van AST-tools zoals "Find all references" (in VS Code). Omgekeerd, wanneer u een recordEvent oproep, kunt u met één klik naar "Ga naar implementatie" gaan om de gebeurtenisdefinitie en de bijbehorende handler te vinden.
Geen type-unions meerWe gebruiken eerder basistypen, wat iets is dat TypeScript moedigt aan om prestatieproblemen bij typecontrole te vermijden die grote unions met zich meebrengen.
Gebeurtenishandlers worden samen met domeinspecifieke logica geplaatst.App-gebeurtenishandlers worden niet langer in één map opgeslagen. In plaats daarvan worden ze samen met de relevante bedrijfslogica opgeslagen. Een domeinspecifieke service kan er bijvoorbeeld als volgt uitzien:
Werken met registers vandaag de dag
Tegenwoordig werken we samen met tientallen registers om alle code bij hun applicatielogica te houden. Enkele opvallende voorbeelden zijn:
.db.tsvoor het registreren van database-entiteiten.workflows.tsen.activiteiten.tsvoor het registreren Tijdelijk werkstromen.checks.tsvoor het registreren van gezondheidscontroles (blogbericht).main.tsvoor het registreren van diensten die domeinspecifieke bedrijfslogica groeperen.toestemming-rol.tsen.toestemmingssleutel.tsvoor het definiëren van RBAC-machtigingen in ons product.email-box.tsvoor het registreren van handlers die e-mails in een Gmail-account parseren.cron.tsvoor het registreren van cron-taken.grootboekbalans.tsvoor het definiëren van onze interne financiële "grootboek"-primitief.metrics.tsvoor het definiëren van Datadog-metrics
en verschillende andere domeinspecifieke extensies.
Op dit moment hebben we dit patroon nog niet open source gemaakt, maar hopelijk geeft dit bericht een duidelijk beeld van hoe het in andere codebases kan worden geïmplementeerd. Als je dit nuttig vond, probeer het dan eens in je eigen projecten en laat ons weten hoe het gaat!