Wir haben das Experiment gewagt und unser bestehendes Frontend umgebaut und Microfrontends eingeführt. Was uns dazu bewogen hat und was wir dabei gelernt haben, lernt ihr im Artikel.
Wir hatten folgende Situation: zwei Projektteams, welche voneinander unabhängige Webportale in derselben Firma entwickeln, sollen möglichst viele Komponenten wiederverwenden. Die Wiederverwendung wurde beiden Projekten als Ziel vorgegeben.
Aufgrund der Nutzung einer Microservice-Architektur war das serverseitig für einige Komponenten kein Problem: So wurde etwa das Authentifizierungsmodul zwischen den Projekten geteilt. Jedes Anwendungssystem nutzte lediglich seine eigene Instanz.
Im Frontend war Wiederverwendung auch in vielen Punkten möglich: So wurden etwa der Anmeldebildschirm und das Design System geteilt.
Eine andere geteilte Komponente im Frontend war die zentrale Administrationsanwendung für die internen Fachanwender:innen. In dieser Anwendung lassen sich Mandanten sperren oder einrichten und neue Benutzer:innen hinzufügen. Diese Funktionalitäten hängen fast ausschließlich am Authentifizierungsmodul, welches beide Projekte gemeinsam nutzen. Insofern wurde während der Entwicklung entschieden die zentrale Administration auch frontendseitig zu teilen.
Dieser Ansatz funktionierte mehrere Jahre problemlos. Dann bekamen wir die Anforderung im zentralen Administrationsmodul eine Anforderung speziell für eines der Projekte umzusetzen. Die gewünschte Funktion sollte eine Bereinigungsfunktion in einem fachlichen Microservice triggern, der spezifisch für ein Projekt war.
Das klingt trivial – war es aber nicht. Die Grundannahme war simpel: Wir integrieren die Funktionalität ins zentrale Administrationsmodul und blenden die dazugehörigen Schaltflächen basierend auf den Berechtigungen der Nutzer:innen ein.
Um statische Typisierung für unsere Projekte sicherzustellen, generiert ein Microservice eine passende Schnittstellenbeschreibung per OpenAPI. Diese openapi.json entsteht beim Build des Microservices. Sie wird in den Nexus hochgeladen und im Frontendprojekt per Maven heruntergeladen. Im Frontend wird diese Schnittstellenbeschreibung genutzt, um die TypeScript-Typen für Requests und Responses der Microservices zu generieren. In der Theorie hervorragend, in der Praxis bedeutet es, dass das Frontend nicht vor dem Backend gebaut werden kann, da sonst die Schnittstellendefinition fehlt.
Um jetzt unsere Anforderung umzusetzen, mussten wir dem zentralen Administrationsprojekt eine Referenz auf den projektspezifischen Microservice hinzufügen. Autsch! Die Abhängigkeitssituation ist in der folgenden Abbildung verdeutlicht.
Die Abhängigkeitssituation, in die wir uns selbst begeben haben
In der Regel koordinierten beide Projekte einen gemeinsamen Release-Termin. Das bedeutet, alle Release-Builds werden nacheinander am selben Termin durchgeführt. Dieses Vorgehen funktionierte die ersten Releases gut. Probleme gab es erst, als unser Projekt sein Release verschob. Da jetzt der Entwicklungszeitraum in beiden Projekten zu unterschiedlichen Zeitpunkten endete, wurde festgelegt, dass geteilte Abhängigkeiten, wie die zentrale Administration, zum ersten Release-Termin gebaut werden. Diese verwies per Abhängigkeit allerdings auf unseren projektspezifischen Microservice, der jedoch noch weiterentwickelt wurde. Folglich konnten die TypeScript-Typen nicht generiert werden und die zentrale Administration nicht gebaut werden. Die Situation ließ sich glücklicherweise temporär schnell lösen: Die betreffende Schnittstelle hatte sich nicht geändert und so konnten wir die Version aus dem vorherigen Release nutzen, um unsere TypeScript-Typen zu generieren.
Diese Lösung konnte aber nur temporär funktionieren. Nach Abwägung von verschiedenen Architekturvarianten entschieden wir uns dazu die projektspezifischen Features als Microfrontends umzusetzen. Die Vorteile lagen auf der Hand: Wir haben separate Projekte für unsere Microfrontends, welche getrennt gebaut und released werden können.
Um aus unserem Frontend-Code eine produktive Anwendung zu bauen, benutzen wir webpack als Bundler. Dieser sorgt dafür, dass TypeScript-Code in JavaScript-Code übersetzt wird und dass sämtliche Mediendateien (Fonts, CSS-Styles, Bilder usw.) im richtigen Format vorliegen. Darüber hinaus nimmt der Bundler Optimierungen vor, damit wir eine effiziente Anwendung ausliefern können.
Webpack 5 bietet zur Umsetzung von Microfrontends ein neues Feature namens Module Federation.
Was ist Module Federation?
Ich habe den Begriff Module Federation schon verwendet, doch was ist diese Technologie? Zack Jackson, der Vater von Module Federation beschreibt es wie folgt:
In webpack können über das ModuleFederationPlugin eigene Federated Modules definiert werden. Ein Federated Module besteht aus folgenden Teilen:
- Der name ist der Name des Federated Modules und gleichzeitig sein Scope
- Der remoteEntry ist der Startpunkt, um ein Federated Module zu initialisieren
- remotes ist eine Liste der Federated Modules, die ein Federated Module lädt
- Durch exposes werden Elemente nutzbar für ein ladendes Modul
- shared sind die Abhängigkeiten, welche sich Federated Modules untereinander teilen
Darüber hinaus kann jedes von webpack gebaute Projekt ein Federated Module sein.
Folgender Code zeigt beispielhaft die Definition eines Federated Modules unter dem Namen beispielmicrofrontend. Es stellt ein Modul helpers bereit, also zur Laufzeit helpers.js.
Ein einfaches Federated Module, welches eine Datei bereitstellt
Wir bauen Plugins für unsere Anwendung
Am Anfang stellte sich die Frage, wie wir die Schnittstelle für unsere Anwendung definieren. Über diese Schnittstelle sollte unsere Anwendung ihre Funktionalität mit Hilfe eines Federated Modules erweitern. Bisher konnte beliebiger Code für die Funktionalität aufgerufen werden, da der Code Teil derselben Anwendung war. Als Inspiration für diese Schnittstelle diente untere Entwicklungsumgebung: Sie ist durch Plugins flexibel erweiterbar.
Für die Plugin-Architektur in unserer Anwendung haben wir ein Interface definiert. Die folgende Liste gibt einen Kurzüberblick, über die Dinge, die unser Anwendungsplugin implementieren muss:
- eine Liste aller Routen, die das Plugin definiert
- einen Scope für Routen, Store-Einträge usw. des Plugins
- eine Render-Methode, welche für eine aufgerufene Route das zu rendernde UI zurückgibt
- eine Init-Methode, welche aufgerufen wird, um das Plugin zu laden
- diverse Schnittstellen, um Router, Store und ähnliches von der Hostanwendung zu empfangen
Dieses Interface stellen wir über eine gemeinsam genutzte Bibliothek bereit. Mit dieser Schnittstelle erreichen wir, dass wir eine Anwendung beliebig zur Laufzeit über Federated Modules erweitert werden kann.
Auswirkungen auf unsere Codebasis
Nachdem die Plugin-Schnittstelle implementiert war, mussten die Plugins für die projektspezifischen Funktionalitäten umgesetzt werden. Pro Microservice besaßen wir bereits eine passende Frontend-Bibliothek, welche wiederverwendbare Funktionen zur Nutzung dieses Microservices enthielt. Wir entschieden, die Plugins in diese Bibliotheken zu legen. So weit, so simpel? Nicht ganz!
Da die Bibliotheken jetzt den Code für Federated Modules beinhielten, mussten sie auch mit webpack gebundled werden. Hierfür musste eine webpack-Konfiguration angelegt und der Build in den bestehenden Build-Prozess integriert werden. Darüber hinaus müssen die entstehenden Artefakte der Federated Modules auch deployed, versioniert und archiviert werden. Auch wenn es im Nachhinein völlig einleuchtet, die Anpassungen, die für diese Änderungen insbesondere an unserer CI/CD-Pipeline notwendig waren, sind sehr aufwändig gewesen.
Web Components und Module Federation passen gut zusammen
Unsere Anwendungen nutzen Web Components. Wir nutzen die Bibliothek Lit, um sie zu entwickeln. Um neue, nachgeladene, Komponenten zu registrieren genügt ein Aufruf von window.customElements.define. Es darf kein Elementname mehrfach vergeben werden. Ist das Element bereits registriert, wirft der Browser einen schweren Fehler. Unsere Komponenten sind selbstdefinierend, d. h. der define-Aufruf steht direkt unterhalb der TypeScript-Klasse, welche das Element beschreibt. Beim Import der Quelldatei wird das Element unter dem vorgegebenen Namen registriert. Folgender Code zeigt ein einfaches Beispiel einer selbstdefinierenden Komponente.
Eine einfache Web Component mit Lit, welche unter dem Tag hello-world verfügbar ist
Unsere Komponenten werden als npm-Packages ausgeliefert. Sowohl die Anwendungen als auch unsere Plugins referenzieren dieses Paket. Ohne Module Federation stellt webpack sicher, dass solche Abhängigkeiten nur einmal im gebauten Artefakt vorhanden sind. So kann es nicht zur Mehrfachdefinierung von Komponenten kommen. Dieses Verhalten funktioniert mit Module Federation nicht. Stattdessen müssen geteilte Abhängigkeiten über die shared-Konfigurationseigenschaft angegeben werden.
Abhängigkeiten mit shared teilen
Mit Key shared werden in der Konfiguration des ModuleFederationPlugins Abhängigkeiten angegeben, die sich die Federated Modules untereinander teilen. Folgendes zeigt die Konfiguration eines Federated Modules mit Abhängigkeiten.
Konfiguration eines Federated Modules, welches die Abhängigkeit lit als Singleton definiert
Es können auch Bedingungen angegeben werden, wann eine Abhängigkeit geteilt werden soll. Hierfür kann die benötigte Versionsnummer spezifiziert werden. Diese kann per Semantic Versioning ähnlich zu npm beispielsweise als ^1.0.0 angeben werden.
Standardmäßig werden allerdings nur die Elemente eines npm-Packages geteilt, welche sich im Root des Packagenames befinden. So wird z. B. der Import import {LitElement} from “lit“ zwischen den Federated Modules geteilt. Folgender Import wird jedoch nicht geteilt: import {ref} from “lit/decorators/ref“, da sich ref nicht direkt im Root des Packages lit befindet. Um alle Elemente eines Packages zu teilen, muss in der Konfiguration der Packagename mit einem / abgeschlossen werden. Für die Teilung von Abhängigkeiten gibt es noch deutlich mehr Konfigurationsmöglichkeiten. Diese sind in der offiziellen Dokumentation beschrieben.
Unerwartete Schwierigkeiten
Wir verwenden Staging in unserem Softwareentwicklungsprozess. Das bedeutet wir haben verschiedene Umgebungen: Entwicklung, Test, Abnahme und Produktion. Auf diesen wird dasselbe Softwareartefakt deployed. Für das ModuleFederationPlugin muss die absolute Url eines remoteEntries spezifiziert werden. Das ist für unser Staging nicht praktikabel, da unsere Anwendungen nicht wissen sollen, wo sie deployed werden. Mit dem external-remotes-plugin kann die Url eines remoteEntries um Platzhalter ergänzt werden. Diese Platzhalter werden dann dynamisch zur Laufzeit ersetzt. Der folgende Code zeigt beispielhaft den Plugins-Teil einer webpack Konfiguration. Es wird der Platzhalter libUrl definiert. Dieser gibt dynamisch den Host des Federated Modules an.
Plugins in der webpack Konfiguration. Das ExternalTemplateRemotesPlugin erlaubt die Definition von Platzhaltern in der Remote-Url
Während der Initialisierung der Anwendung setzen wir den Platzhalter libUrl mit Hilfe von window.location.origin.
Ein weiteres Problem zeigte sich in einem fehlschlagenden CI-Build: Wir nutzen eine Content Security Policy, die vorschreibt, dass alles geladene JavaScript per SHA-384-Hash identifizierbar ist. Hierfür nutzen wir das Plugin webpack-subresource-integrity. Es generiert die SHA-384-Hashes automatisch und fügt sie in die Ausgabedatei ein. Die Idee von Microfrontends ist jedoch, dass wir eine losere Kopplung erzwingen: Da wir Federated Modules zur Laufzeit laden und zur Build-Zeit noch nicht wissen, wie genau sie implementiert sind, funktioniert Subresource Integrity nicht für Federated Modules. An dieser Stelle blieb uns also nichts anderes übrig als unsere Content Security Policy für die Administrationsmodul zu ändern: Wir verzichten auf die Generierung von SHA-384-Hashes und stattdessen steht die script-src Direktive für diese Anwendung auf self. Damit können nur Skripte vom eigenen Server geladen werden.
Wie wir Remotes dynamisch definieren und nachladen
Der lokale Test funktioniert, der CI Build läuft. Zum finalen Test prüft der Kollege im anderen Projekt die Änderung. Auch dort funktioniert die Änderung, aber ihm fällt etwas Seltsames in der Entwicklerkonsole auf: Die Anwendung versucht auf unsere remoteEntries zuzugreifen. Offenbar geht webpack standardmäßig davon aus, dass remoteEntries für remotes verfügbar sind und lädt zumindest die remoteEntries. In unserem Fall werden sie im anderen Projekt jedoch nie deployed werden. Deswegen sollte die Anwendung nicht standardmäßig versuchen unsere projektspezifischen Federated Modules zu laden. Wir können unsere remotes also nicht in der webpack Konfiguration definieren, sondern wir müssen sie dynamisch im Anwendungscode hinterlegen. Dieses Konzept heißt bei webpack Dynamic Remote Container. Über das npm Package @module-federation/utilities wird eine Funktion importRemote bereitgestellt, mit welcher remotes dynamisch definiert werden können. Folgendes zeigt beispielhaft, wie ein Plugin dynamisch aus einem Federated Module nachgeladen werden kann.
Dynamisches Laden einer Remote und des dazugehörigen Plugins, falls es benötigt wird
Zuerst setzen wir in der Variable remote die URL zu unserem Server zusammen, da ein Federated Module per vollqualifizierter URL geladen werden muss. Das Objekt, welches wir importRemote übergeben hat folgenden Aufbau:
- url: Der Dateiname des remoteEntries, welchen wir laden wollen.
- scope: Der Name unter diesem das Federated Module in bereitgestellt wird. Dieser muss identisch sein zum name, den das Federated Module in seiner Konfiguration definiert
- module: die Ressource, welche aus dem Federated Module geladen werden soll. Der Pfad entspricht jenem Pfad, welcher unter exposes in der Konfiguration des konsumierten Federated Modules definiert ist.
Das Ergebnis von importRemote in Listing 5 ist die Pluginklasse, welches die projektspezifische Administrationsfunktion enthält. Hiervon müssen wir eine Instanz erzeugen, auf der wir die load-Methode aufrufen, um unser Anwendungsplugin zu laden. In der Realität passiert mit den Plugins mehr als die Init-Methode aufzurufen. So nutzen wir z. B. deren Render-Methoden, um Seiten aus dem Plugin zu rendern. Das Beispiel dient nur zur Veranschaulichung. Da unsere remotes dynamisch nachgeladen werden, können wir den remotes Eintrag aus der Federated Module Konfiguration entfernen.
Und damit konnte der Kollege wieder testen. Und er gibt sein Okay und merged die Änderungen. Seit diesem Tag setzen wir Federated Modules ein. So viel Aufwand die Implementierung im Vorhinein erforderte, in Produktion läuft sie seit langem stabil und ohne Probleme.
Unsere Anwendung ist nicht bereit für Federated Modules
Wie anfangs erwähnt haben wir versucht mit möglichst geringem Aufwand aus einer bestehenden Anwendung Federated Modules zu schneiden. Das unsere Anwendung für diese Operation am offenen Herzen nur bedingt geeignet war, zeigte sich während der Entwicklung an diversen Stellen.
Mein persönlicher Favorit: Mit jedem Neuladen einer Unterseite eines Plugins unsere Anwendung meldete, die Seite nicht zu kennen. Dies konnten Nutzer:innen etwa durch Drücken der F5-Taste oder der entsprechenden Schaltfläche im Browser provozieren. Der Grund für dieses Verhalten war ein Timing-Problem: Bevor das Federated Module komplett geladen war, meldete der Router bereits, dass es sich um eine unbekannte Route handelte und schickte Nutzer:innen auf die 404-Seite. Die zugegeben nicht perfekte Lösung war, den Router erst zu starten, nachdem alle Plugins geladen und initialisiert waren. Damit verzögerte sich die Ladezeit der Anwendung beim Erststart ein wenig (danach cached der Browser die Federated Modules), für eine nicht häufig genutzte Backoffice-Anwendung war dies jedoch zu verschmerzbar. Heutzutage würde ich das Problem wahrscheinlich anders lösen: Es ist in Ordnung, wenn die Administrationsanwendung alle ihre Routen kennt. Das ein Plugin neue Routen in einer Anwendung definiert, ist wohl ein klassisches Beispiel von Over Engineering.
Ein weiteres Beispiel ist, dass unsere Federated Modules aus tausenden kleinen Dateien, welche alle nachgeladen werden müssen. Es zeigt wie effizient das Bundling für Webanwendungen ohne Federated Modules ist. Dass wir so viele kleine Dateien haben, liegt insbesondere daran, dass unser Design System als shared Dependency eingebunden ist. Damit kann ein großer Teil der Komponenten und Funktionen, nicht mehr zu einem Bundle zusammengefasst werden (denn webpack weiß zur Build-Zeit nicht wie Federated Modules diese nutzen). Ab HTTP/2 sollte die Nutzung von vielen kleinen Dateien jedoch kein Problem mehr darstellen. Gleichzeitig haben viele, kleine Dateien eine positive Auswirkung auf das Caching: Eine Änderung führt nur noch zu einer Änderung in wenigen Dateien und nicht zur Änderung des kompletten, großen Bundles.
Ausblick
Unser erstes Projekt mit Module Federation nutzt die geschaffene Plugin Architektur als klar definierte Schnittstelle. Das funktioniert in diesem Szenario gut, in Zukunft wäre es in einem Monorepository aber wünschenswert Federated Modules beliebig implementieren zu können und bei der Einbindung die passenden TypeScript-Typen automatisch zu nutzen. Wir haben hierzu bereits Prototypen umgesetzt, es gibt aber bereits Bestrebungen dies auch offiziell in Module Federation zu ermöglichen.
Weiterhin stellt sich die Frage, was passiert, wenn man in einem Grüne-Wiese-Projekt Federated Modules nutzt. Meine Zielvorstellung wäre, das Design System als Federated Module bereitzustellen, so dass Updates an den Komponenten vorgenommen werden können, ohne dass die nutzenden Anwendungen neu- gebaut oder deployed werden müssen.
Nicht zuletzt stellt sich die Frage, wie die Zukunft von webpack und Module Federation aussieht: Der Erfinder von webpack hat mit turbopack mittlerweile ein neues Projekt, welches als Nachfolger positioniert wird. Turbopack unterstützt jedoch noch keine Module Federation.
Zack Jackson der Vater von Module Federation scheint mittlerweile mit rspack eine andere Alternative zu webpack zu favorisieren. So wurde das jüngste Update Module Federation, Version 1.5, für rspack und webpack veröffentlicht. Allerdings ist die aktuelle Version in rspack bereits standardmäßig aktiv, bei webpack muss sie über ein neues npm Package erst installiert und genutzt werden. Es bleibt abzuwarten, wie lange diese Koexistenz bestehen bleibt und wie sich Module Federation weiterentwickelt.
Dieser Artikel erschien zuerst in der Java Aktuell.
Haben wir Ihr Interesse geweckt? Melden Sie sich gerne bei uns.
zurück zur Blogübersicht