Das Nx-Monorepo – Verwalte Deine Anwendung in einem Repository

Foto des Autors
Robert Weyres

Ich zeige Dir in diesem Tutorial, wie Du mit einem Nx Monorepo Deine Anwendung mit mehr Organisation und weniger Duplikaten erstellen kannst. Das Beispielprojekt findest Du auf GitHub.

Einführung

Wann immer ein neues Projekt geplant wird, stellt sich die Frage, welche Technologien verwendet werden sollen. Hierbei kommt es auf verschiedene Faktoren an, die für jeden Anwendungsfall individuell sind – wie Skalierbarkeit für Anwendungen mit unterschiedlich hohem Zugriffsaufkommen oder Verfügbarkeit für Serviceanwendungen. In meinem letzten In-House Projekt hatte ich den Luxus, meinen Technologiestack für Front- und Backend selbst wählen zu können. Ich habe mich gegen den traditionellen Ansatz entschieden, ein Java-Backend zu verwenden. Stattdessen wollte ich versuchen, sowohl Frontend als auch Backend in einem Monorepo zu implementieren.

Zur Realisierung meines Plans habe ich mich für ein Nx-Monorepo entschieden, also ein Repository, welches alle Teile der Anwendung beinhaltet. Nx ist ein Tool was genau für diesen Use Case entwickelt wurde und es spielend einfach macht, verschiedene Technologien zu kombinieren. Hierbei muss man sich nicht auf ein Framework festlegen, sondern kann beliebige Projekte innerhalb des Monorepo kombinieren und sogar Code projektübergreifend verwenden – man kann also zum Beispiel dieselben Typdefinitionen in Front- und Backend einsetzen, oder umgebungsspezifische Daten für beide Anwendungen verfügbar machen. Das spart Code, Zeit und eliminiert potentielle Fehlerquellen bei der Duplikation. Um dabei aber nicht den Überblick zu verlieren, lässt sich mit Nx leicht definieren, welche Anwendungen Zugriff auf welchen Code haben.
 
Mit dem eigenen Kommandozeilentool wird die Generierung des Projektes sehr ähnlich zu Angular-Projekten. Die zahlreichen Nrwl-Plugins ermöglichen es, aus verschiedenen Frontend- und Backend-Technologien auszuwählen und diese zu kombinieren. Wird als Code-Editor Visual Studio Code verwendet, bietet Nx sogar ein Plugin mit dem die Funktionen des Kommandozeilentools in einer grafischen Oberfläche interaktiv ausgeführt werden können.
 
Für meinen Use Case habe ich mich für Angular im Frontend und Nest.js im Backend entschieden. Das Frontend wird sowohl als mobile App für Android und iOS, aber auch als Webapp benutzbar sein. Zur Generierung der Apps verwende ich Capacitor, welches mit nahezu allen modernen JavaScript Anwendungen kompatibel ist und native Apps erzeugt. Um plattformübergreifend ein einheitliches Aussehen zu erreichen, können z.B. Komponenten des Ionic-Frameworks verwendet werden, einem Framework zur Entwicklung plattformübergreifender Apps in Angular, React oder Vue. Hiermit werden Anwendungen gebaut, die sowohl auf Android und iOS, aber auch als Desktop- oder Web-App lauffähig sind. Um dieses Ziel zu erreichen wird intern Capacitor benutzt, was allerdings nicht von Ionic abhängig ist. Um mein Repository möglichst schlank zu halten habe ich mich dafür entschieden, lediglich die Angular-Komponentenbibliothek von Ionic zu verwenden und mit Hilfe von Capacitor aus meiner Angular-Anwendung die mobilen Apps zu generieren.
 
Nx bietet Plugins, um die Einrichtung der Technologien zu vereinfachen, somit ist das Generieren und Verknüpfen der Anwendungen leicht. Für End-to-End Tests zum Beispiel bietet Nx zwei Möglichkeiten: Es besteht die Auswahl zwischen dem klassischen Angular-Setup mit Protractor für End-to-End Tests oder der Verwendung von Cypress, was bei einem neuen Nx Projekt automatisch aktiviert ist. Letzteres ist ein Testframework aus dem Jahre 2014, das direkt im Browser arbeitet. Die Entwicklung der Tests in Cypress ist intuitiv und gut dokumentiert.
 
Seit Nx 10 wird für Unit-Tests standardmäßig Jest verwendet – dieses Testframework unterscheidet sich vom Angular-Standard Karma/Jasmine insofern, als dass kein realer Browser für die Tests gestartet werden muss. Stattdessen wird jsdom verwendet, was lediglich eine Implementierung diverser Webstandards ist und dadurch eine schnellere Ausführung der Tests ermöglicht, da nur die benötigten Funktionalitäten eines Browsers emuliert werden.
 
Für die Code-Analyse bringt Nx statt TSLint außerdem ESLint mit. ESLint kann zur Überprüfung der Code-Qualität von TypeScript als auch JavaScript verwendet werden, während TSLint auf TypeScript beschränkt ist. Die Autoren von TSLint raten zur Migration zu ESLint, da das Projekt ab Dezember 2020 keine weiteren Pull Requests erlaubt und somit nicht mehr weiterentwickelt wird.

GitHub Repository
Das fertige Git Repository zu diesem Artikel sind im Conciso GitHub zum Download verfügbar.

Einrichtung der Entwicklungsumgebung für ein Monorepo und der leeren Anwendungen

Um die Möglichkeiten von Nx zu nutzen, wird das Kommandozeilentool benötigt. Dieses kann entweder mit npm install -g nx installiert und benutzt werden, oder aber mittels npm run nx -- ausgeführt werden. Für diesen Beitrag verwende ich der Einfachheit halber die globale Installation.
 
Anschließend wird mit dem Befehl npx create-nx-workspace@latest interaktiv eine Arbeitsumgebung erstellt. Nach der Vergabe eines Namens kann entweder ein leerer Workspace generiert werden, oder eines der vorhandenen Templates angewendet werden.
Für meinen Anwendungsfall habe ich einen Angular-Nest Workspace mit dem Namen „nx-demo-app“ erstellt:

$ npx create-nx-workspace@latest nx-demo-app
? What to create in the new workspace angular-nest
? Application Name                    demo-app
? Default stylesheet format           SASS(.scss)
? Default linter                      ESLint
? Use Nx Cloud?                       No
Code-Sprache: Bash (bash)

Nx generiert nun drei Anwendungen: Ein Nest.js Backend, ein Angular Frontend sowie ein Cypress End-to-End Projekt. Anschließend kann innerhalb des Projektordners weitergearbeitet werden (cd nx-demo-app).

Erstellung der mobilen Anwendungen

Um die Angular Anwendung in Apps für Android und iOS umzuwandeln, wird Capacitor verwendet. Dies wird mittels nx add @nxtend/capacitor dem Projekt hinzugefügt. Außerdem wird die Angular Anwendung einmal mittels nx build gebaut, damit dann mit Capacitor die gewünschten Plattformen hinzugefügt werden können.
 
Anschließend muss eine Capacitor-Anwendung erstellt werden, welche letztendlich sowohl die Android- als auch iOS-App beinhaltet:

$ nx generate @nxtend/capacitor:capacitor-project
? What app ID would you like to use?      io.ionic.starter
? What app name would you like to use?    demo-app
? What npm client would you like to use?  npm
Code-Sprache: Bash (bash)

Nun können die Plattformen hinzugefügt werden:
nx run demo-app:add:ios  für iOS
nx run demo-app:add:android  für Android
 
Für das Erstellen des XCode-Projektes ist Cocoapods vonnöten, welches mit dem Befehl sudo gem install cocoapods installiert werden kann. Weitere Informationen hierzu finden sich auf der offiziellen Seite.
 
Für plattformübergreifende UI-Komponenten aus dem Ionic Framework werden die Verknüpfungen für Angular hinzugefügt:  npm install --save @ionic/angular
Um die Komponenten in der Angular App verwenden zu können, muss das IonicModule in der Datei app.module.ts des Frontends als Modul importiert werden:

import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { IonicModule } from '@ionic/angular';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule, IonicModule.forRoot()],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
Code-Sprache: TypeScript (typescript)

Generierung von Komponenten

Um in den erstellten Projekten Komponenten zu generieren, wird ebenfalls die Nx CLI verwendet (Die hier gezeigten Befehle sind lediglich Beispiele):
 
Für ein Gateway in der Backend-Anwendung:
 
nx generate @nestjs/schematics:gateway --name=app --language=ts --path=api/src/app/app --sourceRoot=apps
 
Für eine Komponente im Angular-Frontend:
 
nx generate @schematics/angular:component --name=my-component --project=demo-app

Starten der Anwendungen im Monorepo

Zum Starten des Frontends wird der Befehl nx serve verwendet. Das Backend kann entweder mittels nx serve --project=api oder lokal in Docker gestartet werden (mehr zu Docker weiter unten.

Kommunikation zwischen Backend und Frontend

Der erste Durchstich zum Backend wird von Nx automatisch generiert. Wenn man die Frontend-Anwendung startet, ruft sie eine standardmäßig enthaltene Willkommensnachricht des Backends ab und zeigt diese an:

Nx Willkommen mit JSON-Objekt
Nx Willkommensnachricht mit JSON-Objekt

Die Nachricht wird aktuell als komplettes Objekt dargestellt, es ist aber auch einfach möglich, nur den String „Welcome to api!“ anzeigen zu lassen.
Hierfür müssen wir im HTML der AppComponent die letzte Zeile anpassen:

<div style="text-align: center;">
  <h1>Welcome to demo-app!</h1>
  <img
    width="450"
    src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-logo.png"
  />
</div>
<div>Message: {{ (hello$ | async)?.message }}</div>
Code-Sprache: HTML, XML (xml)

Die Anpassung bewirkt, dass nun nur noch das Feld „message“ des Objektes angezeigt wird. Um Fehler zu verhindern, wenn das Objekt noch nicht verfügbar ist dient das Fragezeichen. Dadurch wird nur etwas angezeigt, wenn das Objekt vorhanden ist, andernfalls aber auch kein Fehler geworfen. Nun sehen wir kein JSON-Object mehr, sondern nur noch die enthaltene Nachricht:

Nx Willkommen mit Nachricht
Nx Willkommensnachricht mit enthaltenem String

Geteilter Code zwischen Projekten

Wie bereits erwähnt ermöglicht Nx, Code zwischen den Projekten des Monorepo zu teilen. Dies wird hier anhand der environment-Datei veranschaulicht. Diese Datei wird verwendet, um umgebungsspezifische Variablen wie den Port der API festzulegen und zu importieren.
Zunächst legen wir ein neues Nx-Projekt an, welches für die Konfiguration zuständig ist. Hierfür erstellen wir den Ordner „config“ in libs/ und tragen diesen anschließend in die nx.json, angular.json und tsconfig.base.json ein. In der angular.json wird das Projekt unter projects eingetragen.
nx.json:

{
  "npmScope": "nx-demo-app",
  "affected": {
    "defaultBase": "master"
  },
  "implicitDependencies": {
    "angular.json": "*",
    "package.json": {
      "dependencies": "*",
      "devDependencies": "*"
    },
    "tsconfig.base.json": "*",
    "tslint.json": "*",
    ".eslintrc.json": "*",
    "nx.json": "*"
  },
  "tasksRunnerOptions": {
    "default": {
      "runner": "@nrwl/workspace/tasks-runners/default",
      "options": {
        "cacheableOperations": ["build", "lint", "test", "e2e"]
      }
    }
  },
  "projects": {
    "demo-app": {
      "tags": []
    },
    "demo-app-e2e": {
      "tags": [],
      "implicitDependencies": ["demo-app"]
    },
    "api": {
      "tags": []
    },
    "api-interfaces": {
      "tags": []
    },
    "config": {
      "tags": []
    }
  }
}
Code-Sprache: JSON / JSON mit Kommentaren (json)

angular.json:

{
  "version": 1,
  "projects": {
    ...
    "config": {
      "root": "libs/config",
      "sourceRoot": "libs/config/environments",
      "projectType": "library",
      "schematics": {},
      "architect": {
        "lint": {
          "builder": "@nrwl/linter:eslint",
          "options": {
            "lintFilePatterns": ["libs/config/**/*.ts"]
          }
        }
      }
    }
  },
  ...
Code-Sprache: JSON / JSON mit Kommentaren (json)

tsconfig.base.json:

{
  "compileOnSave": false,
  "compilerOptions": {
    "rootDir": ".",
    "sourceMap": true,
    "declaration": false,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "importHelpers": true,
    "target": "es2015",
    "module": "esnext",
    "typeRoots": ["node_modules/@types"],
    "lib": ["es2017", "dom"],
    "skipLibCheck": true,
    "skipDefaultLibCheck": true,
    "baseUrl": ".",
    "paths": {
      "@nx-demo-app/api-interfaces": ["libs/api-interfaces/src/index.ts"],
      "@nx-demo-app/environments/*": ["libs/config/environments/*"]
    }
  },
  "exclude": ["node_modules", "tmp"]
}
Code-Sprache: JSON / JSON mit Kommentaren (json)

Anschließend werden zwei Environment-Dateien in libs/config/environments erstellt:

environment.prod.ts:

export const environment = {
  production: true,
  api_url: 'http://beispiel.domain.de',
  api_port: '4444',
};
Code-Sprache: TypeScript (typescript)

environment.ts:

export const environment = {
  production: false,
  api_url: 'http://localhost',
  api_port: '4444',
};
Code-Sprache: TypeScript (typescript)

Anschließend werden die environment-Dateien der jeweiligen Projekte so konfiguriert, dass sie auf die Dateien in libs/config/environments zeigen.
Inhalt der environment.ts in apps/api/src/environments/ und apps/demo-app/src/environments/:

import { environment as _environment } from '@demo-app/core/environments/environment';

export const environment = _environment;
Code-Sprache: JavaScript (javascript)

Inhalt der environment.prod.ts in apps/api/src/environments/ und apps/demo-app/src/environments/:

import { environment as _environment } from '@demo-app/core/environments/environment.prod';

export const environment = _environment;
Code-Sprache: JavaScript (javascript)

Interessant hierbei ist, dass der Import-Pfad mit @demo-app beginnt. Dies ist ein Namespace, der von Nx angelegt wird, um das Teilen von Code zwischen den Projekten zu ermöglichen – @demo-app zeigt im Hauptverzeichnis auf den Ordner libs.
 
Anschließend können wir die Datei in der main.ts der API importieren und verwenden:

/**
 *<code>This is not a production server yet!
 * This is only a minimal backend to get started.
 */
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const globalPrefix = 'api';
  app.setGlobalPrefix(globalPrefix);
  const port = environment.api_port;
  await app.listen(port, () => {
    Logger.log(
      `Listening at ${environment.api_url}:${environment.api_port}/${globalPrefix}</code>` ); }); } bootstrap();
Code-Sprache: TypeScript (typescript)

Im Frontend klappt die Kommunikation zum Backend nun nicht mehr auf scheinbar magische Weise. Zuvor war dies möglich, weil Nx automatisch Proxy-Einstellungen vornimmt, um die Kommunikation einzelner Apps während der Entwicklung zu erleichtern.
 
Um den Durchstich wiederherzustellen, legen wir einen BackendService an, der die Nachricht des Backends abruft und anzeigt. Hierfür muss zunächst der Service generiert werden: nx generate @schematics/angular:service --name=backend --project=demo-app
 
Im Frontend wird die environment auf die gleiche Art im BackendService eingebaut:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

import { environment } from '../../../environments/environment';

@Injectable({ providedIn: 'root' })
export class AppService {
  constructor(private http: HttpClient) {}
  getWelcomeMessage(): Observable<{ message: string }> {
    return this.http.get<{ message: string }>(
      `${environment.api_url}:${environment.api_port}/api/hello`
    );
  }
}
Code-Sprache: TypeScript (typescript)

Anmerkung: Zur Zeit (Stand: 10.11.2020) gibt es ein Versionsproblem mit RxJS, was mittels  nx update rxjs behoben werden kann. Ansonsten schlagen die Imports fehl, da mehrere RxJS Versionen im Workspace aktiv sind.
 
Nun können wir den BackendService in der AppComponent einbauen und verwenden:

import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';

import { BackendService } from './backend.service';

@Component({
  selector: 'nx-demo-app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnInit {
  hello$: Observable<{ message: string }>;
  constructor(private backendService: BackendService) {}

  ngOnInit(): void {
    this.hello$ = this.backendService.getWelcomeMessage();
  }
}
Code-Sprache: TypeScript (typescript)

Jetzt sind wir fast fertig. Wie zuvor gesagt, richtet Nx automatisch Proxies ein, um die lokale Kommunikation zwischen den Apps zu ermöglichen. Da wir den Proxy nicht angepasst haben, wird nun ein Fehler angezeigt, da das Backend die Verbindung nicht zulässt. In einem Produktionssetup wären die Proxies allerdings ebenfalls nicht vorhanden, deswegen richten wir in der API Cross-Origin-Requests (CORS) ein, damit auch von einem anderen Ort auf die API zugegriffen werden kann:

/**
 * This is not a production server yet!
 * This is only a minimal backend to get started.
 */
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const globalPrefix = 'api';
  app.enableCors();
  app.setGlobalPrefix(globalPrefix);
  const port = environment.api_port;
  await app.listen(port, () => {
    Logger.log(
      `Listening at ${environment.api_url}:${environment.api_port}/${globalPrefix}`
    );
  });
}
bootstrap();
Code-Sprache: TypeScript (typescript)

Ausrollen der Anwendungen und Apps

Wenn die Anwendungen bereit fürs Release sind, können sie mit Hilfe von Nx individuell einzeln gebaut werden.

Zum Bauen eines einzelnen Projekts wird nx build --project=PROJEKTNAME verwendet:
nx build --project=api
nx build --project=demo-app
Das Backend der Anwendung lässt sich einfach als Docker Image ausliefern und kann auf jeder Maschine benutzt werden, auf der Docker läuft.
Hierzu wird ein Dockerfile mit folgendem Inhalt benötigt:

FROM node:12-alpine

ADD package.json /tmp/package.json
ADD package-lock.json /tmp/package-lock.json
RUN cd /tmp && npm install --production
RUN mkdir -p /app && cp -a /tmp/node_modules /app
COPY dist/apps/api/ /app

WORKDIR /app
USER node
EXPOSE 4444

CMD [ "node", "main.js" ]
Code-Sprache: PHP (php)

Anschließend kann nach dem erfolgreichen Build das Docker-Image erstellt und gestartet werden:
docker build -f docker/Dockerfile -t api .
docker run -p 4444:4444 api
Das Docker Image kann auch mittels docker-compose gestartet werden, hierzu wird folgende docker-compose.yml benötigt:

services:
  api:
    image: api
    container_name: api
    ports:
      - '4444:4444'
Code-Sprache: YAML (yaml)

Zum Starten kann nun einfach docker-compose -f docker/docker-compose.yml up verwendet werden.
 
Um die entsprechenden mobilen Apps zu generieren, wird Capacitor benutzt. Hierbei wird jeweils zunächst ein Projekt für Android Studio (Android) oder XCode (iOS) automatisch generiert, inklusive dem Download aller nötigen Abhängigkeiten. Anschließend wird die fertige App hineinkopiert. Die Projekte lassen sich dann in der entsprechenden Entwicklungsumgebung öffnen und können entweder auf einem Emulator getestet werden, oder direkt als fertige Apps ausgeliefert werden.
Die Generierung der Projekte haben wir bereits am Anfang erledigt, nun muss noch der neueste Stand des Projektes gebaut werden und kann dann zur App transformiert werden.
 
iOS App
Für die iOS-App werden folgende Schritte benötigt:
nx run demo-app:sync:ios -> Kopieren der zuvor gebauten Anwendung in das XCode-Projekt (Dieser Schritt ist optional und nur nötig, wenn nach dem Erstellen des XCode-Projekts die App nochmal verändert wurde)
nx run demo-app:open:ios -> Öffnen des erstellten Projektes in XCode
 
Android App
Die Schritte zur Erstellung der Android-App sind sehr ähnlich:
nx run demo-app:sync:android -> Kopieren der zuvor gebauten Anwendung in das Android Studio-Projekt (Dieser Schritt ist optional und nur nötig, wenn nach dem Erstellen des Android Studio-Projekts die App nochmal verändert wurde)
nx run demo-app:open:android -> Öffnen des erstellten Projektes in Android Studio

Solltest Du ein Monorepo verwenden?

Ein Monorepo ist ein hilfreiches Tool zur Organisation mehrerer Anwendungen in einem einzigen Repository, welches die Möglichkeit bietet, weniger duplizierten Code schreiben zu müssen. Gleichzeitig kann aber granular konfiguriert werden, welche Teile des Codes von welchen Anwendungen verwendet werden können. Desweiteren liefert Nx Plugins, mit denen sich verschiedenste Technologien beliebig kombinieren und einrichten lassen. Die Plugin-Entwicklung ist community-getrieben – wenn also ein Plugin für ein gewünschtes Framework fehlt, kann man dies theoretisch einfach selbst entwickeln.

Schreibe einen Kommentar

Das könnte Dich auch noch interessieren

Continuous Deployment einer Ionic-App mit Jenkins

Continuous Deployment einer Ionic-App mit Jenkins

In Zeiten der fortschreitenden Automatisierung wird auch im Bereich der Software-Entwicklung an der Optimierung der Prozesse gearbeitet. Keine moderne Softwareschmiede ...
Webinar: Keycloak mit SPIs erweitern

Webinar: Keycloak mit SPIs erweitern

Die Open-Source Identity- und Accessmanagement-Lösung Keycloak kann leicht erweitert werden. Dieses Webinar zeigt, wie man die Service Provider Interface (SPI) ...
Während seiner Mitarbeit am DB Navigator hat unser Kollegen Michael Didion wichtige Erfahrungen im Bereich der Migration mit JSCodeshift gesammelt. Um ebendiese wird es im Beitrag gehen.

JSCodeshift: Automatisierte Vue 3-Migration 

JSCodeshift ist ein nützliches Werkzeug für Entwickler:innen, um zeitaufwendige und fehleranfällige Aufgaben im Zusammenhang mit Codeänderungen zu automatisieren und zu ...