TypeScript All-in-One: Monorepo mit seinen Schmerzen und Gewinnen
In diesem Artikel werde ich nicht Monorepo und Polyrepo vergleichen, da es sich dabei alles um Philosophie handelt. Stattdessen werde ich mich auf die Erstellung und Weiterentwicklung von Erfahrungen konzentrieren und davon ausgehen, dass Sie mit dem JS/TS-Ökosystem vertraut sind.
Einleitung
Ich habe schon immer von einem Monorepo geträumt.
Ich habe den Monorepo-Ansatz bei der Arbeit für Airbnb gesehen, aber das war nur für das Frontend. Mit einer tiefen Liebe zum JavaScript-Ökosystem und der „glücklichen“ TypeScript-Entwicklungserfahrung begann ich, Frontend- und Backend-Code in derselben Sprache von vor ~drei Jahren zu vereinheitlichen. Es war großartig (zur Einstellung), aber nicht so großartig für die Entwicklung, da unsere Projekte immer noch über mehrere Repositories verteilt waren.
Wie es heißt, „die beste Art, ein Projekt zu refaktorisieren, besteht darin, ein neues zu starten“. Also, als ich vor etwa einem Jahr mein Startup startete, beschloss ich, eine totale Monorepo-Strategie zu verwenden: Front- und Backend-Projekte, sogar Datenbankschemata, in einem Repo unterbringen.
In diesem Artikel werde ich nicht Monorepo und Polyrepo vergleichen, da es sich dabei alles um Philosophie handelt. Stattdessen werde ich mich auf die Erstellung und Weiterentwicklung von Erfahrungen konzentrieren und davon ausgehen, dass Sie mit dem JS/TS-Ökosystem vertraut sind.
Das Endergebnis ist auf GitHub verfügbar.
Warum TypeScript?
Offen gesagt, ich bin ein Fan von JavaScript und TypeScript. Ich liebe die Verträglichkeit seiner Flexibilität und Strenge: Man kann auf unbekannt
oder irgendwelche
zurückgreifen (obwohl wir jegliche Form von irgendwelche
in unserer Codebasis verboten haben), oder man kann eine super-strenge Lint-Regel ansetzen, um den Code-Stil im gesamten Team abzustimmen.
Als wir vorher über das Konzept von „Fullstack“ sprachen, stellen wir uns normalerweise mindestens zwei Ökosysteme und Programmiersprachen vor: eine für das Frontend und eine für das Backend.
Eines Tages wurde mir plötzlich klar, dass es einfacher sein könnte: Node.js ist schnell genug (glauben Sie mir, in den meisten Fällen ist die Codequalität wichtiger als die Laufgeschwindigkeit), TypeScript ist reif genug (funktioniert gut in großen Frontend-Projekten), und das Monorepo-Konzept wurde von einer Reihe bekannter Teams (React, Babel, etc.) praktiziert - also warum kombinieren wir nicht den gesamten Code, vom Frontend bis zum Backend? Das kann es Entwicklern ermöglichen, die Aufgaben ohne Kontextwechsel in einem Repo durchzuführen und ein komplettes Feature in (fast) einer Sprache zu implementieren.
Auswahl des Paketmanagers
Als Entwickler, wie üblich, konnte ich es kaum erwarten, mit dem Coden zu beginnen. Aber dieses Mal waren die Dinge anders.
Die Auswahl des Paketmanagers ist entscheidend für die Entwicklererfahrung in einem Monorepo.
Der Schmerz der Trägheit
Es war Juli 2021. Ich fing mit [email protected]
an, da ich es schon lange benutze. Yarn war schnell, aber bald stieß ich auf einige Probleme mit Yarn Workspaces. Z.B. Abhängigkeiten nicht korrekt heben, und tonnenweise Probleme sind mit „fixed in modern“ getaggt, was mich zur v2 (berry) leitet.
„Okay, gut, ich upgrade jetzt.“ Ich hörte auf, mit v1 zu kämpfen und begann zu migrieren. Aber der lange Migrationsleitfaden von berry hat mir Angst gemacht, und ich gab nach ein paar gescheiterten Versuchen auf.
Es funktioniert einfach
Daher begann die Recherche über Paketmanager. Ich wurde von pnpm
nach einem Testlauf fasziniert: schnell wie yarn, native Monorepo-Unterstützung, ähnliche Befehle wie bei npm
, Hard Links, etc. Am wichtigsten ist, dass es einfach funktioniert. Als Entwickler, der ein Produkt starten möchte, aber KEIN Paketmanager entwickelt, wollte ich nur einige Abhängigkeiten hinzufügen und das Projekt starten, ohne zu wissen, wie ein Paketmanager funktioniert oder irgendwelche anderen ausgefallenen Konzepte.
Basierend auf der gleichen Idee wählten wir einen alten Freund lerna
zur Ausführung von Befehlen über die Pakete und zur Veröffentlichung von Workspace-Paketen.
Definition von Paketumfängen
Es ist schwierig, den endgültigen Umfang jedes Pakets am Anfang klar herauszufinden. Fangen Sie einfach mit Ihrem besten Versuch nach dem Status Quo an und denken Sie daran, dass Sie während der Entwicklung immer refaktorisieren können.
Unsere anfängliche Struktur enthält vier Pakete:
core
: der Backend-Monolith-Service.phrases
: i18n Schlüssel → Phrasen-Ressourcen.schemas
: die Datenbank und geteilte TypeScript-Schemata.ui
: eine Web-SPA, die mitcore
interagiert.
Tech-Stack für Fullstack
Da wir das JavaScript-Ökosystem annehmen und TypeScript als unsere Hauptprogrammiersprache verwenden, sind viele Entscheidungen ein Leichtes (basierend auf meiner Vorliebe 😊):
koajs
für den Backend-Service (core): I hatte eine harte Erfahrung beim Verwenden vonasync/await
inexpress
, daher beschloss ich, etwas mit nativer Unterstützung zu verwenden.i18next/react-i18next
für i18n (phrases/ui): mag seine einfache API und gute TypeScript-Unterstützung.react
für SPA (ui): Nur persönliche Vorliebe.
Wie sieht es mit Schemas aus?
Hier fehlt noch etwas: Datenbanksystem und Schema-<-> TypeScript Definition Mapping.
Allgemein vs. Meinungsbildend
An diesem Punkt habe ich zwei beliebte Ansätze ausprobiert:
- Verwenden Sie ein ORM mit vielen Dekoratoren.
- Verwenden Sie einen Query Builder wie Knex.js.
Aber beide erzeugten während der vorherigen Entwicklung ein merkwürdiges Gefühl:
- Für ORM: Ich bin kein Fan von Dekoratoren, und eine weitere abstrakte Ebene der Datenbank verursacht mehr Lernaufwand und Unsicherheit für das Team.
- Für den Query Builder: Es ist wie SQL mit einigen Einschränkungen (in gutem Sinne) zu schreiben, aber es ist kein tatsächliches SQL. Daher müssen wir
.raw()
für rohe Abfragen in vielen Szenarien verwenden.
Dann sah ich diesen Artikel: „Stop using Knex.js: Using SQL query builder is an anti-pattern“. Der Titel sieht aggressiv aus, aber der Inhalt ist großartig. Es erinnert mich deutlich daran, dass „SQL eine Programmiersprache ist“, und ich erkannte, dass ich direkt SQL schreiben konnte (genau wie CSS, wie konnte ich das verpassen!), um die nativesprachlichen und datenbankbezogenen Funktionen zu nutzen, anstatt eine weitere Ebene hinzuzufügen und die Leistung zu reduzieren.
Zusammengefasst entschied ich mich, bei Postgres und Slonik (ein Open-Source-Postgres-Client) zu bleiben, wie im Artikel angegeben:
... der Nutzen, den Benutzern die Auswahl zwischen verschiedenen Datenbank-Dialekten zu ermöglichen, ist marginal und der Aufwand, gleichzeitig für mehrere Datenbanken zu entwickeln, ist erheblich.
SQL-<-> TypeScript
Ein weiterer Vorteil des Schreibens von SQL ist, dass wir es leicht als einzig wahre Quelle von TypeScript-Definitionen verwenden können. Ich schrieb einen Code-Generator, um SQL-Schemata in TypeScript-Code zu übersetzen, den wir in unserem Backend verwenden werden, und das Ergebnis sieht nicht schlecht aus:
Wir können sogar jsonb
mit einem TypeScript-Typ verbinden und die Typvalidierung im Backend-Dienst durchführen, wenn nötig.
Ergebnis
Die endgültige Abhängigkeitsstruktur sieht aus wie:
Vielleicht bemerken Sie, dass es ein Einweg-Diagramm ist, das uns sehr geholfen hat, eine klare Architektur und die Fähigkeit zur Expansion zu behalten, während das Projekt wächst. Außerdem ist der Code (im Grunde) alles in TypeScript.
Entwicklererfahrung
Paket- und Konfigurationsfreigabe
Interne Abhängigkeiten
pnpm
und lerna
machen einen tollen Job bei internen Workspace-Abhängigkeiten. Wir verwenden den untenstehenden Befehl im Projekt-Root, um Geschwisterpakete hinzuzufügen:
Es wird @logto/schemas
als Abhängigkeit zu @logto/core
hinzufügen. Während Sie die semantische Version in package.json
Ihrer internen Abhängigkeiten beibehalten, kann pnpm
sie auch korrekt in pnpm-lock.yaml
verlinken. Das Ergebnis sieht so aus:
Konfigurationsfreigabe
Wir behandeln jedes Paket in Monorepo als „unabhängig“. Daher können wir den Standardansatz für die Konfigurationsfreigabe verwenden, der tsconfig
, eslintConfig
, prettier
, stylelint
und jest-config
abdeckt. Siehe dieses Projekt als Beispiel.
Code, Lint und Commit
Ich verwende VSCode für die tägliche Entwicklung, und kurz gesagt, nichts ist anders, wenn das Projekt richtig konfiguriert ist:
- ESLint und Stylelint funktionieren normal.
- Wenn Sie das VSCode ESLint-Plugin verwenden, fügen Sie die folgenden VSCode-Einstellungen hinzu, damit es die pro-Paket-ESLint-Konfiguration anerkennt (ersetzen Sie den Wert von
pattern
durch Ihren eigenen):
- Wenn Sie das VSCode ESLint-Plugin verwenden, fügen Sie die folgenden VSCode-Einstellungen hinzu, damit es die pro-Paket-ESLint-Konfiguration anerkennt (ersetzen Sie den Wert von
- husky, commitlint und lint-staged funktionieren wie erwartet.
Compiler und Proxy
Wir verwenden unterschiedliche Compiler für das Front- und das Backend: parceljs
für UI (React) und tsc
für alle anderen reinen TypeScript-Pakete. Ich kann Ihnen dringend empfehlen, parceljs
auszuprobieren, wenn Sie das noch nicht getan haben. Es handelt sich dabei um einen echten „Zero-Config“-Compiler, der verschiedene Dateitypen anmutig handhabt.
Parcel hostet seinen eigenen Frontend-Entwicklungsserver, und der Produktionsausgang besteht nur aus statischen Dateien. Da wir APIs und SPA unter demselben Ursprung bereitstellen möchten, um CORS-Probleme zu vermeiden, funktioniert die folgende Strategie:
- In der Entwicklungs-Umgebung verwenden Sie einen einfachen HTTP-Proxy, um den Verkehr an den Parcel-Entwicklungsserver umzuleiten.
- Im Produktionsbetrieb dienen Sie direkt statische Dateien.
Sie können die Implementierung der Frontend-Middleware-Funktion hier finden.
Watch-Modus
Wir haben ein dev
Skript in package.json
für jedes Paket, das auf Dateiänderungen achtet und bei Bedarf neu kompiliert. Dank lerna
ist es einfach, mit lerna exec
Paket-Skripte parallel auszuführen. Das Root-Skript sieht so aus:
Zusammenfassung
Idealerweise nur zwei Schritte für einen neuen Ingenieur/Beitragenden zum Start:
- Klonen Sie das Repository
pnpm i && pnpm dev
Schlussbemerkungen
Unser Team entwickelt seit einem Jahr nach diesem Ansatz und wir sind ziemlich zufrieden damit. Besuchen Sie unser GitHub-Repo, um den neuesten Stand des Projekts zu sehen. Zum Abschluss:
Schmerzen
- Muss mit dem JS/TS-Ökosystem vertraut sein
- Muss den richtigen Paketmanager auswählen
- Erfordert einige zusätzliche einmalige Einrichtung
Gewinne
- Entwickeln und pflegen Sie das gesamte Projekt in einem Repo
- Vereinfachte Kodierungsanforderungen
- Gemeinsame Code-Stile, Schemas, Phrasen und Dienstprogramme
- Verbesserte Kommunikationseffizienz
- Keine Fragen mehr wie: Was ist die API-Definition?
- Alle Ingenieure sprechen in derselben Programmiersprache
- CI/CD mit Leichtigkeit
- Verwenden Sie die gleiche Toolchain zum Bauen, Testen und Veröffentlichen
In diesem Artikel bleiben einige Themen unberücksichtigt: Einrichtung des Repos von Grund auf, Hinzufügen eines neuen Pakets, Nutzung von GitHub Actions für CI/CD, etc. Es wäre zu lang für diesen Artikel, wenn ich jedes davon erweitern würde. Fühlen Sie sich frei zu kommentieren und lassen Sie mich wissen, welches Thema Sie in Zukunft sehen möchten.