eine Person hält ein Werkzeug in der Hand

Letztes Mal haben wir uns die Vorteile von Monorepos angesehen, jetzt schauen wir uns die Tools an und machen uns die Hände schmutzig (Foto von Christopher Burns auf Unsplash)

In Teil 1 des Artikels haben wir gezeigt, wie die Verwendung eines Monorepos eine Menge Kopfschmerzen ersparen kann, wenn man ein webbasiertes Produkt vom MVP zu einer vollwertigen Multi-Service-App skaliert und dabei hilft, die Anwendung so zu schreiben, dass die technische Komplexität auch über einen längeren Zeitraum hinweg überschaubar bleibt.

Schauen wir uns nun an, wie wir unser typisches Tooling für Testing, Linting usw. so einrichten können, dass es in unserem Monorepo funktioniert und wie wir die üblichen Herausforderungen angehen, die entstehen, wenn das Monorepo weiter wächst.


Developer Tooling-Setup

Die meisten Tools in der modernen Web-Entwicklung unterstützen Monorepos in der Regel bereits auf eine der folgenden Arten:

  • die Möglichkeit, eine gemeinsame zentrale Konfiguration, aber auch eine individuelle Konfiguration pro Package zu haben
  • die parallele Ausführung von Tasks in mehreren Packages zur Verbesserung der Geschwindigkeit (z.B. Testen und Veröffentlichen von Packages)
  • einen Überblick über die Abhängigkeiten zwischen Packages behalten, um die Ergebnisse von Tasks sinnvoll zu cachen und Tasks nur in den Packages auszuführen, die von den letzten Codeänderungen betroffen sind

Lernen wir nun, wie wir beliebte Webentwicklung-Tools einrichten können, um deren Monorepo-Funktionen zu nutzen:

TypeScript

Wenn Sie sowohl Backend- als auch Frontend-Services in Ihrem Monorepo haben, ist es wahrscheinlich, dass beide weitgehend verschiedene TypeScript-Konfigurationen benötigen. Je nachdem, welche Frameworks Sie verwenden und wie Ihre einzelnen Packages strukturiert sind, brauchen Sie möglicherweise noch weitere tsconfig.json-Anpassungen pro Package. Trotzdem sollten Sie die Konfiguration wiederverwenden, wo Sie können, um einen wachsenden Wartungsaufwand bei einer steigenden Anzahl von Packages zu vermeiden.

TypeScript unterstützt das über eine Funktion namens “Project References”. Wenn sie aktiviert ist, wird jedes Package im Monorepo als individuelles TypeScript-Projekt mit eigener Konfiguration behandelt. Darüber hinaus werden Abhängigkeiten zwischen den Packages modelliert, damit TypeScript nur Packages neu kompilieren muss, die von Codeänderungen betroffen sind.

Das Einrichten von Project References in TypeScript erfordert ein wenig Konfiguration. Zusammengefasst umfasst es diese Schritte:

  1. Sie fügen eine “tsconfig.json” zu jedem Package hinzu, das TypeScript-Code enthält. Um doppelte Konfigurationen zu vermeiden, kann man geteilte Konfigurationen (z.B. für Backend-Dienste, Frontends, Tests usw.) im Monorepo-Rootverzeichnis ablegen und diese in den Packages mit dem Keyword extends benutzen.
  2. Ergänzen Sie die Zeile "composite": true in der tsconfig.json jedes Packages, dessen Dateien von anderen Packages in Ihrem Monorepo importiert werden. Wenn Sie die Repository-Struktur-Konvention in Teil 1 des Artikels befolgt haben, werden das wahrscheinlich alle Packages in packages/libs sein. Dadurch wird TypeScript die jeweiligen Packages als erstes kompilieren und Metainformationen darüber abspeichern.
  3. Fügen Sie dann jedes TypeScript-Package aus dem Monorepo, das Ihr aktuelles Package als Dependency in der package.json hat, unter “references” in der tsconfig.json des Packages hinzu. Das tun Sie für alle Packages in Ihrem Monorepo.
  4. Ändern Sie den Befehl zur Ausführung von TypeScript von tsc in tsc --build. TypeScript wird jetzt Project References verwenden.

Weitere Anleitungen und mehr Details zur Funktionsweise von Project References finden Sie in der offiziellen TypeScript-Dokumentation.

Wenn Sie nicht übermäßig viele Packages und Abhängigkeiten zwischen den Packages in Ihrem Monorepo haben, ist es noch vertretbar, Project References von Hand hinzuzufügen, aber sobald Ihr Projekt wächst, kann es sinnvoll sein, die Verwaltung Ihrer Project References zu automatisieren, zum Beispiel mit Tools wie @monorepo-utils/workspaces-to-typescript-project-references.

Auch kann es sinnvoll sein, ein npm Script hinzuzufügen, das alle Composite Packages in Ihrem Monorepo auf einmal kompiliert, wenn das Monorepo lokal installiert wird, z.B. im “prepare” Script der root-package.json.

Jest

Wie TypeScript verfügt auch Jest über eine “projects” Konfiguration, die es erlaubt, jedes Package in Ihrem Repository als eigenes Jest-Projekt zu behandeln, das individuelle Einstellungen haben kann. Sie können Projects folgendermaßen einrichten:

Ähnlich wie bei der TypeScript-Einrichtung fügen Sie eine Datei namens jest.config.js zu allen Packages hinzu, die Jest-Tests haben. Auch hier können Ihre Packages verschiedene gemeinsam genutzte Konfigurationen importieren und wiederverwenden, z.B. für Frontend- und Backend-Services. Darüber hinaus möchten Sie eventuell noch weitere Dinge auf Package-ebene konfigurieren:

  • displayName: ein Kurzname Ihres Package, der beim Ausführen der Tests neben dem Namen der Testdatei angezeigt wird
  • rootDir: der Pfad zum Monorepo-Rootverzeichnis, wie z.B. "../../...". Das erlaubt es zum Beispiel, die Testabdeckung für die gesamte Monorepo zu ermitteln.
  • roots: enthält die Pfade, in denen Jest nach Testdateien sucht. Geben Sie hier den Pfad zu Ihrem Package an.

Um die Codebase DRY zu halten, können sie eine Helper-funktion anlegen, die displayName und roots automatisch aus dem Verzeichnispfad und dem Inhalt der package.json eines Packages generiert. Diese können Sie dann in der jest.config.js jedes Package wiederverwenden.

Wenn Sie die Konfigurationen in allen Packages hinzugefügt haben, fügen Sie in der jest.config.js im Ihrem Monorepo-Rootverzeichnis das Feld “projects” mit den Pfaden zu allen Ihren Package-Konfigurationen hinzu. Das Feld kann einen Glob enthalten, wie zum Beispiel projects: ['/packages/**/jest.config.js'].

Nun behandelt Jest Ihre Packages als individuelle Projekte. Ein weiterer Vorteil des Hinzufügens Ihrer Packages als Jest-Projekte ist, dass Jest dann Testläufe zwischen Packages parallelisieren kann, was in meinem Fall die Ausführungsdauer der Tests im gesamten Monorepo reduziert hat.

ein Terminal, das die Ergebnis eines Jest-Durchlaufs mit mehreren Projekten anzeigt

Jest fügt den displayName jedes Projekts zum Test-Output hinzu

Aufgrund der Anzahl der Packages und der einfacheren Testbarkeit durch die bessere Separation of Concerns, die Sie mit dem Monorepo-Ansatz erreichen (siehe Teil 1 dieses Artikels), werden Sie schnell eine ziemlich hohe Anzahl von Tests erreichen, so dass die Ausführungsgeschwindigkeit eine große Rolle spielt.

TypeScript-Tests mittels ts-jest durch den Standard-TypeScript-Compiler laufen zu lassen, erwies sich meiner Erfahrung nach als viel zu langsam für diesen Fall. Ich habe sehr gute Erfahrungen mit @swc/jest und esbuild-jest gemacht, die beide die Ausführungsdauer von Test-Suites um 90% (!) im Vergleich zu ts-jest reduzieren konnten. Wenn Sie vite als Build-Tool bevorzugen, sollten Sie vielleicht auch ein Auge auf vitest werfen. Im Moment scheint es allerdings noch keine vergleichbare Option zu Jests “projects” zu haben.

ESLint

ESLint hat keinen speziellen Modus für die Ausführung innerhalb von Monorepos, aber es verfügt über verschiedene Mechanismen zum Mergen von Konfigurationen:

  1. Config-Dateien können anderen Configs extenden
  2. Configs in Unterverzeichnissen haben Priorität über Configs aus den höheren Verzeichnissen

Technisch gesehen wäre es also möglich, individuelle ESLint-Konfigurationen für alle Packages im Monorepo hinzuzufügen und gemeinsam genutzte Configs zu extenden. Allerdings fand ich persönlich die ESLint-Dokumentation darüber, welche der Einstellungen in einer in “extends” verwendeten Konfiguration tatsächlich extended werden und ob einzelne Einstellungen zusammengeführt oder überschrieben werden und wie das mit “vererbten” Konfigurationen aus übergeordneten Verzeichnissen zusammenspielt, eher knapp und intransparent. Oftmals verhielt es sich anders, als ich es erwartet hätte.

Linting ist auch generell ein anderer Fall als die anderen bisher besprochenen Tools: Während Sie meistens verschiedene TypeScript- oder Testing-Konfigurationen für einzelne Packages haben wollen, weil die technischen Gegebenheiten der einzelnen Services unterschiedlich sind, werden hingegen Ihre Coding-Standards für alle Ihre Packages weitgehend identisch sein. Wenn Sie schon TypeScript als einheitliche Sprache für alle Ihre Monorepo-Packages verwenden, wollen Sie wahrscheinlich keine unterschiedlichen Coding Styles in den verschiedenen Packages haben, egal ob es sich um Frontend-Services, Backend-Services oder Libraries handelt.

Daher verwende ich üblicherweise nur eine einzige ESLint-Konfigurationsdatei im Monorepo-Rootverzeichnis und inkludiere alle Monorepo-Packages in den ESLint-Task des Root-Packages.

Manchmal kann es trotzdem nötig sein, die Linting-Konfigurationen an bestimmten Stellen des Monorepos anzupassen, z.B. um widersprüchliche Regeln für ein bestimmtes Package oder einen bestimmten Dateityp zu deaktivieren. Ein Beispiel dafür wäre ein größeres Monorepo, das Jest-Tests enthält, aber auch ein Package mit End-to-End-Tests, die in Cypress geschrieben wurden. Die ESLint-Plugins für Jest und Cypress vertragen sich nicht besonders, weil sie möglicherweise ihre Regeln auf Testdateien des jeweils anderen Tools anwenden wollen.

In diesem Fall ist es möglich, zusätzliche Konfigurationen für bestimmte Dateinamen über die Option “overrides” in der ESLint-Konfiguration hinzuzufügen, wo Sie den Pfad zu einem bestimmten Package, eine bestimmte Dateiendung oder ähnlichem angeben und die Konfiguration für diese überschreiben können.

Zusätzlich sollten Sie noch das Attribut root: true zur ESLint-Konfiguration in Ihrem Monorepo-Rootverzeichnis hinzufügen. Dies verhindert, dass ESLint nach Konfigurationsdateien weiter oben in Ihrem Dateisystem sucht.

Wenn Sie drastisch verschiedene TypeScript-Konfigurationen in Ihren Monorepo-Packages haben, können Sie auch den TypeScript-ESLint-Parser so konfigurieren, dass er diese Konfigurationen auch für das Linting erkennt. Das kann allerdings die Geschwindigkeit des Lintings negativ beeinflussen. In meinem Fall reichte es aus, auf die tsconfig.json in meinem Monorepo-Rootverzeichnis zu verweisen, die alle Packages referenziert und für den kompletten Build aller Packages verwendet wird.

Bonus-Tipp: Dependency Management mit depcheck

In Teil 1 des Artikels haben wir gelernt, wie Monorepos einen besseren Überblick über externe Abhängigkeiten bieten können, da wir eine lange Liste von Dependencies in kleinere Listen für jene Packages aufteilen können, in denen diese tatsächlich verwendet werden.

Aber selbst dann können noch Probleme mit unseren Dependencies auftreten:

Installierte, aber nicht verwendete Dependencies: Es kann vorkommen, dass wir ein Package so refactoren, dass es eine bestimmte Dependency nicht mehr verwendet (z.B. eine Library eines Drittanbieters), wir aber vergessen, die Dependency zu entfernen. Dies führt zwar nicht unmittelbar zu Problemen, verlängert aber die Installationszeit, erhöht den Wartungsaufwand und kann im Team für Verwirrung sorgen.

Verwendete, aber nicht installierte Dependencies: Auch das Gegenteil könnte der Fall sein: Wir verwenden Dependencies in einem Package, in dem sie nicht installiert sind. In einer Single-Package-Anwendung würde man dies recht einfach bemerken: Ihre Anwendung würde einfach nicht gebaut werden oder zur Laufzeit einen Fehler produzieren, wenn die Dependency verwendet wird. In einem Monorepo ist es jedoch möglich, dass der Code noch funktioniert, auch wenn das Package die Dependency nicht hinzugefügt hat. Wie das?

Dieses Phänomen wird “Phantom-Abhängigkeiten” genannt und betrifft nur die Package Manager npm und yarn v1. Wenn mehr als ein Package in einem Monorepo ein bestimmtes Package als Dependency hinzugefügt hat, hoisten die Package Manager die Dependency auf das Monorepo-Root-Package. Einfach ausgedrückt wird das Package im node_modules-Ordner des Monorepo-Rootverzeichnisses installiert, statt es in den node_modules Ordnern der Packages, die es einbinden, zu duplizieren. Das ist prinzipiell sinnvoll, heißt aber, dass ein drittes Package im Monorepo nun ebenfalls die Dependency nutzen kann, ohne sie selbst explizit als Dependency hinzugefügt zu haben. Das kann (und wird, vor allem in größeren Teams) zu nervigen Bugs in Packages des Monorepos führen, auch wenn die Packages selbst gar nicht angefasst wurden!

Stellen Sie sich vor, package-a und package-b haben eine Abhängigkeit zu lodash definiert, und package-c nicht, es verwendet lodash aber trotzdem in seinem Code. Das würde solange funktionieren, bis package-b die Abhängigkeit nach einem Refactoring entfernt. Nach einem erneuten npm install / yarn install würde die lodash Dependency nun nach package-a/node_modules verschoben werden und package-c hätte keinen Zugriff mehr auf lodash. Folglich wäre package-c kaputt, obwohl es keine Änderungen darin gab. Viel Spaß dabei das herauszufinden, wenn Sie für package-c verantwortlich sind und plötzlich eine Fehlermeldung erhalten.

Ein weiterer Fall, in dem Phantom-Abhängigkeiten problematisch sind, ist wenn Sie package-c veröffentlichen wollen, um es außerhalb des Monorepos verfügbar zu machen. In Ihrem lokalen Monorepo-Setup würde alles wie gewohnt funktionieren, aber sobald jemand anderes Ihr Package als Dependency installiert, wird es das nicht mehr (wenn die andere Person nicht bereits zufällig die richtige lodash-Version als Dependency zum ihrem Projekt hinzugefügt hat).

Glücklicherweise kann man beide Arten von Problemen vermeiden. Es gibt eine ESLint-Regel zur Vermeidung vom Importieren nicht installierter Packages, aber sie funktioniert nur, wenn Sie das Package explizit in Ihrem Source Code importieren. Öfters werden Sie jedoch Dependencies haben, die implizit verwendet werden, für die Sie also kein Import-Statement hinzufügen. Das ist oft, aber nicht nur der Fall bei devDependencies. Einige Beispiele sind:

  • Packages, die implizit in einer Umgebung vorhanden sind, wie z.B. Jest in einer Testdatei
  • Packages, die nur über Namenskonventionen referenziert werden, wie z.B. ESLint-Plugins, die in der ESLint-Konfigurationsdatei extended werden
  • CLI-Tools, die Sie in einem npm-Skript aufrufen

Das Package depcheck ist ein Tool, das die Liste der Dependencies in einer package.json mit ihrer Verwendung in Ihrem Code abgleicht. Es kann sowohl installierte, aber ungenutzte Dependencies als auch genutzte, aber nicht installierte Dependencies erkennen. Außerdem versteht es die Konventionen vieler populärer Tools wie Webpack, Babel, SCSS und ESLint und funktioniert auch in diesen Fällen.

Für Fälle, in denen depcheck nicht verstehen kann, dass ein Package implizit von einer anderen Dependency benutzt wird und falschen Alarm wegen ungenutzter Dependencies schlägt (wie im Fall von Storybook.js-Plugins), können die Dependencies in einer .depcheckrc-Konfigurationsdatei ignoriert werden.

depcheck funktioniert standardmäßig nicht in einem Monorepo, aber Sie können einfach eine .depcheckrc zu jedem Ihrer Packages und auch zum Monorepo-Rootverzeichnis hinzufügen und dann einen npm-Task hinzufügen, um das Tool im Monorepo-Root und in jedem Package auszuführen. Im Falle von yarn v1 würde das so aussehen:

// in der root package.json:
{
  "devDependencies": {
    "depcheck": "^1.4.3"
  },
  "scripts": {
    "depcheck": "depcheck && yarn workspaces run depcheck"
  }
}

Skripte im Monorepo effizient ausführen

Da unser grundlegendes Toolset innerhalb des Monorepos nun funktioniert, können wir uns ansehen, wie wir einige der Funktionen des Monorepos nutzen können, um uns das Leben leichter zu machen und Zeit zu sparen, wenn wir größere Projekte verwalten.

Schauen wir uns einige Szenarien an, die typischerweise in einem größeren Projekt vorkommen und die wir bei zunehmender Projektgröße automatisieren wollen.

Level 1: Ausführen eines bestimmten Tasks in mehreren Packages (wenn er existiert)

Diese Funktion ist Bestandteil aller Package Manager, die Workspaces unterstützen:

# npm v7 und neuer
npm run my-task --workspaces --if-present
# yarn v1
# (stellen Sie sicher, dass es `my-task` in allen Packages gibt, sonst schlägt es fehl)
yarn workspaces run my-task
# yarn v2 mit workspace-tools Plugin:
yarn workspaces foreach run my-task
# pnpm
pnpm run my-task --recursive

Meiner Erfahrung nach gibt es nicht allzu viele Anwendungsfälle, in denen man dies tatsächlich braucht. In Code-Beispielen im Internet wird es oft bei der Ausführung von Tests, dem Linting von Code oder der Veröffentlichung von Packages verwendet. Für alle drei Fälle haben wir in diesem Artikel schon Möglichkeiten gesehen, wie man diese Aufgaben effektiv auf globaler Ebene für das Monorepo erledigen kann und sich dadurch eine Menge an Konfiguration in den einzelnen Packages erspart. Sie werden diese Funktion hauptsächlich für Tools brauchen, die Monorepos nicht standardmäßig unterstützen (wie das Beispiel depcheck, das wir zuvor gesehen haben).

Level 2: Tasks in mehreren Packages parallel ausführen

Wenn Sie viele Packages in Ihrem Monorepo haben, kann die sequentielle Ausführung bestimmter Tasks in allen Packages viel Zeit in Anspruch nehmen. Wenn der Task nicht von Ergebnissen aus anderen Packages abhängig ist, können wir die Ausführung parallelisieren, um sie zu beschleunigen.

Es gibt mehrere Möglichkeiten, das zu erreichen. Einige Package Manager haben dieses Feature bereits eingebaut:

# yarn v2 mit workspace-tools Plugin:
yarn workspaces foreach run my-task --parallel

# pnpm
pnpm run my-task --recursive --parallel

Wenn Sie npm oder yarn v1 verwenden, gibt es mehrere Möglichkeiten, Skripte parallel laufen zu lassen:

Die erste Option ist Lerna, das den Befehl lerna run --parallel my-task enthält. Lerna verfügt über einige weitere nützliche Features, auf die wir später zurückkommen werden, erfordert aber auch einige zusätzliche Konfigurationen und geringfügige Änderungen an Ihren üblichen Arbeitsabläufen und Befehlen.

Für einfachere Anwendungsfälle gibt es Packages wie npm-run-all und concurrently, die auf einfache Art und Weise mehrere npm-Befehle gleichzeitig ausführen können. Standardmäßig funktionieren sie nicht in mehreren Packages, aber wir können sie trotzdem zum Laufen bringen. Nehmen wir an, wir haben einen Tasks, der einen Dev-Server in manchen unserer Packages startet und wir wollen alle Dev-Server auf einmal starten, dann können wir folgendes tun:

// angenommen Sie nutzen Yarn v1, fügen Sie diese Scripts in Ihrer Root-package.json hinzu:
{
  "scripts:" {
    "dev:web-app": "yarn workspace web-app run dev",
    "dev:payment-service": "yarn workspace payment-service run dev",
    "dev:storybook": "yarn workspace storybook run dev",
    "dev:user-service": "yarn workspace user-service run dev",
    "dev": "concurrently \"yarn:dev:*\""
  },
  "devDependencies": {
    "concurrently": "^7.2.2"
  }
}
// wenn Sie dann "yarn dev" ausführen, werden alle Dev-Server parallel gestartet

Das skaliert natürlich nicht besonders gut für eine große Anzahl von Packages, aber für ein einfaches Monorepo-Setup erfüllt es die Aufgabe einwandfrei.

Bei einem komplexeren Dev-Setup mit mehreren Services werden Sie möglicherweise Docker für Ihr lokales Dev-Setup nutzen. In diesem Fall ist es sinnvoll, eine Docker Compose Konfiguration hinzuzufügen, die alle Ihre Services miteinander verbindet.

Level 3: Tasks nur ausführen, wenn sich eine Dependency geändert hat

Je komplexer Ihr Monorepo wird, desto wichtiger wird es, wie schnell gängige Tasks ausgeführt werden können. Wenn Ihre test- oder lint-Tasks sehr lange dauern, wird sich das mit der Anzahl der Packages in Ihrem Monorepo vervielfachen, was sich ziemlich bald negativ auf die Developer Experience auswirken wird.

Irgendwann reicht es nicht mehr aus, dass die einzelnen Tasks so schnell wie möglich ausgeführt werden, sondern man möchte sie gar nicht mehr ausführen, wenn sie nicht unbedingt ausgeführt werden müssen.

Angenommen, Ihr Monorepo besteht aus 50 Packages. Sie haben Code in einem Package geändert, der von vier anderen Packages in Ihrem Monorepo verwendet wird (als direkte oder nested Dependency). Wenn Sie in diesem Fall einen neuen Build aus Ihrem Monorepo erstellen wollen, wollen Sie genau fünf Packages (das geänderte und die davon abhängigen) neu bauen und nicht 50.

Wenn wir unbedarft einfach alles neu bauen, könnte das 10 Mal so lange dauern (oder sogar noch länger, je nach Komplexität der einzelnen Packages) und 45 der neu gebauten Packages werden genau so aussehen wie vorher. Außerdem müssen wir wissen, in welcher Reihenfolge unsere Packages gebaut werden müssen, damit jeder Build funktioniert. Natürlich wollen wir dafür keine Build-Skripte von Hand schreiben, da das nicht besonders gut skalieren würde.

Es gibt Tools, die genau bei diesem Problem helfen Können. Sie verstehen package-übergreifende Abhängigkeiten innerhalb eines Monorepos, modellieren sie als einen Dependency Tree und verstehen, welche Packages von Änderungen in anderen betroffen sind. Auf diese Weise sind sie in der Lage, package-übergreifende Aufgaben nur für die Packages auszuführen, die ein anderes Ergebnis als zuvor erzeugen würden, und cachen Ergebnisse für alle anderen Packages.

Wie immer in der Welt der Webentwicklung gibt es auch hier mehrere Möglichkeiten:

Turborepo: Turborepo arbeitet ergänzend zu den Workspaces-Funktionen der üblichen Package Manager. Es fügt eine zusätzliche Konfigurationsdatei turbo.json hinzu, wo sie sogenannte “Pipelines” definieren, die übliche Tasks im Rahmen des gesamten Monorepos zusammen mit ihren Abhängigkeiten definieren. Ansonsten sind keine Änderungen an Ihrem Projekt-Setup erforderlich.

Ein Beispiel wäre eine “build”-Pipeline, die festlegt, dass der “build”-Task in allen Packages ausgeführt werden soll, falls es einen gibt. Turborepo liest die package-übergreifenden Abhängigkeiten zwischen den Packages aus deren package.json-Dateien, kennt daher die korrekte Build-Reihenfolge und weiß, welche Packages aufgrund der letzten Dateiänderungen neu gebaut werden müssen. Die Build-Artefakte werden dann zwischengespeichert, so dass sie sofort verwendet werden können, wenn das nächste Mal ein Build ausgeführt wird. Dasselbe gilt für Kommandozeilen-Output von Tasks, die keine Datei-Änderungen erzeugen, wie z.B. Linting.

Pipelines können wiederum von anderen Pipelines abhängig sein: Eventuell müssen Sie zuerst Ihre Packages bauen, bevor Sie Ihre Testsuite ausführen können. Diese Abhängigkeit kann auch in der Turborepo-Konfiguration modelliert werden. Und wenn Sie jetzt denken: “Hey, das klingt sehr nach der Konfiguration einer CI-Pipeline!”, dann haben Sie recht. Tatsächlich können Sie Ihr CI-Setup sogar vereinfachen, indem Sie Turborepo einfach direkt auch in Ihrer CI verwenden.

Der Cache, den Turborepo bei der Ausführung von Pipelines erzeugt, kann dann nicht nur auf dem eigenen Rechner verwendet, sondern auch mit der Cloud synchronisiert werden, sodass er heruntergeladen und auf den Rechnern anderer Teammitglieder oder sogar in der eigenen CI-Umgebung wiederverwendet werden kann.

Ein guter Anwendungsfall dafür ist die Review eines Pull-Requests: Manchmal reicht es nicht aus, nur die Datei-Änderungen anzusehen, um einen PR komplett zu reviewen. Stattdessen möchte man die Änderung lokal auschecken und testen, um sie vollständig zu verstehen. Wenn Ihr Monorepo komplex ist und der Cache in die Cloud hochgeladen wird, kann Turborepo, wenn Sie nach dem Auschecken des Branches einen lokalen Build ausführen, den Cache herunterladen und verwenden, anstatt den gleichen Build erneut auf Ihrem Rechner durchzuführen, was sehr viel schneller sein kann. Wenn Sie dafür nicht die Cloud-Services von Vercel nutzen wollen, gibt es bereits Open-Source-Lösungen, die es ermöglichen, z.B. AWS S3 für die Speicherung des Caches zu nutzen.

In Ihrer CI-Pipeline kann dies besonders nützlich sein, da übliche Aufgaben wie das Ausführen von Tests und das Erstellen eines Builds in CI-Umgebungen oft noch mehr Zeit in Anspruch nehmen als auf Ihrem lokalen Rechner. Durch die Verkürzung der Laufzeiten der CI-Pipeline können Sie an dieser Stelle bares Geld sparen.

Nx (und Lerna): Beide Tools werden inzwischen von derselben Firma gepflegt, und neuere Versionen von Lerna verwenden Nx unter der Haube.

Nx funktioniert sehr ähnlich wie Turborepo. Es verfügt über alle oben erläuterten Funktionen, einschließlich eines Cloud-Dienstes zur gemeinsamen Nutzung von Caches, hat aber sogar noch einige Funktionen darüber hinaus: Die Visualisierungsmöglichkeiten von Monorepo-Abhängigkeitsgraphen sind vielfältiger und Nx hat auch eine Generator-Funktionalität, um ganze Packages in einem Monorepo zu bootstrappen, die mit Plugins für verschiedene populäre Services erweitert werden kann. Auf diese Weise kann man ein ganzes Monorepo mit einer Frontend-Anwendung, Backend-Services, einem End-to-End-Test- und Unit-Test-Framework und einem Storybook in wenigen Minuten anlegen. Sehr nett.

Welches also nehmen? Das hängt von den persönlichen Vorlieben ab. Beide leisten gute Arbeit bei der Beschleunigung von Monorepo-Workflows. Turborepo ist einfacher zu konfigurieren, aber Nx hat einen größeren Funktionsumfang. Das folgende Video könnte bei der Entscheidung helfen:

Monorepos - How the Pros Scale Huge Software Projects // Turborepo vs Nx

Fireship auf YouTube.com

Jeff Delaney von Fireship.io hat ein hervorragendes Video gemacht, in dem er Turborepo und Nx vergleicht

Versionierung und Veröffentlichung mehrerer Packages in Ihrem Monorepo

Je nach Projektsetup kann es nötig sein, einzelne Packages eines Monorepos mit Personen außerhalb des Projektteams zu teilen. Das passiert häufiger bei Monorepos komplexer Open-Source-Libraries, kann aber auch bei Monorepos von digitalen Produkten passieren. Dabei handelt es sich meist um Libraries, die Sie für Ihr Produkt entwickelt haben und nun auch für andere Produkte wiederverwenden wollen, wie z.B. eine Library mit wiederverwendbaren UI-Komponenten, Clients für Drittanbieter-Software wie ein CMS oder einen Zahlungsanbieter oder eigene Konfigurations-Presets wie ESLint- oder TypeScript-Konfigurationen.

Die Komplexität der Einrichtung eines ordentlichen Package Release Management Workflows innerhalb Ihres Monorepos wächst üblicherweise mit der Anzahl der Packages. Wenn Sie nur ein Package veröffentlichen wollen, ist der Aufwand ziemlich trivial. Im einfachsten Fall können Sie zum Package-Ordner navigieren, die Version erhöhen und npm publish ausführen.

Normalerweise beinhaltet eine richtiger Release jedoch mehr als nur die Veröffentlichung einer neuen Package-Version. Sie wollen wahrscheinlich ebenso:

  • das Changelog Ihres Packages aktualisieren, um zu dokumentieren, was neu in der Package-Version ist und ob es Breaking Changes gibt
  • einen Release in GitHub / GitLab mit dem Changelog erstellen
  • das Changelog und die aktualisierte package.json mit der neuen Version in Git pushen
  • den Release-Commit mit der neuen Version taggen, damit Sie ihn später wiederfinden können
  • eventuell andere Leute benachrichtigen, dass eine neue Version veröffentlicht wurde

Es ist möglich, das alles von Hand zu erledigen, aber selbst bei nur einem Package werden Sie bald merken, dass es lästig und fehleranfällig ist, es selbst zu tun. Glücklicherweise gibt es Werkzeuge, die dies für Sie automatisieren, wie semantic-release oder release-it. Beide Tools haben auch die Option, automatisch die neue Package-Version für Sie zu bestimmen, z.B. basierend auf den Commit-Messages seit dem letzten Release, vorausgesetzt Sie verwenden eine saubere Konvention für Commit-Messages wie z.B. Conventional Commits. Langfristig wollen Sie vielleicht Ihre Releases komplett automatisiert in der CI durchführen, wenn neuer Code in Ihren Release Branch gepusht wird, so dass kein Mensch mehr in den Prozess eingreifen muss.

Dieser Workflow ist schon viel besser, hat aber einen Nachteil: Er funktioniert nur für ein einzelnes veröffentlichtes Package in Ihrem Repository. Wenn neue Versionen auf Versions-Tags und Git-Commit-Messages in Ihrem Repository basieren, können die Tools nicht wissen, welches Package sie betreffen. Die Versions-Tags können angepasst werden, indem der Version der Package-Name vorangestellt wird, aber die Commit-Messages sind nicht so einfach, da die Tools nicht wissen, welche Packages von einer Änderung betroffen sind. Wir erinnern uns, dass Packages in einem Monorepo voneinander abhängig sein können, so dass eine Änderung in einem Package auch ein anderes Package betreffen kann.

Für diesen Fall benötigen wir Tools, die die Abhängigkeiten innerhalb unseres Monorepos tatsächlich verstehen können. Solche Tools existieren, aber die meisten von ihnen haben nicht den kompletten Funktionsumfang der oben erwähnten Tools für Single-Package-Release-Workflows. Es gab mehrere Versuche, Monorepo-Logik zu Tools wie semantic-release hinzuzufügen, aber leider hat keiner zu einem Tool geführt, das weit verbreitet ist und/oder noch gepflegt wird. Stattdessen sind andere Tools entstanden, die speziell für Releases innerhalb von Monorepos gedacht sind.

Ein Foto von mehreren Luftballons, die in den Himmel steigen

Irgendwann müssen Sie vielleicht einmal mehr als ein Package in Ihrem Monorepo auf einmal veröffentlichen (Foto von Ankush Minda auf Unsplash)

Das erste Tool, das den Release mehrerer Packages in einem Monorepo unterstützt, ist Lerna, das diese Funktion schon seit langem hat. Sie können sich aussuchen, ob die Versionen der Packages in Ihrem Monorepo synchron bleiben sollen oder ob Sie Ihre Packages unabhängig voneinander versionieren wollen. Lerna kann sich dann um die Versionierung und Veröffentlichung Ihrer Packages mit den Befehlen lerna version und lerna publish kümmern. Sie müssen nach wie vor selbst die Information liefern, welche die neue Version sein wird (Major, Minor oder Patch-Inkrement) und auch Dinge wie das Erstellen von Changelogs oder Release-Commits sind nicht Teil des Funktionsumfangs von Lerna.

Ein Tool, das tatsächlich Changelogs erstellen, die nächsten Package-Versionen bestimmen und Release-Commits innerhalb eines Monorepos erstellen kann, ist changesets, so dass es für den Einsatz innerhalb einer CI-Umgebung geeignet wäre. Das Tool hat seinen eigenen Workflow, um Informationen über Änderungen zu generieren, die für die nächste Veröffentlichung relevant sind, wobei Sie für jede Änderung in Markdown-Dateien im Repository die Art der Versionserhöhung und eine textliche Beschreibung der Änderung angeben, sowie welche Packages betroffen sind. Aus diesen Informationen kann das Tool die neuen Package-Versionen ableiten, wenn der nächste Release getriggert wird.

Das letzte Tool, das Sie sich ansehen können, wenn Sie einen komplexeren Release-Prozess in Ihrem Monorepo haben, ist auto, welches Lerna unter der Haube verwendet, aber dessen Funktionalität mit der Bestimmung der nächsten Veröffentlichungsversion und der Erzeugung von Changelogs erweitert. Standardmäßig verwendet auto die Tags eines Pull-Requests, um die neue Version zu bestimmen, aber es gibt auch ein conventional commits plugin. Zusätzlich hat auto noch viele weitere nützliche Plugins, z.B. eines um andere Leute über Slack zu benachrichtigen, wenn eine neue Version veröffentlicht wurde.


Operative Herausforderungen in Monorepo-Projekten

Zum Schluss wollen wir noch einen Blick auf einige DevOps-bezogene Herausforderungen werfen, die in Monorepo-Projekten auftreten können, insbesondere bei größeren Teams oder sogar mehreren Teams, die im selben Repository arbeiten, und schauen wie wir sie lösen können.

Anhäufung von Pull Requests

Dies ist kein reines Monorepo-Problem, sondern tritt bei jedem großen Repository mit einer größeren Anzahl von Contributors auf: Die Teammitglieder werden viele Pull Requests in kurzer Zeit erstellen, was den Entwicklungsprozess verlangsamen kann, z.B. wenn Leute auf Änderungen von anderen PRs angewiesen sind, bevor diese gemerged werden können.

Ein weiterer relevanter Quelle für Blocker ist die Verwendung von GitLab mit anderen Merge-Methoden als “Merge-Commit erstellen”, die erfordern, dass die Änderungen im Merge-Request fast forward in Bezug auf den Target Branch sind, um sie mergen. Das heißt, wenn Sie mehrere Merge Requests mergen wollen, müssen Sie nach dem Merge des ersten Merge Requests den nächsten Feature Branch erneut auf den Target Branch rebasen und auf eine erfolgreiche CI-Pipeline warten, bevor Sie ihn mergen können. Dies kann ein Team erheblich verlangsamen. Hier ist also ein strukturierter Kollaborationsprozess enorm wichtig, um sicherzustellen, dass die Merge-Requests klein gehalten und schnell gemerged werden, damit sie sich nicht auftürmen.

Docker Builds

Auch wenn Sie Docker nicht für die Entwicklung verwenden, möchten Sie es vielleicht für das Deployment der Services Ihres Produkts verwenden.

Ein Monorepo mit mehreren Packages, die voneinander abhängig sind, stellt die Effizienz eines Docker-Builds vor einige Herausforderungen, da man nicht einfach ein Dockerfile zu einem der Packages hinzufügen und das Image im Kontext des Package-Verzeichnisses erstellen kann, da man keinen Zugriff auf Packages außerhalb des Package-Verzeichnisses hat, weil das einzelne Package nicht weiß, dass es innerhalb eines Monorepos lebt. Wenn Sie also das manuelle Verschieben von Code aus anderen Packages vermeiden wollen, können Sie die Images für einzelne Services wirklich nur im Kontext des Monorepo-Rootverzeichnisses erstellen.

Das bedeutet jedoch, dass Sie die Dependencies für das gesamte Monorepo installieren müssen, was bedeutet, dass alle Packages in das Image aufgenommen werden müssen, so dass jede Codeänderung in einem Package die cached Docker Layers invalidieren würden, die dann jedes Mal neu erstellt werden müssten (indem alle Monorepo-Dependencies erneut installiert werden).

Es gibt einige Optionen, die dabei helfen. Die erste ist die Verwendung von Multi-Stage-Builds, bei der Sie zuerst ein Image erstellen, das alle Dependencies des Monorepos installiert, und es dann (lokal oder in Ihrer CI-Pipeline) als Grundlage für die Erstellung Ihrer individuellen Services in ihren jeweiligen Dockerfiles wiederverwenden. Wenn Sie Nx oder Turborepo mit ihren Cloud-basierten Build-Caches verwenden, können Sie diese auch innerhalb Ihrer Docker-Umgebung nutzen, um die Builds zu beschleunigen, die innerhalb von Docker in einer CI-Umgebung normalerweise noch länger dauern als auf Ihrem lokalen Rechner.

Dennoch bleiben effiziente Docker-Builds innerhalb eine Monorepos eine Herausforderung, die manuelles Experimentieren und das Einbeziehen bzw. Ausschließen bestimmter Packages für jedes neue Monorepo erfordert.

Secrets Management mit mehreren Services

Fast alle Anwendungen oder Services in Ihrem Monorepo werden irgendwann zur Ausführung eine Art von geheimen Environment-Variablen benötigen, die nicht im Klartext in das Repository eingecheckt werden können, wie z.B. Passwörter, API-Keys oder Access Tokens für private Package Registries und ähnliches.

Eine übliche Konvention für die lokale Entwicklung ist es, diese in .env-Dateien zu speichern, die gitignored sind. Wenn Sie jedoch mehrere Services haben, die alle ihre eigenen Secrets benötigen, wie schaffen Sie es, diese im gesamten Team aktuell zu halten?

Sie möchten wahrscheinlich nicht, dass alle Teammitglieder die .env-Dateien in mehreren Service-Ordnern manuell bearbeiten, um das Projekt auf ihren Rechnern einzurichten und zu jedes Mal einzeln anzupassen, wenn eine dieser Dateien aktualisieren werden muss.

Stattdessen können Sie diese Services auf eine zentrale .env-Datei in Ihrem Monorepo-Root verweisen lassen, die die geheimen Env-Variablen für alle Services enthält, die für die lokale Entwicklung benötigt werden. Diese zentrale Datei kann entweder in verschlüsselter Form in das Repo eingecheckt werden oder dem Team über einen Passwort-Manager oder ähnliche Tools zur Verfügung gestellt werden.

JavaScript-Services können z.B. das Package dotenv so konfigurieren, dass die env-Datei von einem anderen Ort als dem Package-Verzeichnis geladen wird; eine universelle und noch einfachere Möglichkeit ist, stattdessen einen guten alten Symbolic Link zu verwenden. Symlinks können ohne Probleme in Git eingecheckt werden. Sie würden dann einfach die zentrale .env Datei im Monorepo-Rootverzeichnis gitignoren, auf die die Symlinks zeigen.

In der Praxis würde das folgendermaßen aussehen:

# zun Package-Verzeichnis navigieren
cd packages/apps/my-service
# einen .env-Symlink erstellen, der auf die .env im Monorepo-Root zeigt
ln -s ../../../.env .env
# den .env-Datei-Symlink des Packages in Git hinzufügen
git add .env

Jetzt müssen Sie nur noch die zentrale .env-Datei aktuell halten. Es ist ratsam, in Ihrer gemeinsamen zentralen .env-Datei anzugeben, welche Env-Variablen von welchen Services verwendet werden, entweder durch Kommentare oder indem Sie den Namen der Variablen entsprechend vorangestellt werden.


Fazit

Wir wissen jetzt, wie man gängige Developer Tools in einem Monorepo zum Laufen bringt, wie man Aufgaben effizienter ausführt, einschließlich einer orchestrierten Veröffentlichung mehrerer Packages, und wir haben etwas über die typischen Herausforderungen bei der Arbeit in einem Monorepo mit mehreren Personen gelernt.

Ich hoffe, dass ich ein bisschen Licht in die Möglichkeit bringen konnte, ein Monorepo für die Skalierung eines webbasierten Produkts zu nutzen, so dass Sie ausreichend informiert sind, um selbst eine fundierte Entscheidung zu treffen, ob dieser Ansatz eine praktikable Option für Ihr nächstes Projekt sein kann.