Letztes Jahr bei Proton wechselten wir von einer Polyrepo-Architektur zu einer Monorepo-Architektur, um das Management der Pakete, die Teil unseres Frontend-Web-App-Stacks sind, zu vereinfachen. Wir standen schon länger vor Problemen und nachdem wir unsere Optionen abgewogen hatten, entschieden wir, dass eine Monorepo-Lösung die geeignetste wäre. Dieser Artikel erklärt die Probleme, mit denen wir bei unserer Polyrepo-Struktur konfrontiert waren, erörtert die Vorteile einer Monorepo-Struktur und beschreibt unseren Weg von Polyrepo zu Monorepo.
Bevor wir weitermachen, wenn ich „Polyrepo“ und „Monorepo“ sage, meine ich Folgendes:
- Polyrepo: Ein System von Quellcode-Modulen, die voneinander abhängig sind, aber separate Instanzen von Versionskontroll-Repositories sind.
- Monorepo: Ein System von Quellcode-Modulen, die voneinander abhängig sind, aber alle unter einer einzigen Instanz von Versionskontroll-Repositories leben.
Ich werde von nun an „Git-Repositories“ oder einfach „Repositories“ statt „Versionskontroll-Repositories“ sagen. Und um das klarzustellen, Git ist keine Voraussetzung für eine Monorepo-Architektur.
Der Anfang
Proton begann mit einem E-Mail-Client, Proton Mail(new window), als einzige Anwendung, hat sich aber seitdem zu einem Datenschutzanbieter entwickelt, der eine breite Palette von Produkten anbietet, einschließlich Web-Anwendungen für Proton Mail, Proton Calendar(new window), Proton Drive(new window) und das Proton-Konto, das alles miteinander verbindet. Das Hinzufügen neuer Anwendungen zu unserem Stack führte dazu, dass die Anzahl der von uns gewarteten Git-Repositories proportional anwuchs, mit einem Repository pro Anwendung. Allerdings haben wir über die für unsere Anwendungen erforderlichen Repositories hinaus weitere erstellt. Wie du dir vorstellen kannst, müssen unsere Apps dieselbe Funktionalität, Optik und Haptik teilen, auch wenn es sich um unterschiedliche Produkte handelt. Daraus folgt, dass wir Repositories für Code verwendet haben, der zwischen Produkten geteilt wurde.
Als Beispiel hatten wir früher ein separates Repository für geteilte React-Komponenten. Das war das Ergebnis einer natürlichen Entwicklung unserer bestehenden Systeme. Allerdings wurde das Teilen von Code über Codebasen hinweg zunehmend komplexer, als wir mehr Anwendungen und Produkte hinzufügten, was es schwierig machte, Pakete unter dieser Multi-Repository-Struktur zu verwalten. Es gibt mehrere Gründe, warum dieses System nicht gut skalierbar war.
Unser Hauptproblem mit unserem Polyrepo
Während und nach unserem Übergang zu einem Monorepo begannen wir zu sehen, wie wir von dessen Architektur profitieren konnten. Ein Problem jedoch – die unnötige und verschwenderische Verdoppelung administrativer Aufgaben – veranlasste uns, diese Monorepo-Option überhaupt erst in Betracht zu ziehen. Wann immer eine Feature-Implementierung Änderungen über mehrere Projekte erforderte, um abgeschlossen zu werden (z.B. das Hinzufügen einer React-Komponente für ein neues Feature in der Proton Mail-Anwendung), waren administrative Aufgaben in Git äußerst unpraktisch auszuführen. Um ein einziges Feature vorzubereiten, mussten wir Git-Operationen – Branchen, Commiten, Merge-Requests öffnen, Reviewen, Rebasen usw. – über viele Repositories hinweg spiegeln.
Dann stießen wir auf die Idee der „atomaren Änderungen“, die uns ansprach, auch wenn sie eine Änderung unserer Philosophie darstellte. Die Hauptidee hinter atomaren Änderungen ist, dass du statt Änderungen auf ein technisches Anliegen deines Projekts oder deiner Projekte zu beschränken, Änderungen nach ihrem semantischen Zusammenhang als Modifikationsblöcke an der Funktionalität deines Produkts gruppierst. Es gibt keinen Grund, Änderungen aufzuspalten, die unsere geteilten UI-Komponenten und (zum Beispiel) die Proton Mail-Anwendung betreffen, wenn sie alle dasselbe Anliegen adressieren. Solche semantisch verbundenen Änderungen sollten sein:
- Gruppiert unter der gleichen Änderung, Differenz und Commit
- Gleichzeitig überprüfbar (nicht in zwei separaten Merge-Anfragen)
- Als eine Einheit rückgängig zu machen.
Ein Monorepo ermöglicht uns dies, da es natürlicherweise atomare Änderungen in Form von Git-Commits unterstützt.
Im Polyrepo war das Testen von Code vor der Annahme und dem Mergen in den Hauptzweig ebenfalls herausfordernd, besonders aus Sicht der Automatisierung von CI/CD. Builds mussten Versionen von Abhängigkeiten beinhalten, die nicht auf dem Hauptzweig ihres jeweiligen Repositorys waren. Dennoch konnten wir mit einigen CI/CD-Kniffen und Tricks die Arbeit erledigen, und es war möglich, Features erfolgreich durch den Entwicklungszyklus zu schicken.
Wir haben auch keine Semver und Registry-Hosting zur Versionierung unserer Pakete verwendet (und tun dies immer noch nicht), was eine Möglichkeit gewesen wäre, einige dieser Probleme anzugehen. Allerdings wäre Semver weit entfernt von einer Allzwecklösung für unsere Bedürfnisse gewesen und bringt sein eigenes Gepäck mit, wie Komplexität bei der Verwaltung gehosteter Pakete, deren Veröffentlichung und Versionierung bei der Nutzung.
Die Polyrepo-Architektur hatte viele andere kleinere, unpraktische Eigenheiten, die unseren Bedürfnissen nicht entsprachen. Ich werde auf mehr Probleme eingehen, die wir hatten, während ich die Vorteile unseres Monorepos diskutiere. Um mehr Kontext zu geben, unsere Polyrepo-Architektur präsentierte neben der Entwicklererfahrung auch inhärente technische Probleme. Ein greifbares Beispiel war, dass wir keine Rollbacks zu früheren Versionen auf einer cross-repository Basis durchführen konnten. Wenn ein neues Feature, das mehrere Repositories betraf, gemerged wurde und sich dann als problematisch herausstellte, war es schwierig, automatische Rollbacks durchzuführen, da keine einzelne Operation gleichzeitig ein Rollback auf separaten Git-Historien durchführen konnte.
Diese Probleme häuften sich langsam an, und es wurde klar, dass wir eine Lösung brauchten. Nach einiger Überlegung stellte sich heraus, dass die Lösung in der Migration zu einer Monorepo-Architektur lag.
Unsere Optionen abwägen
Mit der Entscheidung zur Migration fest im Griff, mussten wir einen Plan ausarbeiten.
Zu dieser Zeit hatten wir etwa 15 Entwickler im Frontend-Team, die an unserem Webanwendungs-Stack arbeiteten. Darüber hinaus trugen viele Leute aus anderen Teams, wie Crypto oder Backend, auch häufig zu unseren Repositories bei. Dass viele Leute aktiv an diesen Repositories arbeiteten, bedeutete, dass die physische Migration schnell erfolgen musste und die Implementierung robust sein musste, sobald wir auf der anderen Seite waren. Andernfalls riskierten wir, die Arbeit unserer Kollegen für eine längere Zeit zu blockieren.
Um eine robuste Implementierung zu gewährleisten, haben wir ziemlich viel Zeit damit verbracht, verschiedene Tools zu recherchieren und mit Proof-of-Concepts zu experimentieren. Wir überprüften, wie sich eine Option anfühlte oder ob wir sie so zum Laufen bringen konnten, wie wir es wollten. Wir erkundeten verschiedene Paketmanager (speziell npm, yarn, pnpm), semantische Versionierung mit gehostetem Registry, verschiedene Arten von Abhängigkeitsinstallationen, Lockfile-Management und mehr.
Am Ende entschieden wir uns für eine sehr einfache Lösung. Wir wählten Yarn (Berry) und Yarn Workspaces, eine einzige Lockfile im Wurzelverzeichnis des Monorepos, keine semantische Versionierung und keine Zero-Installs. Wir kamen zu diesen Entscheidungen, weil wir so wenig Overhead wie möglich, ausgereifte Tools und ein bereits mit diesen Tools vertrautes Team wollten.
Alle potenziellen Vorteile eines Monorepos
Ein Schlüsselmoment während unserer Recherche zu Monorepos war die Erkenntnis, dass diese Architektur zwar sicherlich mit den Problemen, mit denen wir konfrontiert waren, umgehen würde, diese Systeme aber noch so viel mehr boten. Monorepos boten viele Vorteile, die wir nicht unbedingt bedacht hatten, die meisten drehten sich um die Zusammenarbeit der Entwickler.
Wir argumentierten, dass eine Monorepo-Architektur Menschen dazu anregen würde, an Projekten zu kollaborieren, die sie nicht unbedingt besitzen, indem sie den gesamten Code sichtbar machen und Entwicklern so ermöglichen, einfache Korrekturen durchzuführen. Anstatt Hilfe suchen zu müssen, weil du eine Blackbox betrachtest, könntest du selbst eine notwendige Änderung vornehmen, da der gesamte Code leicht zugänglich wäre.
Monorepos würden auch großangelegtes Refactoring ermöglichen, da wir in der Lage wären, große Teile verschiedener Projekte mit einheitlichen Commits zu ändern. Da nun der gesamte voneinander abhängige Quellcode im selben Git-Repository gehostet würde, wäre die Verfügbarkeit und Dateisystemposition jedes Code-Teils vorhersehbar. Das würde es möglich machen, Hilfsprogramme für alle notwendigen Aktionen zur Arbeit mit dem Monorepo lokal oder in der kontinuierlichen Integration (CI) bereitzustellen, z.B. Umgebungskonfiguration, Entwickler-Server, Builds, Checks, automatisches Sym-Linking, Lockfile-Management und mehr. Um es gelinde auszudrücken, wir waren ziemlich begeistert davon.
Nachdem wir einen Monorepo-Entwurf erstellt hatten, mit dem wir zufrieden waren, stellten wir eine Präsentation für den Rest des Teams zusammen, präsentierten unsere Ergebnisse und Konzepte, sammelten Feedback und verbesserten es. Wir wollten sicherstellen, dass wir keine Einrichtung schaffen, mit der jemand nicht arbeiten kann oder möchte. Es wurde gut aufgenommen, und wir beschlossen, voranzuschreiten.
Die physische Migration
Als wir uns auf die Migration vorbereiteten, war unser Hauptziel, laufende Arbeiten nicht zu stören. Wir schrieben ein Skript, das alle bestehenden Repositories aus unserem Polyrepo-Setup nehmen, ihre Git-Historien in eine einzige Historie zusammenführen und die Lücken füllen würde, die notwendig sind, um das vollständige Monorepo zu realisieren. Dieses Skript konnte unser gesamtes Monorepo auf Befehl generieren, was bedeutete, dass wir das Monorepo jederzeit erstellen konnten, unabhängig davon, in welchem Zustand sich das Polyrepo gerade befand. Das war viel besser, als die Entwicklung stilllegen zu müssen, während wir das Monorepo manuell aus dem Polyrepo aufbauten.
Die vollständige Implementierung sah auch eine komplette Neuschreibung unserer CI für alle App- und Paketprüfungen und -bereitstellungen vor, was einen ziemlich großen Teil des Übergangs ausmachte. Wie man CI für ein Monorepo anpasst und schreibt, wird zu einem späteren Zeitpunkt in einem eigenen Artikel behandelt.
Sobald alles bereit und eingerichtet war, legten wir ein Datum für die Migration fest: ein Samstag. Wir wählten einen Wochentag, damit die Leute nach Hause gehen konnten, ihre Arbeit am Freitag hinter sich lassen und am folgenden Montag zurückkommen und finden konnten, was sie gearbeitet hatten, nun innerhalb des Monorepos.
Ab diesem Zeitpunkt betrachteten wir das Polyrepo als veraltet, weil wir nicht kontinuierlich mehrere sich widersprechende Git-Historien pflegen wollten. Um sicherzustellen, dass keine Arbeit verloren ging, stellten wir eine Liste aller aktiven Zweige zusammen, die die Leute gerettet und übertragen haben wollten (wir fügten Unterstützung dafür in unserem Monorepo-Erstellungsskript hinzu).
Auf der anderen Seite
So unrealistisch ehrgeizig der Plan auf dem Papier auch klingen mag, für uns hat es ziemlich reibungslos geklappt! Während der ersten Woche nach der Migration schlugen einige Pipelines fehl, und einige unvollständige Code-Teile wurden im Polyrepo-Setup zurückgelassen und mussten nach dem Übergang manuell übertragen werden. Abgesehen von diesen und einigen anderen kleinen Pannen verlief alles gut. Niemand war ernsthaft daran gehindert, seine Arbeit fortzusetzen, und jetzt, da die Migration abgeschlossen ist, hat niemand zurückgeblickt.
Wir haben entdeckt, dass das Monorepo seit der Migration sogar noch mehr Vorteile bietet als erwartet. Es ist jetzt viel einfacher, Leute in unsere Codebasis einzuarbeiten, dank der Ein-Klick-Einrichtung für eine lokale Entwicklungsumgebung. Eine kleine interne Gemeinschaft hat sich darum entwickelt, und es sind nicht nur Mitglieder aus dem Proton Frontend-Team. Dazu gehören alle, die sich für Monorepo-Architektur interessieren und mit unserem arbeiten. In dieser Community sprechen wir über:
- Monorepos im Allgemeinen (und unser WebClients-Monorepo(new window) im Besonderen)
- Umgang mit Problemen rund um das Monorepo, wenn jemand Hilfe benötigt
- Vorschläge zur Verbesserung unseres Monorepo-Workflows.
Am wichtigsten ist, dass wir jetzt alle dieselbe Sprache sprechen, wenn es um Git-Workflow und Verwaltung geht. Da es jetzt ein einziges Git-Repository ist, haben wir auch Richtlinien für Git über verschiedene Frontend-Feature-Teams hinweg vereinheitlicht und die Regeln unseres Git-Hosting-Tools, das das gesamte Monorepo umfasst (z.B. Merge-Regeln), einheitlich konfiguriert.
Fazit
Rückblickend hat diese Monorepo-Implementierung unsere Erwartungen übertroffen. Es ist eine gute Lösung für unsere Bedürfnisse, und wir sind froh, dass wir uns dafür entschieden haben! Die Verbesserung der Entwicklererfahrung führte zu einem spürbaren Produktivitätsschub. Es ist immer noch kein Allheilmittel, und es gibt viele Herausforderungen, die damit einhergehen, aber für uns überwiegen die Vorteile bei Weitem die Herausforderungen. Wir hoffen, dass diese Baseline-Paketarchitektur Bestand hat und es uns ermöglicht, problemlos und nach Bedarf weitere erforderliche Pakete für die absehbare Zukunft hinzuzufügen.
Das in diesem Artikel diskutierte Git-Repository ist Open Source und kann unter https://github.com/ProtonMail/WebClients(new window) gefunden werden.