NestJS Tutorial: Angular-Architektur für das Node.js-Backend

Als Plattform für serverseitige Anwendungen hat sich Node.js in den letzten Jahren als solide Lösung für performante Backends etabliert (neben Alternativen wie PHP oder Ruby). Die im Frontend verbreitete Programmiersprache JavaScript kann so auch im Backend verwendet werden.

Node.js lässt sich über Pakete bzw. Packages individuell erweitern und so an die eigenen Anforderungen anpassen. Hierzu werden über den Package Manager npm Module mit spezieller Funktionalität bereitgestellt, die man für das eigene Projekt herunterladen kann. Im Jahr 2019 standen auf diesem Weg bereits über eine Million Packages zur Verfügung (Link).

Gerade für Einsteiger ist es kaum möglich, bei so einer Menge an Möglichkeiten den Überblick zu behalten. Selbst erfahrene Programmierer sind gefordert, etablierte Best-Practice-Lösungen zu finden, die für eine individuelle Aufgabe passen und dazu die Augen für neue Innovationen offen zu halten. Wenig überraschend wurden für diese Herausforderung im Lauf der Zeit verschiedene Frameworks entwickelt, welche dem Nutzer einen Grundstock an npm-Packages und einen dazugehörigen Workflow anbieten.

Node.js-Frameworks und ihre Grenzen

Express hat sich neben Alternativen wie FeatherJS, Koa oder Hapi als beliebtestes Framework herauskristallisiert, zumindest was die Downloadzahlen auf npm anbetrifft (ca. 10 Million wöchentliche Downloads beim Stand Anfang 2020). Zwei der elementarsten Aufgaben eines Backends (z.B. einer API) sind das Routing (Auflösen einer URL zu einer bestimmten Funktionalität) und das Verarbeiten von HTTP-Requests. Für beides stellt Express effiziente Lösungswege bereit.

Doch wie sieht es aus, wenn wir unser Backend mit einer externen Datenbank verbinden wollen? Wie können wir eine Nutzer-Authentifizierung lösen, eingegebene Daten validieren oder HTTP-Requests absenden? Für solche und ähnliche Herausforderungen muss der Entwickler auch mit einem Framework wie Express seine eigenen Lösungswege finden. Anders ausgedrückt: es fehlt an einer umfassenden Architektur.

Kamil Myśliwiec entwickelte mit NestJS eine Lösung für diese Herausforderung. Konzepte, die über Frameworks wie Angular im Frontend etabliert sind, werden über NestJS für das Backend nutzbar gemacht. Die Architektur von NestJS ist offensichtlich dem Aufbau von Angular nachempfunden. Frontend-Entwickler mit Angular-Erfahrung finden sich hier schnell zurecht und sind mitunter überrascht, wie elegant sich ein Backend auf diese Art erstellen lässt.

NestJS installieren

Wie eine Backend-Lösung mit NestJS konkret aussehen kann, wollen wir uns nun praktisch anschauen.

Mit einer installierten Node.js-Umgebung (Mindestversion 8.9.0) installieren wir NestJS global in unserem System:

npm i -g @nestjs/cli

Die Abkürzung i installiert ein Paket und der Schalter -g macht dieses global verfügbar. Der Befehl nest ist anschließend in der kompletten Umgebung verfügbar.

Anschließend erstellen wir ein neues Projekt:

nest new my-first-nest

Vor dem Erstellen müssen wir noch einen Package Manager wählen und entscheiden uns in diesem Fall für npm. Die Alternative wäre Yarn. Beide greifen jedoch auf dasselbe npm-Repository zu, so dass wir mit beiden die gleiche Auswahl an Zusatzmodulen erhalten.

Wechseln wir nun in den erstellten Ordner des Projekts und starten wir dieses:

npm run start
cd ./my-first-nest

Wird die Applikation erfolgreich gestartet, erhalten wir von NestJS die Bestätigung:

NestJS hat uns nun einen funktionierenden Server erstellt, der per Standard auf dem Port 3000 hört. Navigieren wir im Browser unserer Wahl also zu http://localhost:3000/ erhalten wir ein unspektakuläres Hello World:

Führen wir Änderungen an unserem Programm durch, müssten wir auf diese Art den Server jedesmal neu starten, um die Auswirkungen sehen zu können. Mit folgendem Befehl wird Node.js im Hintergrund über Nodemon gestartet. Der Server wird nun nach jeder Anpassung automatisch neu gestartet:

npm run start:dev

Alle vorbereiteten Start-Kommandos lassen sich der Datei package.json (unter scripts) entnehmen.

Ordnerstruktur

NestJS führt beim Installieren ein sogenanntes Scaffolding durch, deutsch Gerüstbau. Es wird eine Ordnerstruktur mit einem Grundstock an Code angefertigt, von welchem aus wir an die weitere Arbeit gehen können. Entwickler mit Kenntnissen in Angular werden beim Blick auf die angelegte Struktur ein vertrautes Bild vorfinden:

Im Ordner dist werden später die kompilierten Dateien für das finale Deployment bereitgestellt, die für eine Veröffentlichung außerhalb der lokalen Umgebung benötigt werden. In node_modules werden die nötigen Abhängigkeiten in Form von npm-Packages aufbewahrt (in der Datei package.json werden diese Packages detailliert mit Versionsnummern aufgelistet). In src spielt sich unser eigentlicher Quellcode ab, den wir die meiste Zeit über bearbeiten werden. test versorgt uns wenig überraschend mit Möglichkeiten zu automatischen Testen der Applikation.

Die Datei tsconfig.json weist uns auf eine entscheidende Abweichung zu gewöhnlichen Node.js-Projekten hin. Als Standard-Programmiersprache wird in NestJS TypeScript verwendet. Hierbei handelt es sich um sogenanntes Superset von JavaScript, also eine Obermenge, die letztlich in reguläres JavaScript transpiliert wird. JavaScript wird erweitert um objektorientierte Programmierelemente, insbesondere statische Typisierung. Variablen können nun bestimmten Typen zugewiesen werden, die fortan eingehalten werden müssen. Das erleichtert das Strukturieren einer Anwendung. Wer doch lieber in klassischem JavaScript programmieren möchte, kann NestJS in dieser Variante direkt über Git lokal klonen, anstatt den genannten Weg über die CLI zu nehmen:

git clone https://github.com/nestjs/typescript-starter.git nest-in-js-project
cd nest-in-js-project
npm install
npm run start

Grunddateien

Im Ordner src, welcher für unseren Code verantwortlich ist, wurden bereits Codedateien erstellt, deren Aufbau dem von Angular entlehnt wurde. app.controller.ts verwaltet den Controller auf dieser Ebene:

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

Im MVC-Muster (Model – View – Controller) verbindet der Controller die Rohdaten (Model) mit der Präsentation (View). Eingehende Anfragen (Requests) werden bearbeitet und Antworten (Responses) an den Client zurückgeben. In diesem Fall wurde eine Route für einen GET-Request angelegt, welche die Funktion getHello() aufruft. In dieser wird aus dem Service AppService die dortige Funktion getHello() aufgerufen und deren Rückgabewert per return zurückgegeben. Später schauen wir uns das Routing in NestJS noch genauer an.

Im Service AppService (app.service.ts) wird die eben genannte Funktion getHello() definiert:

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

Schon im App-Controller haben wir in der jeweils ersten Zeile der Codebeispiele einen Decorator gefunden: @Controller() und @Injectable(). Mit einem Decorator kann in diesem Fall einer Klasse eine besondere Funktionalität zugewiesen werden, die von NestJS entsprechend verarbeitet wird. Dieses Konzept ist in Angular weit verbreitet und über diesen Weg auch im Backend angekommen. @Controller() definiert die Klasse AppController als Controller. @Injectable() ist Teil der Dependency Injection, einem Entwurfsmuster aus der Objektorientierten Programmierung, das wir uns in Kürze noch genauer anschauen.

Das Modul app.module.ts definiert das Modul dieser Ebene:

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Wie aus Angular bekannt (ich wiederhole mich…) werden hier Abhängigkeiten definiert: welche externen Module werden benötigt (imports)? Über welche Controller verfügt dieses Modul (controllers)? Welche Services möchten wir injizieren (providers)? Sollen von diesen Services bestimmte exportiert werden, wenn dieses Modul importiert wird (exports)?

Die Datei main.ts ist die Einstiegsdatei, von welcher aus alle weiteren Komponenten geladen werden:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

Es wird in der Grundvariante der Port festgelegt, auf welchem fortan gehört werden soll. Im Hintergrund wird dies über das Framework Express durchgeführt. Per Standard ist der Port auf 3000 festgelegt und lässt sich hier problemlos auf einen anderen ändern, falls Bedarf besteht.

Die eigentliche API von Express wird von NestJS verdeckt, so dass die Zugriffsmethoden von NestJS verwendet werden. Möchte man konkret Zugriff auf die API von Express erhalten, muss man dies in der main.ts entsprechend erwähnen:

const app = await NestFactory.create<NestExpressApplication>(AppModule);

Für Entwickler, die lieber das Framework Fastify statt Express verwenden, kann dessen API ebenfalls sichtbar gemacht werden:

const app = await NestFactory.create<NestFastifyApplication>(AppModule);

Dependency Injection

Es gehört zum guten Programmierstil, Funktionen und Programmatik möglichst auszulagern und zu kapseln. Über definierte Schnittstellen werden diese Funktionen dann aufgerufen, so dass anschließend z. B. ein Rückgabewert zurückgegeben wird. So lassen sich Dateien verschlanken und Funktionalität bündeln, was letztlich zu mehr Übersicht und Wartbarkeit führt. Dependency Injection greift diese Idee auf. Ein DI-Container verwaltet die ausgelagerten Funktionen als Services. Klassen können diese Services anschließend einfach injecten, wenn sie diese benötigen.

Ein Service kann global angelegt werden, wenn man diesen auf der untersten Stufe (Root) anlegt und registriert. In unserem Fall befinden wir uns auf dieser Ebene. Der Decorator @Injectable() zeigt an, dass diese Klasse fortan von anderen Klassen genutzt, also injected, werden kann. Damit ist der Service im gesamten Programm verfügbar. Legt man einen Service auf höherer Eben an, also in einer bestimmten Component, wird dieser erst ab der dortigen Hierarchiestufe sichtbar und aufrufbar sein. Bei Bedarf kann auf diesem Weg sogar ein tiefer definierter Service überschrieben werden.

API Routen

Erweitern wir nun das von NestJS vorgegebene Grundgerüst und erstellen wir uns unsere eigene API.

Eine grundlegende GET-Route haben wir im Beispiel weiter oben kennengelernt. Übergeben wir nun beim Aufruf per URL als Parameter eine Zahl, verdoppeln wir diese und geben wir das Ergebnis zurück:

@Get(calculate/:number')
calculate(@Param() params): string {
  return (params.number + ' + ' + params.number + ' = ' + params.number * 2);
}

Rufen wir nun über das Tool Postman die Route http://localhost/calculate/3 auf, erhalten wir folgendes Ergebnis zurück:

NestJS hat in diesem kleinen Beispiel eine Subroute /calculate/ angelegt. Über den Doppelpunkt kann hier der Parameter number übergeben werden, welcher anschließend in der Funktion calculate() verfügbar ist. Beim Zurückgeben an den Client wurde automatische der HTTP-Status 200 (ok) angefügt.

Für GET-Requests wird automatisch der Statuscode 200 zurückgegeben, für POST-Requests Code 201 (created). Möchte man einen individuellen Statuscode setzen, kann man dies leicht über den Decorator @HttpCode() erledigen. Wichtig beim Einsetzen noch nicht vorhandener Decorators ist der Import der Funktionalität (meist von @nestjs/common). Viele IDEs erkennen dies automatisch und zeigen einen entsprechenden Vorschlag an:

import { HttpCode } from '@nestjs/common';
...
@Get(calculate/:number')
@HttpCode(201)
...

Wie erwähnt verwendet NestJS auch zum Zurückgeben von Responses das Framework Express. Möchte man mehr Flexibilität haben und das Response-Objekt von Express direkt adressieren, ist dies auch möglich:

calculate(@Res() response): string { ... }

Das Response-Objekt muss man nun selbst erstellen und übergeben:

response.status(200).send();

Beide Ansätze lassen sich pro Route nicht mischen. Wird der Decorator @Res() eingesetzt, muss also für diesen Response der Express-Ansatz ausgeführt werden.

Es lassen sich über NestJS die wichtigsten Properties des Request-Objekts von Express verarbeiten (z.B. Query, Headers, IP u. ä.). Übergeben wir über den Body des Requests die gewünschte Operation, die mit den Zahlen durchgeführt werden soll:

Anschließend können wir den Body leicht über den Decorator @Body() auslesen:

calculate(@Param() params, @Body('operation') operation): string { ... }

Die Operation double lässt sich nun über den Schlüssel operation einsehen und ausgeben:

@Get('calculate/:number')
calculate(@Param() params, @Body('operation') operation): string {
  const result = params.number + ' + ' + params.number + ' = ' + params.number * 2;
  return (
    'Operation: ' + operation + ', Rechnung: ' + result
  );
}

Über Decorators werden nach gleichem Prinzip Requests von anderen HTTP-Methoden verarbeitet: @Put(), @Delete(), @Patch(), @Options(), @Head() und @All().

Variable Platzhalter bzw. Wildcards lassen sich über das Sternchen * einbauen. Für diese kann dann beim Aufruf ein beliebiges alphanummerisches Zeichen verwendet werden:

@Get('calculate/:n*mber')

Response-Header werden über den Decorator @Header() eingefügt:

@Get('calculate/:number')
@Header('Refresh', '3')

Eigene Struktur per CLI

Zum Strukturieren einer Anwendung empfiehlt es sich stets, Funktionalität in eigene Controller und Module auszulagern. Zum Erzeugen neuer Componenten, Services etc. steht uns die eigene CLI von NestJS zur Verfügung. Alle verfügbaren Befehle lassen sich über folgendes Kommando auflisten:

nest --help

Hilfestellung zu einem bestimmten Befehl erhält man ebenfalls über den Schalter help:

nest generate --help

Für unser Beispiel wollen wir Kalkulationen in einen eigenen Service auslagern. Die aus Angular bekannten Abkürzungen der CLI (generate => g, service => s, component => c etc.) greifen auch hier, so dass wir folgenden Befehl wählen:

nest g s calculation

In einem eigenen Unterordner finden wir fortan unseren neuen CalculationService. Das Registrieren im Root in der app.module.ts hat NestJS dankenswerterweise für uns übernommen, so dass der Service direkt einsetzbar ist.

In den neuen Service lagern wir nun unsere Berechnung aus. Konkret definieren wir eine Verdopplung und Quadratur des Eingabewertes:

@Injectable()
export class CalculationService {
  double(n: number): number {
    return (n * 2);
  }

  square(n: number): number {
    return (n * n);
  }
}

Die Route im Controller schreiben wir entsprechend um, so dass fortan der Service zur Berechnung verwendet wird:

@Get('calculate/:number')
calculate(@Param() params, @Body('operation') operation): string {
  let result;
  switch (operation) {
    case 'double':
      result = this.calcService.double(params.number);
      break;
    case 'square':
      result = this.calcService.square(params.number);
      break;
  }

  return (
    'Operation: ' + operation + ', Rechnung: ' + result
  );
}

Die Anfragen und Resultate in Postman bleiben identisch und wir konnten unser Beispiel erfolgreich weiter strukturieren.

Fazit

Dieses kleine Programm sollte verdeutlichen, dass NestJS das Erstellen einer API deutlich erleichtern kann. Express bietet für elementare Aufgaben wir Routing und Request-Handling bereits Hilfestellungen. NestJS bereitet gängige Best-Practice-Lösungen für Node.js in einer ansprechenden und schnell erlernbaren Form auf.

Weitere etablierte Lösungen werden von NestJS von Haus aus mitgeliefert und Angular-like integriert. Für NoSQL-Datenbanken wird auf mongoose gesetzt, Authentication wird über Passport realisiert, ausgehende HTTP-Requests laufen intern über Axios. Wem die mitgelieferte Implementierung nicht zusagt, der kann problemlos die originalen Packages über npm importieren und über diesen Weg verwenden.

Wer partout nicht mit Angular und der davon vorgegebenen Arbeitsweise warm wird, der könnte auch mit NestJS Anpassungsschwierigkeiten haben. Wünschenswert für die Zukunft wären weitere Implementierung, die sich an alternativen Frameworks wie Vue.js oder React anlehnen. So könnte jeder die für die eigene Arbeitsweise passende Variante wählen.

Für den jetzigen Stand bietet NestJS jedoch auch in der vorliegenden Form eine praxistaugliche Architektur für Node.js-Anwendungen, die nicht umsonst stetig an Beliebtheit zunimmt.

Autor: Isaak Tappert / Tappert IT

Bildquelle (Katze): https://unsplash.com/@pactovisual

2 comments

Comments are closed.