Blog

JavaScript Days | Angular Days | React Days | HTML & CSS Days
Die großen Trainingsevents für JavaScript, Angular, React, HTML & CSS
15
Sep

Microfrontends im Monorepo

Monorepos vereinfachen Aufgaben, die rund um Microfrontends anfallen, bergen jedoch ein paar bewusste Einschränkungen. Damit sie skalieren können, sind darauf ausgelegte Prozesse und Werkzeuge notwendig.

Das Hauptziel von Microfrontends ist Isolation: Einzelne kleine Anwendungen, die gemeinsam ein größeres System bilden, sollen unabhängig voneinander umgesetzt werden. Somit kann sich jeweils ein kleines Team um jede Anwendung kümmern. Da diese Teams autark sind, müssen sie sich nicht gegenseitig abstimmen und können möglichst rasch Geschäftsnutzen bieten. Deswegen ist es auch üblich, Microfrontends in einem jeweils eigenen Repository zu platzieren [1]. Das bringt zwar Isolation, aber auch einen Mehraufwand bei der Verwaltung und beim Teilen von Bibliotheken mit sich. Monorepos, die mehrere Microfrontends beherbergen, machen solche Aufgaben hingegen einfacher.

In diesem Artikel möchte ich die Konsequenzen beider Ansätze herausarbeiten. Als Beispiel [2] nutze ich eine Angular-Lösung, die auf Module Federation und einem Nx-Monorepo basiert.

Ein Repository pro Microfrontend

Der Einsatz mehrerer Repositorys sorgt für ein Maximum an Isolation. Die Repositorygrenzen verhindern Abhängigkeiten zwischen den einzelnen Microfrontends (Abb. 1).

steyer_monorepos_1.tif_fmt1.jpgAbb. 1: Verwaltung von Microfrontends in mehreren Repos

Da jedes Team sein eigenes Repository hat, können diese autark agieren: Jedes Team entscheidet selbst, welche Frameworks, Bibliotheken und Versionen zum Einsatz kommen. Auch der Zeitpunkt von Updates obliegt den Teams. Eine Abstimmung mit anderen Teams ist prinzipiell nicht nötig. Das bringt Agilität. Interne Bibliotheken, die die Teams untereinander teilen, werden versioniert und über eine npm Registry bereitgestellt. Um den Aufwand dafür gering zu halten, ist ein hoher Grad an Automatisierung nötig. Das bedeutet aber auch, dass das notwendige Wissen dazu im Team vorhanden sein muss.

Es kann vorkommen, dass mehrere Versionen ein und derselben internen Bibliothek gewartet werden müssen. Schließlich kann jedes Team entscheiden, wann und ob es ein Update durchführt. Ein Team, das beispielsweise beschlossen hat, bei Angular 10 zu bleiben, braucht auch Versionen der internen Bibliotheken, die für Angular 10 kompiliert wurden. Werden die einzelnen Microfrontends dem Benutzer als integrierte Lösung präsentiert, kann es auch notwendig werden, mehrere Versionen derselben Frameworks und Bibliotheken zu laden. Beispielsweise könnten so sowohl Angular 10 als auch Angular 11 im Browser landen. Das wirkt sich natürlich negativ auf die Ladezeiten aus. Hier wird ein Zielkonflikt zwischen Isolation und optimierten Ladezeiten deutlich: Entweder sind die einzelnen Anwendungs-Bundles voneinander isoliert und bringen ihre eigenen Abhängigkeiten oder die einzelnen Microfrontends greifen auf geteilte Abhängigkeiten zu. Im letzteren Fall müssen sich Teams auf die geteilten Versionen zu Lasten einer isolierten Vorgehensweise einigen.

Ein Monorepo für alle Microfrontends

Ein alternativer Organisationsansatz, der unter anderem bei Firmen wie Google, Facebook oder Microsoft zum Einsatz kommt, ist das Monorepo. Es handelt sich dabei um ein Repository, das mehrere zusammengehörige Projekte beherbergt (Abb. 2).

steyer_monorepos_2.tif_fmt1.jpgAbb. 2: Verwaltung von Microfrontends in einem Monorepo

Monorepos vereinfachen das Teilen von Quellcode. Anstatt eine neue Version über eine Registry zu veröffentlichen, werden die Änderungen einfach in einen entsprechenden Branch übernommen. Außerdem muss auch immer nur die aktuellste Version im Repository gewartet werden. Daneben sehen einige Monorepo-Implementierungen auch vor, dass jede Abhängigkeit nur in einer einzigen Version für alle Projekte vorliegt. Der oben erwähnte Fall, dass mehrere Angular-Versionen miteinander kollidieren, kann sich also erst gar nicht ergeben.

Prozesse und Werkzeuge für skalierbare Monorepos

Der Monorepo-Ansatz skaliert jedoch nur, wenn sowohl entsprechende Prozesse als auch Werkzeuge zum Einsatz kommen. Die Prozesse regeln zum Beispiel, wie mit Breaking Changes von Bibliotheken umzugehen ist. Denkbar ist das Etablieren einer Deprecation Policy, die eine gewisse Abwärtskompatibilität für einen bestimmten Zeitraum vorsieht oder das Veröffentlichen neuer Bibliotheksversionen in Release-Branches. Mit diesen können die Produktteams prüfen, ob die schlussendliche Umstellung auf die neue Bibliotheksversion funktionieren wird. Es liegt auf der Hand, dass diese Prozesse die Souveränität der einzelnen Teams einschränken. Auf der anderen Seite verhindern sie die Koexistenz verschiedener Bibliotheksversionen und stellen sicher, dass Versionskonflikte vermieden oder zumindest frühzeitig aufgelöst werden.

Neben Prozessen benötigen Teams auch entsprechende Werkzeuge für den Einsatz skalierbarer Monorepos. Eine Isolation zwischen den einzelnen Domänen und somit zwischen den Microfrontends kann über Linting sichergestellt werden. Ein populäres Werkzeug, das diese Ideen automatisiert, ist Nx [3]. Abbildung 3 zeigt beispielsweise eine von Nx angebotene Linting-Regel in Aktion, die den Zugriff zwischen einzelnen Teilprojekten einschränkt.

steyer_monorepos_3.tif_fmt1.jpgAbb. 3: Linting zum Verhindern domänenübergreifender Zugriffe

Um zu verhindern, dass ständig das gesamte Monorepo gebaut und getestet werden muss, brauchen wir die Option, diese Aufgaben lediglich für die geänderten Programmteile auszuführen. Auch für solche inkrementellen Builds und Tests bietet Nx-Lösungen.

Microfrontends mit Module Federation und einem Nx Monorepo

Um die in den letzten beiden Abschnitten besprochenen Ideen zu Microfrontends und Monorepos zu veranschaulichen, nutze ich hier ein einfaches Beispiel, das auf Angular und Nx basiert. Die Integration der Front-ends übernimmt eine Shell. Dabei handelt es sich um eine SPA, die die Microfrontends bei Bedarf lädt. Für diese Aufgabe nutzt die Shell das von webpack 5 angebotene Module Federation [4]. Um den Brückenschlag zwischen Angular und dem Angular CLI auf der einen und Module Federation auf der anderen Seite kümmert sich die CLI-Erweiterung @angular-architects/module-federation [5]. Da ich diese Werkzeuge schon in vorangegangenen Ausgaben vorgestellt habe, gehe ich hier nicht im Detail darauf ein. Die am Ende des Artikels platzierten Links führen jedoch zu den für den Einstieg benötigten Informationen.

Die Struktur des Monorepos spiegelt der mit Nx generierte Abhängigkeitsgraph in Abbildung 4 wider.

steyer_monorepos_4.tif_fmt1.jpgAbb. 4: Aufbau des beispielhaften Monorepos

Neben einer Shell beinhaltet das Monorepo ein erstes Microfrontend: mfe1. Beide Anwendungen werden separat kompiliert und bereitgestellt. Das Projekt mfe1 nutzt zwei fachliche Bibliotheken: mfe1-feature-search und mfe1-feature-book. Diese teilt es jedoch nicht mit anderen Anwendungen, sondern nutzt sie lediglich zur Codestrukturierung. Deswegen kann Nx diese beiden Bibliotheken auch ins Kompilat von mfe1 aufnehmen.

Anders gestaltet es sich bei der technischen Bibliothek shared-auth-lib. Die Shell und mfe1 teilen sich diese Bibliothek. Deswegen bietet es sich an, sie separat zu kompilieren und zur Laufzeit nur einmal zu laden. Das verbessert nicht nur die bei Webclients mitunter wichtige Startperformance, sondern ermöglicht auch, dass sich die Anwendungen den Zustand dieser Bibliothek teilen. Dabei handelt es sich lediglich um den Namen des angemeldeten Benutzers (Abb. 5).

steyer_monorepos_5.tif_fmt1.jpgAbb. 5: Die Shell und das Microfrontend teilen die auth-lib und ihre Zustände

Teilen von Abhängigkeiten mit Module Federation

Die von Module Federation verwendete Konfiguration erlaubt die Angabe von Node-Paketen, die sich ein Microfrontend mit anderen teilen soll. Der Ausschnitt in Listing 1 gibt zum Beispiel an, dass die Bibliotheken @ angular/core, @angular/common und @angular/router zu teilen sind.

Listing 1

new ModuleFederationPlugin({
  […]
  shared: {
    "@angular/core": { singleton: true, strictVersion: true }, 
    "@angular/common": { singleton: true, strictVersion: true }, 
    "@angular/router": { singleton: true, strictVersion: true }
   }
}),

Teilen bedeutet, dass der Build-Prozess für diese Bibliotheken eigene Bundles erzeugt. Beim Programmstart handeln die Shell und die Microfrontends aus, wessen Version zum Einsatz kommt. Standardmäßig entscheidet sich Module Federation für die jeweils höchste kompatible Version. Stehen beispielsweise Version 10.0 und 10.1 zur Verfügung, fällt die Wahl auf 10.1. Existiert keine höchste kompatible Version, kommt es zu einem Konflikt. Das ist der Fall, wenn verschiedene Major-Versionen vorliegen, die laut Semantic Versioning nicht miteinander kompatibel sein müssen, beispielsweise 10.0 und 11.0. Über die Konfiguration lässt sich angeben, wie Module Federation solche Konflikte auflösen soll. Im hier betrachteten Beispiel legen die Eigenschaften singleton: true und strictVersion: true fest, dass darauf mit einem Laufzeitfehler zu reagieren ist. Details finden sich unter [6].

Die Herausforderung bei Angular-CLI-basierten Monorepos ist nun jedoch, dass die geteilten Bibliotheken keine npm-Pakete sind. Es handelt sich dabei lediglich um Ordner, die über Path-Mappings in der TypeScript-Konfiguration einen Namen erhalten, der dem eines npm-Pakets gleicht. Zum Glück haben die Macher von Module Federation diesen Fall berücksichtigt: Anstatt auf ein npm-Paket zu verweisen, kann die Konfiguration direkt die benötigten Metadaten entgegennehmen (Listing 2).

Listing 2

new ModuleFederationPlugin({
  […]
  shared: {
    […]
    "@nx-mf-demo/shared/auth-lib": {
      import: path.resolve(__dirname, "../../libs/auth-lib/src/index.ts"),
      version: '1.0.0',
      requiredVersion: '^1.0.0'
    },
  }
}),

Der Schlüssel des Konfigurationsobjekts verweist auf den Namen des Path-Mappings. Hierüber importieren die Anwendungen die geteilte Bibliothek:

import { AuthLibModule } from '@nx-mf-demo/shared/auth-lib';

Die Eigenschaft import verweist auf den Einsprungpunkt der Bibliothek. Dabei handelt es sich in der Regel um ihr öffentliches API, das ausgewählte Elemente exportiert. Die aktuell vorliegende Version dieser Bibliothek findet sich in version. Jene Version(en), die die konfigurierte Anwendung akzeptiert, ist hingegen unter requiredVersion abzulegen.

Diese beiden Versionen verwendet Module Federation bei der erwähnten Aushandlung beim Programmstart. Im betrachteten Fall bietet die konfigurierte Anwendung Version 1.0.0 an, während sie sich jedoch auch mit einer höheren Version (>1.0.0) zufrieden gibt, sofern ein anderes Microfrontend eine solche anbietet. Gehen wir davon aus, dass in einem Monorepo immer lediglich die höchste Version existiert, und dass ein Deployment immer sämtliche geänderte Programmteile umfasst, können wir diese Aushandlung auch deaktivieren. Dazu ist lediglich requiredVersion auf false zu setzen (Listing 3).

Listing 3

new ModuleFederationPlugin({
  [...]
  shared: {
    [...]
    "@nx-mf-demo/shared/auth-lib": {
      import: path.resolve(__dirname, "../../libs/auth-lib/src/index.ts"),
      requiredVersion: false
    },
  }
}),

Im Fall von Angular kommt erschwerend dazu, dass auch der vom Angular-Compiler generierte Code auf geteilte Bibliotheken zugreifen muss. Um das zu ermöglichen, bedarf es ein paar Details in der Konfiguration. Damit wir uns damit nicht belasten müssen, bietet die erwähnte CLI-Erweiterung @angular-architects/module-federation eine Hilfsklasse SharedMappings an (Listing 4).

Listing 4

[...]
 
const sharedMappings = new mf.SharedMappings();
sharedMappings.register(
  path.join(__dirname, '../../tsconfig.base.json'),
  ['@nx-mf-demo/shared/auth-lib']);
 
module.exports = {
  […]
  plugins: [
    new ModuleFederationPlugin({
      [...]
      shared: {
        "@angular/core": { [...] },
        "@angular/common": { [...] },
        "@angular/router": { [...] },
        sharedMappings.getDescriptors()
      }
 
    }),
    sharedMappings.getPlugin(),
  ],
};

Mit der Methode register nimmt SharedMappings die Namen der gemappten Pfade, die als Monorepo-interne Bibliotheken fungieren, entgegen. Damit generiert getDescriptors die in Listing 2 und Listing 3 diskutierten Einstellungen. Die Methode getPlugin liefert hingegen ein konfiguriertes webpack-Plug-in, das für den vom Angular-Compiler generierten Code benötigt wird. Da die webpack-5-Integration derzeit noch ein experimentelles Feature des Angular CLI ist, gibt es hier den einen oder anderen Fallstrick. Eine Übersicht findet sich am Ende von [5]. Mit CLI 12 möchte das Angular-Team im Mai 2021 jedoch eine stabile webpack-5-Integration ausliefern. Dann sollten diese Fallstricke der Vergangenheit angehören.

Deployment

Da in einem Monorepo immer nur eine Version aller Bibliotheken existiert, bietet es sich an, bei jedem Deployment sämtliche Anwendungen (Microfrontends, Shell), die von durchgeführten Programmänderungen betroffen sind, bereitzustellen. Um herauszufinden, welche Anwendungen von den durchgeführten Änderungen betroffen sind, stellt Nx das npm-Skript affected:apps bereit (Abb. 6).

steyer_monorepos_6.tif_fmt1.jpgAbb. 6: Ermitteln der von Änderungen betroffenen Anwendungen

Standardmäßig vergleicht dieser Befehl den aktuell vorliegenden Quellcode mit dem des Haupt-Branch. Dessen Name lässt sich in der nx.json hinterlegen. Durch Angabe entsprechender Parameter können wir jedoch auch zwei beliebige Branches bzw. auch zwei beliebige Commits miteinander vergleichen. Zusätzlich führt Nx eine statische Quellcodeanalyse durch, um sämtliche Anwendungen zu identifizieren, die von den geänderten Dateien abhängig sind.

Da das npm-Skript affected:apps die betroffenen Anwendungen auf der Konsole ausgibt, lässt es sich wunderbar in den CI/CD-Prozess integrieren. Somit können automatisiert sämtliche betroffenen Anwendungen bereitgestellt werden. Um dieselben Informationen auf eine graphische Weise zu erhalten, bietet sich das npm-Skript affected:dep-graph an. Es führt zu einem Abhängigkeitsgraphen, der sämtliche von Änderungen betroffene Programmteile mit roten Rahmen präsentiert (Abb. 7).

steyer_monorepos_7.tif_fmt1.jpgAbb. 7: Abhängigkeitsgraph mit von Änderungen betroffenen Programmteilen

Daneben können wir mit dem Skript affected:build nur die betroffenen Anwendungen neu bauen lassen. Das Skript affected:test führt lediglich die Unit-Tests für diese Anwendungen aus; affected:e2e macht dasselbe mit deren End-to-End-Tests.

Isolation

Wie eingangs erwähnt, ist das Hauptziel von Microfrontends das Isolieren von Anwendungen, sodass einzelne Teams diese möglichst autark (weiter)entwickeln können. Der Einsatz eines Repositorys pro Anwendung hilft dabei. Bei Monorepos benötigt man hierfür hingegen Linting. Nx unterstützt u. a. das populäre Linting-Werkzeug ESLint und bietet darauf aufbauend eine Linting-Regel, mit der sich der Zugriff zwischen Anwendungen und Bibliotheken einschränken lässt.

Um diese Möglichkeit zu nutzen, weisen wir zunächst innerhalb der generierten nx.json jeder Anwendung (Microfrontend) und jeder Bibliothek eine oder mehrere Kategorien (Tags) zu (Listing 5).

Listing 5

"shell": {
"tags": ["scope:shell"]
},
"mfe1": {
  "tags": ["scope:mfe1"]
},
"shared-auth-lib": {
  "tags": ["scope:shared"]
},
"mfe1-feature-search": {
  "tags": ["scope:mfe1"]
},
"mfe1-feature-book": {
  "tags": ["scope:mfe1"]
}

Die einzelnen Kategorien können wir frei vergeben. Das hier gezeigte Beispiel definiert über diese Kategorien, ob die jeweilige Anwendung zur Shell (scope:shell) oder zum Microfrontend (scope:mfe1) gehört bzw. von beiden geteilt wird (scope:shared).

Basierend auf diesen Kategorien lässt sich in der Datei .eslintrc.json die Linting-Regel @nrwl/nx/enforce-module-boundaries konfigurieren (Listing 6).

Listing 6

"rules": {
  "@nrwl/nx/enforce-module-boundaries": [
    "error",
    {
      "enforceBuildableLibDependency": true,
      "allow": [],
      "depConstraints": [
      {
        "sourceTag": "scope:shell",
        "onlyDependOnLibsWithTags": ["scope:shell", "scope:shared"]
        },
        {
          "sourceTag": "scope:mfe1",
          "onlyDependOnLibsWithTags": ["scope:mfe1", "scope:shared"]
        },
        {
          "sourceTag": "scope:shared",
          "onlyDependOnLibsWithTags": ["scope:shared"]
        }
      ]
    }
  ]
}

Die hier gezeigte Konfiguration stellt sicher, dass die Shell nicht direkt auf das Microfrontend und vice versa zugreifen darf. Beide dürfen jedoch die geteilte auth-lib verwenden. Ein Verstoß gegen diese Regeln wird mit einem Linting-Fehler geahndet. Er wird während des Tippens von der IDE präsentiert (Abb. 3), sofern diese ESLint unterstützt. Außerdem lässt sich das Linting auf der Kommandozeile mit ng lint anstoßen (Abb. 8).

steyer_monorepos_8.tif_fmt1.jpgAbb. 8: Linting über die Konsole anstoßen

Somit lässt sich auch das Linting im Rahmen des CI/CD-Prozesses bzw. im Rahmen von Check-in-Regeln ausführen und eine Isolation zwischen den Microfrontends erzwingen.

Zusammenfassung

Der Einsatz getrennter Repositorys bietet ein Maximum an Flexibilität. Diese kommt jedoch mit einem Preis: Wir müssen geteilte Pakete versionieren, über eine Registry bereitstellen und ggf. mehrere Versionen warten. Da jedes Team selbst über die verwendeten Pakete und Versionen entscheidet, müssen mitunter mehrere Frameworks sowie mehrere Versionen desselben Frameworks in den Browser geladen werden. Beim Monorepo-Einsatz schränken sich die Teams hingegen bewusst ein wenig ein: Sie nutzen immer die neuesten Versionen interner Bibliotheken und müssen somit auch nur diese warten. Außerdem einigen sie sich auf die zu nutzenden Versionen externer Bibliotheken, wie z. B. Angular oder React. Das verringert auch die Größe der zu ladenden Bundles.

Damit der Monorepo-Ansatz funktioniert, braucht es darauf abgestimmte Prozesse. Diese legen zum Beispiel fest, in welchem Maß neue Versionen abwärtskompatibel sein müssen. Außerdem skalieren Monorepo-Projekte nur, wenn auch entsprechende Werkzeuge zum Einsatz kommen. Die betrachtete Angular-CLI-Erweiterung Nx erlaubt zum Beispiel, direkte Zugriffe zwischen einzelnen Microfrontends via Linting einzuschränken. Außerdem unterstützt es inkrementelle Builds sowie Tests und ermittelt jene Microfrontends, die sich seit dem letzten Deployment geändert haben. Auch wenn es sich bei Bibliotheken in Nx-Monorepos um keine npm-Pakete handelt, kann Module Federation sie zur Laufzeit teilen. Dazu sind jene Metadaten, die es ansonsten aus den Paketen entnimmt, manuell anzugeben. Das Plug-in @angular-architects/module-federation automatisiert diese Aufgabe.

 

 

Links & Literatur

[1] https://martinfowler.com/articles/micro-frontends.html

[2] https://github.com/manfredsteyer/nx-and-module-federation

[3] https://nx.dev/angular

[4] https://www.angulararchitects.io/aktuelles/the-microfrontend-revolution-part-2-module-federation-with-angular/

[5] https://www.npmjs.com/package/@angular-architects/module-federation

[6] https://www.angulararchitects.io/aktuelles/getting-out-of-version-mismatch-hell-with-module-federation/

Immer auf dem Laufenden bleiben!
Alle News & Updates: