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 iOSnx 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:
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:
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.