Microfrontends mit Module Federation für bestehende Anwendungen – Ein Erfahrungsbericht

Christian Siebmanns

Die Anwendung ist seit mehreren Jahren produktiv – und dann auf einen Hype wie Microfrontends aufspringen, geht das überhaupt? Wir haben das Experiment gewagt und unser bestehendes Frontend umgebaut und Microfrontends mit Module Federation eingeführt. Was uns dazu bewogen hat und was wir dabei gelernt haben, lernt ihr in diesem Artikel.

Unsere Ausgangslage

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 vom Management vorgegeben.

Aufgrund der Nutzung einer Microservice-Architektur war das serverseitig für einige Komponenten kein Problem: Microservices konnten unter den Projekten geteilt werden, jedes Projekt. Jedes Projekt nutzte lediglich seine eigene Instanz auf eigener Infrastruktur.

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 interne Fachanwender:innen. In dieser Anwendung lassen sich Mandanten und Benutzer:innen verwalten. Diese Funktionalitäten hängen fast ausschließlich am Microservice Authentifizierung, welchen beide Projekte nutzen. Insofern wurde während der Entwicklung entschieden die zentrale Administration auch frontendseitig zu teilen. 

Neue Anforderungen

Dieser Ansatz funktionierte mehrere Jahre problemlos. Dann bekamen wir die Anforderung in der Anwendung 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. 

Unsere Grundannahme war simpel: Wir integrieren die Funktionalität in die Anwendung und blenden die dazugehörigen Schaltflächen basierend auf den Berechtigungen der Nutzer:innen ein. Nutzer:innen des einen Webportals bekommen die Berechtigung, welche die Anzeige regelt und die anderen Nutzer:innen bekommen sie nicht.

Statische Typisierung von Backend Schnittstellen

Um statische Typisierung für unsere Projekte sicherzustellen, generiert ein Microservice-Build eine passende Schnittstellenbeschreibung als OpenAPI. Diese openapi.json wird per Maven in 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, für den die Bereinigungsfunktion bereitgestellt werden soll, hinzufügen. Autsch! Die Abhängigkeitssituation ist in Abbildung 1 verdeutlicht.

Abbildung 1: Die Abhängigkeitssituation, in die wir uns selbst begeben haben

Release-Schwierigkeiten

In der Regel koordinierten beide Projekte einen gemeinsamen Release-Termin. Alle Release-Builds werden nacheinander am selben Termin durchgeführt. Bei diesem Vorgehen ist unsere Abhängigkeitssituation kein Problem. 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 wurden. 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. 

Auf der Suche nach einer Dauerhaften Lösung

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?

Zack Jackson, der Vater von Module Federation beschreibt es wie folgt:

Module federation allows a JavaScript application to dynamically load code from another application — in the process, sharing dependencies, if an application consuming a federated module does not have a dependency needed by the federated code — Webpack will download the missing dependency from that federated build origin.

Zack Jackson, Webpack 5 Module Federation: A game-changer in JavaScript architecture

In webpack können über das ModuleFederationPlugin eigene Federated Modules definiert werden. Federated Modules sind die Hauptentität von Module Federation. Jedes Webpack-Projekt kann ein Federated Module sein. 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

Listing 1 zeigt beispielhaft die Definition eines Federated Modules unter dem Namen beispielmicrofrontend. Es stellt ein Modul helpers bereit, also zur Laufzeit helpers.js. 

const { ModuleFederationPlugin } = require("webpack").container;
const pluginConfig = {
plugins: [
    new ModuleFederationPlugin({
      name: "beispielmicrofrontend",
      exposes: {
        "./helpers": "./src/helpers",
      },

    }),
  ],
}  

Listing 1: Ein einfaches Federated Module, welches eine Datei bereitstellt 

Plugins für unsere zentrale Administrationsanwendung

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 diesen Bibliotheken zu platzieren.

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 werden. Außerdem mussten wir unsere Build Pipeline anpassen, so dass sie Bibliotheken bauen, versionieren, archivieren und deployen konnte. Diese Anpassungen an der Pipeline waren aufwändig.

Web Components und Module Federation passen gut zusammen

Unsere Anwendungen nutzen Web Components. Wir nutzen die Bibliothek Lit von Google, 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. Listing 2 zeigt ein einfaches Beispiel einer selbstdefinierenden Komponente.

import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";

class HelloWorld extends LitElement {
  public render() {
    return html`<p>Hello world!</p>`;
  }
}
window.customElements.define("hello-world", HelloWorld);

Listing 2: Eine einfache Web Component mit Lit, welche unter dem Tag hello-world verfügbar ist

Unsere Web Components werden als npm-Packages ausgeliefert. Sowohl die Anwendungen als auch unsere Plugins referenzieren diese Pakete. 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. 

Abhängigkeiten mit shared teilen

Dieses Verhalten funktioniert mit Module Federation nicht. Mit Key shared werden in der Konfiguration des ModuleFederationPlugins Abhängigkeiten angegeben, die sich die Federated Modules untereinander teilen. Listing 3 zeigt die Konfiguration eines Federated Modules mit Abhängigkeiten. 

const { ModuleFederationPlugin } = require("webpack").container;
const pluginConfig = {
plugins: [
    new ModuleFederationPlugin({
      name: "admin",
      remotes: {
        lib: "lib@http://localhost/lib/remoteEntry.js",
      },
      shared: {"lit/": {singleton: true}},
         ]
}

Listing 3: Konfiguration eines Federated Modules, welches die Abhängigkeit lit als Singleton definiert

Es können auch Bedingungen angegeben werden, wann eine Abhängigkeit zwischen Federated Modules geteilt werden soll. Hierfür kann die benötigte Versionsnummer spezifiziert werden. Diese kann per Semantic Versioning ähnlich zu npm (3)beispielsweise als ^1.0.0 angeben werden.

Standardmäßig werden allerdings nur die Elemente eines npm-Packages geteilt, welche sich im Root des Packages 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, wie in Listing 3.

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. Module Federation erwartet aus technischen Gründen einen absoluten Pfad zu einem anderen Federated Module. 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. Listing 4 zeigt beispielhaft den Plugins-Teil einer webpack Konfiguration. Es wird der Platzhalter libUrl definiert. Dieser gibt dynamisch den Host des Federated Modules an. Während der Initialisierung der Anwendung setzen wir den Platzhalter mit Hilfe von window.location.origin.

const { ModuleFederationPlugin } = require("webpack").container;
const ExternalTemplateRemotesPlugin = require("external-remotes-plugin");

// Plugins Teil der Webpack-Konfiguration

plugins: [
    new ModuleFederationPlugin({
      name: "admin",
      remotes: {
        lib: "lib@[libUrl]/lib/remoteEntry.js",
      },
      shared: {"lit/": {singleton: true}},
    }),
new ExternalTemplateRemotesPlugin()
]

Listing 4: Plugins in der webpack Konfiguration. Das ExternalTemplateRemotesPlugin erlaubt die Definition von Platzhaltern in der Remote-Url.

Module federation und die content security policy

Ein weiteres Problem zeigte sich in einem fehlschlagenden CI-Build: Wir nutzen eine Content Security Policy (CSP), 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. Da wir Federated Modules zur Laufzeit laden und zur Build-Zeit noch nicht wissen, wie genau sie implementiert sind, funktioniert die Generierung der Hashes für diese nicht. An dieser Stelle blieb uns nichts anderes übrig als unsere Content Security Policy für die Administrationsanwendung zu ändern: Wir verzichten auf die Generierung von SHA-384-Hashes und stattdessen steht die Direktive script-src für diese Anwendung auf self. Damit können nur Skripte vom eigenen Server geladen werden. Diese Sicherheitsmaßnahme hat in unserem Fall denselben Effekt.

Wie wir Remotes dynamisch laden und unsere Plugins nutzen

Bei der Prüfung unseres Prototypen zeigte sich, dass webpack offenbar immer versucht die remoteEntries der konsumierten Federated Modules zu laden, unabhängig davon, ob Elemente aus den Modulen geladen wurden oder nicht. 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. Über das npm Package @module-federation/utilities wird eine Funktion importRemote bereitgestellt, mit welcher remotes dynamisch definiert werden können. Listing 5 zeigt beispielhaft, wie ein Plugin dynamisch aus einem Federated Module nachgeladen werden kann. 

import { importRemote } from "@module-federation/utilities";
import { LitElement } from "lit";
import type { Plugin } from "projects/common";




class MyApplication extends LitElement {
  // ...
  protected async setupRemotes() {
if (!this.spezielleAdminFunktionBenoetigt) {
    return;
}
    const remote = `${window.location.protocol}//${window.location.host}`;
    // Klasse laden
    const pluginClass = (await importRemote({
      url: `${remote}/modules/spezielle-admin-funktion`,
      scope: "spezielle_admin_funktion",
      module: "./features/admin-feature",
})) as () => Plugin;
// Instanz des Plugins erzeugen
const plugin = new pluginClass();
//Plugin laden und initialisieren
plugin.load();
    plugin.init();

  }

  // ...
}

Listing 5: 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, welche 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 ist die Verwaltung eines Plugins komplexer. Das Beispiel dient nur zur Veranschaulichung. Da unsere remotes dynamisch nachgeladen werden, können wir den remotes Eintrag aus der Federated Module Konfiguration entfernen. 

Unsere Anwendung ist nicht bereit für Module Federation

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.

Mit jedem Neuladen einer Unterseite eines Plugins monierte unsere Anwendung, 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 verschmerzbar.

Ein weiteres Beispiel ist, dass unsere Federated Modules aus tausenden kleinen Dateien bestehen. 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 auf Module Federation 2.o

Unser erstes Projekt mit Module Federation nutzt die geschaffene Plugin-Architektur als klar definierte Schnittstelle. Das funktioniert in diesem Szenario gut, trotzdem wären es wünschenswert auf die Pluginschnittstelle verzichten zu können und stattdessen die Typen vom Federated Module bereitgestellt zu bekommen. 

Module Federation 2.0 bietet die Möglichkeit Federated Modules typisiert zu nutzen. Die Typen werden zur Entwicklungszeit in Echtzeit generiert und ausgeliefert. Somit können alle exportierten Typen eines Federated Modules genutzt werden. Dies funktioniert sogar rekursiv, d. h. es werden sogar Typen für konsumierte Federated Modules von Federated Modules generiert. Weiterhin werden Typen für genutzte Dependencies wie React oder Lit direkt mit exportiert, wo nötig. Typisierung ist ein Gamechanger für Module Federation.

Module Federation 2.0 bietet jedoch noch einige weitere interessante Neuerungen: Der Module Federation Runtimecode wurde aus webpack herausgeschnitten und in ein separates Package ausgelagert. Dies hat mehrere Vorteile: Module Federation ist damit bundlerunabhängig geworden. Dies ermöglicht nicht nur die Unterstützung für neue Bundler wie rspack oder Vite, sondern auch dass Federated Modules mit jedem Bundler eingebunden werden können, egal mit welchem Bundler sie erstellt wurden. Weiterhin bietet das neue Runtimepackage flexible Möglichkeiten zum Laden von Federated Modulen zur Laufzeit. Dynamisch Module nachzuladen, so wie wir es mit importRemote realisiert haben, ist also deutlich einfacher geworden. 

Mit dem neuen Release wurden auch neue Developer Tools eingeführt. Diese sind für Chrome-basierte Browser als Plugin verfügbar. Sie ermöglichen die Analyse von Informationen der einzelnen Federated Modules wie etwa Dependencies oder exposed Module. Nicht zuletzt ermöglicht es Hot Reloading von Federated Modules während der Entwicklung. 

Module Federation 2.0 bietet im Vergleich zur Module-Federation-Implementierung von webpack 5 signifikante Upgrades insbesondere für die Developer Experience. Module Federation ist sowohl in unseren Erfahrungen im Produktiveinsatz als auch durch die kontinuierliche Weiterentwicklung von Module Federation zu einer guten Möglichtkeit zur Umsetzung von Micro Frontends geworden. 

Total
0
Shares
Previous Post

Immer auf dem Laufenden – mit jeder neuen kostenlosen PDF-Ausgabe!

Next Post

Web-UI für den URL Shortener

Related Posts