Monorepos - Vor- und Nachteile - Feature-Bild

Monorepos – Vor- und Nachteile

Ursprünglich wollte ich heute den dritten Teil meiner Artikelserie über Statische Code-Analyze veröffentlichen. Doch dieser Teil wird umfangreicher als die ersten zwei Teile, sodass ich ihn nicht in zwei Wochen fertigstellen konnte. Deshalb möchte ich heute meine Erfahrung mit Git-Monorepos mit euch teilen.

Artikel zu diesem Thema gibt es zahlreiche, deshalb werde ich insbesondere auf drei praktische Aspekte eingehen: Performance, Mergen von Repositories und Jenkins-Konfiguration für Projekte innerhalb von Monorepos.

Folgende Fragen werde ich in diesem Artikel klären:

  • Was ist ein Monorepo?
  • Was sind die Vor- und Nachteile eines Monorepos?
  • Wie performant ist ein Git-Monorepo?
  • Wie kann man mehrere Git-Repositories in ein Monorepo zusammenführen (mergen) und dabei deren History beibehalten?
  • Wie konfiguriert man Jenkins, um einzelne Module eines Monorepos zu bauen?

Was ist ein Monorepo?

Ein Monorepo (kurz für „monolithic repository“) ist ein Repository, das den Quellcode mehrerer oder aller Projekte eines Teams oder eines Unternehmens enthält.

Einige der größten Internetunternehmen, wie Google, Facebook, Microsoft und Twitter, arbeiten mit Monorepos.

Bei AndroidPIT haben wir im August 2016 ein neues Produkt, das heute aus 25 Microservices und 23 Libraries besteht, als Git-Monorepo aufgesetzt. Zu Beginn waren alle Entwickler zögerlich, doch wir entschieden im Team gemeinsam es auszuprobieren. Wir hielten uns die Option offen das Repository wieder aufzusplitten, falls wir unzufrieden sein sollten. Zwei Jahre später – nachdem wir überwiegend positive Erfahrung gemacht hatten – entschieden wir uns, alle anderen Produkte ebenfalls in Monorepos zusammenzuführen.

Vor- und Nachteile von Monorepos

Zwei lesenswerte, englischsprachige Artikel zu diesem Thema sind:

Im folgenden findet ihr die Vor- und Nachteile, wie ich sie im Programmiereralltag erfahren habe, bzw. meine Sicht zu den verbreiteten Kritikpunkten.

Was sind die Vorteile eines Monorepos?

Reduzierung von Aufwand / Kosten:

  • Entwickler müssen nur ein (oder wenige) Repositories auschecken und aktualisieren. Neue Projekte werden in Unterverzeichnissen erstellt und somit automatisch an die anderen Entwickler verteilt, ohne dass diese weitere Repositories auschecken müssen.
  • Entwickler haben zu jeder Zeit einen konsistenten Stand des Gesamtprojekts – nicht nur im Master, sondern auch auf Branches und alten Commits. Bei vielen Einzelrepositories kommt es hingegen leicht zu Inkompatibilitäten.
  • Atomare Commits: Zusammengehörige Änderungen an mehreren Teilprojekten können gemeinsam eingecheckt werden.
  • Das Anlegen und Mergen von Branches für Änderungen, die mehrere Teilprojekte umfassen, ist aufwändig, wenn die Projekte in separaten Repositories liegen. In einem Monorepo muss nur einmal gemerged werden.
  • Das Behandeln von Merge-Konflikten, die sich auf zurückliegende Zustände der Codebasis beziehen, wird vereinfacht, da sich in einem Monorepo während der Behandlung des Konflikts die komplette Codebasis in einem konsistenten Zustand befindet. Bei mehreren Repositories muss man diese ggf. erst mühsam auf einen einheitlichen Zustand bringen.

Verbesserte Wartbarkeit:

  • Code kann deutlich einfacher zwischen den Verzeichnissen eines Monorepos verschoben werden als von einem Repository in ein anderes. Somit können Entwickler die Modulgrenzen bei Änderungen von Anforderungen oder Refactorings problemlos anpassen.
  • Projektübergreifende Dokumentation kann im Root-Folder des Monorepos abgelegt werden.

Organisation:

  • Monorepos führen dazu, dass alle Entwickler Zugriff auf die gesamte Codebasis haben. In den meisten Fällen ist dies erwünscht, fördert die teamübergreifende Zusammenarbeit und erlaubt es Entwicklern an Code anderer Teams mitzuarbeiten.
  • Monorepos reduzieren den Konfigurationsaufwand der Source Code Management Tools.

Was sind die (angeblichen) Nachteile eines Monorepos?

Performance / Skalierbarkeit:

  • Kritiker bemängeln, dass Monorepos zu Performance-Probleme führen. Ich konnte in Projekten der Größe, in der die meisten von uns sich bewegen, bisher keine Performance-Einbußen feststellen (s. Folgeabschnitt Performance und Skalierbarkeit eines Git-Monorepos).
  • Ebenso wird ein erhöhter Speicherbedarf kritisiert, da jeder Entwickler die gesamte Codebasis auschecken muss. Dies dürfte heutzutage in den meisten Projekten unerheblich sein: Der Quellcode des Linux-Kernels bspw. belegt etwa 3,5 GB. Die Repositories der meisten Unternehmen dürften kleiner sein.
  • Monorepos machen vermeintlich die Build-Pipeline komplexer. Auch das entspricht nicht meiner Erfahrung. Mittels Sparse Checkouts und Polling Filtern kann man problemlos Teilprojekte bauen (s. Abschnitt Jenkins-Konfiguration für ein Git-Monorepo).
  • Angeblich wird die Versionierung komplizierter. Auch dies kann ich nicht nachvollziehen. Es ist sowohl möglich die komplette Codebasis zu versionieren (mit Tags wie „v1.0“) als auch einzelne Teilprojekte (mit Tags wie „project-a-v1.0“).

Architektur:

  • Einige sind der Meinung, dass ein Monorepo einer sauberen und effektiven Modularisierung entgegen wirkt, da die Entwickler nicht gezwungen sind, den Code auf separate Repositories aufzuteilen. Ich sehe das als Vorteil, da Modulgrenzen durch Monorepos nicht in Stein gemeißelt werden (s. o. „Verbesserte Wartbarkeit“).

Organisation:

  • Der unter den Vorteilen aufgeführte Aspekt, dass alle Entwickler Zugriff auf die gesamte Codebasis haben, ist nicht in allen Organisationen erwünscht. Beispielsweise sollten Projekte für unterschiedliche Kunden eines Auftragsentwicklers nicht in einem Monorepo abgelegt werden.
  • Auch wenn Teilprojekte als Open Source-Komponenten freigegenen werden sollen, ist ein Monorepo hinderlich.

Performance und Skalierbarkeit eines Git-Monorepos

Ein weit verbreiteter Kritikpunkt an Monorepos ist deren schlechte Performance und Skalierbarkeit.

Aus meiner Erfahrung der letzten drei Jahre kann ich sagen, dass es in den meisten Teams keine Performance-Probleme geben sollte. In den meisten Unternehmen dürfte die gesamte Codebasis kleiner sein als der Linux-Kernel, welcher ebenfalls durch Git verwaltet wird (und für den Git ursprünglich entwickelt wurde).

Unser Repository bei AndroidPIT enthält knapp 5.000 Java und JavaScript Files mit etwa 400.000 Codezeilen (zum Vergleich der Linux-Kernel: 17,9 Mio Codezeilen in 47.000 C/C++ Files laut cloc). Unser Repository enthält etwa 500.000 Objekte (Linux: 6,9 Mio) und knapp 300.000 Deltas (Linux: 5,7 Mio). Das Klonen des gesamten Repositories dauert knapp eine Minute. Das Wechseln zwischen Branches dauert grundsätzlich weniger als eine Sekunde.

Aktuell arbeite ich an einem Projekt bei 1&1 IONOS. Das Monorepo des Java-Backends liegt von der Größeordnung zwischen AndroidPIT und dem Linux-Kernel. Das Klonen des gesamten Repositories dauert auch hier weniger als eine Minute. Das Umschalten von Branches dauert je nach Alter des Branches zwischen 0,2 und 1,5 Sekunden.

Die meisten von uns arbeiten in Unternehmen dieser Größenordnungen; von daher sehe ich in naher Zukunft keine praktischen Probleme bei der Skalierbarkeit eines Monorepos.

Zusammenführen (Mergen) mehrerer Git-Repositories unter Beibehaltung der History

Nehmen wir an, wir haben zwei Projekte, „project-a“ (beispielhaft angelegt unter https://gitlab.com/SvenWoltmann/project-a) und „project-b“ (https://gitlab.com/SvenWoltmann/project-b), die wir zu einem Repository „sparse-checkout-demo“ zusammenfassen wollen. Die zwei Projekte sollen dabei in Unterverzeichnissen mit den jeweiligen Projektnamen angeordnet werden.

Zunächst müssen wir beide Projekte auschecken (sofern diese nicht bereits vorhanden sind), das Verzeichnis für das Monorepo erstellen und dort ein Git-Repository initialisieren (alternativ eines via Gitlab erstellen und klonen):

git clone [email protected]:SvenWoltmann/project-a.git
git clone [email protected]:SvenWoltmann/project-b.git

mkdir sparse-checkout-demo
git init sparse-checkout-demo

Hier die Ausgaben, die ihr sehen solltet:

Git-Repositories mergen - Schritt 1
Git-Repositories mergen – Schritt 1

Mit folgenden Kommandos mergen wir Projekt A in das Monorepo:

cd project-a
git filter-branch -f --prune-empty --tag-name-filter cat --tree-filter '
    mkdir -p project-a
    git ls-tree --name-only $GIT_COMMIT | xargs -I{} mv {} project-a
'
cd ../sparse-checkout-demo
git remote add project-a ../project-a
git fetch project-a
git merge --allow-unrelated-histories project-a/master
git remote rm project-a

Folgende Ausgabe solltet ihr sehen:

Git-Repositories mergen - Schritt 2
Git-Repositories mergen – Schritt 2

Das gleiche wiederholen wir für Projekt B:

cd ../project-b
git filter-branch -f --prune-empty --tag-name-filter cat --tree-filter '
    mkdir -p project-b
    git ls-tree --name-only $GIT_COMMIT | xargs -I{} mv {} project-b
'
cd ../sparse-checkout-demo
git remote add project-b ../project-b
git fetch project-b
git merge --allow-unrelated-histories project-b/master
git remote rm project-b

Hier die entsprechenden Ausgaben dazu:

Git-Repositories mergen - Schritt 3
Git-Repositories mergen – Schritt 3

Und das war es schon. Mit dir und git log könnt ihr sehen, dass beide Teilprojekte und deren Commits im Monorepo enthalten sind:

Git-Repositories mergen - Überprüfung
Git-Repositories mergen – Überprüfung

In diesem Beispiel ging das ziemlich schnell. Stellt euch bei größeren Projekten darauf ein, dass der Prozess einige Stunden in Anspruch nehmen kann. Als wir bei AndroidPIT die Module, aus denen die Webseite besteht, zusammengefasst haben, hat dies etwa dreieinhalb Stunden gedauert.

Jenkins-Konfiguration für ein Git-Monorepo

In diesem Abschnitt zeige ich euch, wie man Jenkins konfiguriert, um ein Teilprojekt eines Git-Monorepos zu bauen. Ich verwende hierfür zunächst das Jenkins User Interface. Danach zeige ich euch, wie ihr den gleichen Jenkins-Job als Code schreiben könnt (mittels Jenkins Job DSL Plugin). Grundsätzlich empfehle ich immer die Code-Variante, da ihr so mit wenig Aufwand einheitliche Jobs für alle eure Prokjekte automatisiert und reproduzierbar erstellen könnt.

Ich beschränke mich an dieser Stelle auf einen rudimentären Job, der ein Maven-Projekt aus einem Unterverzeichnis des master-Branches des Monorepos auscheckt und compiliert. Zusätzliche Features wie die Auswahl eines Branches oder das Erstellen und Taggen eines Releases sind nicht Monorepo-spezifisch und würden den Rahmen dieses Artikels sprengen. Ich verweise stattdessen auf mein Jenkins-Tutorial zu Build- und Release-Jobs.

Zu den Tags ist noch zu erwähnen, dass diese innerhalb eines Repositories global sind; daher haben wir für Tags das Format <Projektname>-<Versionsnummer> verwendet, um Teilprojekte separat releasen und die Tags den einzelnen Projekten zuordnen zu können.

Jenkins-Job über das User Interface konfigurieren

Ich lege zunächst einen neuen Maven-Job an und gebe ihm den Namen „Sparse Checkout Demo“. Im „General“-Tab trage ich eine kurze Beschreibung ein:

Git-Monorepo in Jenkins konfigurieren - Allgemeine Einstellungen
Git-Monorepo in Jenkins konfigurieren – Allgemeine Einstellungen

Unter „Source Code Management“ wähle ich „Git“ und gebe das Repository an. Das im Beispiel verwendete Repository https://gitlab.com/SvenWoltmann/sparse-checkout-demo gibt es tatsächlich – ihr könnt es gerne zum Testen verwenden. Als Branch Specifier trage ich „master“ ein.

Unter „Additional Behaviours“ klicke ich auf „Add“ („Hinzufügen“ in der deutschen Version) und wähle „Sparse Checkout paths“ aus. Als Pfad gebe ich „project-a/“ an, eines meiner zwei Projektverzeichnisse im Demo-Monorepo.

Ich klicke ein zweites Mal auf „Add“ und wähle „Polling ignores commits in certain paths“ aus. Unter „Included Regions“ gebe ich noch einmal mein Projektverzeichnis an. Diese Einstellung bewirkt, dass der Job nur dann ausgeführt wird, wenn Dateien im Verzeichnis „project-a“ geändert werden.

Git-Monorepo in Jenkins konfigurieren - Source Code Management
Git-Monorepo in Jenkins konfigurieren – Source Code Management

Schließlich gebe ich unter „Build“ als Root POM („Stamm-POM“ in der deutschen Jenkins-Version) „project-a/pom.xml“ an sowie das Maven Goal „clean install“:

Git-Monorepo in Jenkins konfigurieren - Build
Git-Monorepo in Jenkins konfigurieren – Build

Damit ist die Job-Konfiguration bereits abgeschlossen und der Job ist bereit ausgeführt zu werden.

Jenkins-Job als Code (Jenkins Job DSL) erstellen

Den gleichen Jenkins-Job kann ich mit der Jenkins Job DSL wie folgt erstellen (dazu muss das Jenkins-Plugin Job DSL installiert sein):

mavenJob('Sparse Checkout Demo') {
    description 'This is a demo for building a project in a sub-directory of a Git Monorepo.'

    def sparseCheckoutPath = 'project-a'

    scm {
        git {
            remote {
                name 'origin'
                url 'https://gitlab.com/SvenWoltmann/sparse-checkout-demo.git'
            }

            branch 'master'

            configure { git ->
                git / 'extensions' / 'hudson.plugins.git.extensions.impl.SparseCheckoutPaths' / 'sparseCheckoutPaths' {
                    'hudson.plugins.git.extensions.impl.SparseCheckoutPath' {
                        path "$sparseCheckoutPath/"
                    }
                }
                git / 'extensions' / 'hudson.plugins.git.extensions.impl.PathRestriction' {
                    includedRegions "$sparseCheckoutPath/.*"
                }
            }
        }
    }

    rootPOM "$sparseCheckoutPath/pom.xml"
            
    goals 'clean install'
}

Den Sparse Checkout Path und den Polling-Filter müsst ihr hierbei über sogenannte Extensions konfigurieren, da die Jenkins Job DSL diese (durch das Git-Plugin hinzugefügten) Features nicht nativ unterstützt.

Fazit

In diesem Artikel habe ich die Vor- und Nachteile von Git-Monorepos gegenübergestellt – meiner Meinung nach überwiegen die Vorteile deutlich, sodass Monorepos für die meisten Teams eine gute Wahl sind. Die häufig kritisierten Performance- und Skalierbarkeitsprobleme kommen bei den Teamgrößen der meisten Unternehmen nicht zum Tragen, und das einfachere Refactoring von Code über Modulgrenzen hinweg erhöht die Wartbarkeit des Codes.

Ich habe gezeigt, wie Repositories unter Beibehaltung ihrer History gemerged werden können und wie man Jenkins-Jobs konfiguriert, sodass Teilprojekte eines Monorepos gebaut werden können.

Wer nicht alle Projekte in einem einzigen Repository haben möchte, kann Monorepos auch auf diejenigen Projekte beschränken, die eng miteinander verwoben sind und bei denen gemeinsames Branchen und Mergen sinnvoll ist. Auch könnt ihr eure Projekte nach und nach in Monorepos überführen und, ebenso problemlos, Monorepos und Einzelprojekte nebeneinander laufen lassen.

Hat Dir der Artikel weitergeholfen? Dann hinterlass mir doch einen Kommentar oder teil den Artikel über einen der folgenden Buttons. Ich würde mich sehr darüber freuen!

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.