|

NestJS – Besser und effizienter mit Node.js Arbeiten

Wer mit Node.js anfangen möchte, wird eventuell am Anfang etwas skeptisch sein bzgl. der Architektur. Und auch wer bereits mit Node.js arbeitet fragt sich vielleicht zunächst: "Wie soll ich mein Projekt strukturieren?", "Wie sieht ein guter Aufbau aus?" oder "Wie kann ich effizienter Services erstellen?". Auf diese Fragen habe ich eine passende Antwort: NestJS.

Kurzer Überblick über NestJS

  • Framework
  • Angular Architektur
  • vereinfacht Node.js und das Express-Framework
  • TypeScript Unterstützung
  • gut für REST-APIs
  • gut für GraphQL-APIs
  • gut für Umsetzung mit Websockets (mehr dazu in einem späteren Blog-Beitrag)
  • gute Dokumentation
  • Swagger implementiert
  • wird nicht nur von mir gemocht: Tweet – Backend-Frameworks mit den meisten "Sternen" auf GitHub[1]

In diesem Artikel wird vor allem die Entwicklung einer REST-API mit NestJS betrachtet. Wer mit NestJS einsteigen bzw. die hier angerissenen Themen vertiefen möchte, dem lege ich die Dokumentation von NestJS nahe.

Hintergrund

NestJS ist ein Framework welches eine bzw. eigentlich sogar zwei Abstraktionsebenen auf Node.js und das bekannte Express-Framework aufsetzt. Somit steckt hinter NestJS zwar keine Magie, aber dennoch ermöglicht die vorgegebene Architektur und Fassade eine bessere Strukturierung des Projektes und einen effizienteren Workflow. Wie NestJS selbst sagt: "Die Architektur ist stark von Angular inspiriert". Dieses Gefühl bekommt man auch, wenn man mit NestJS arbeitet. Angular-Entwickler machen sich schnell mit NestJS vertraut und wer NestJS einigermaßen kennt, wird auch schneller in Angular reinkommen.

Basiswissen und Ordnerstruktur

In NestJS gibt es Modules ähnlich wie Namespaces in der .NET-Welt oder Packages in der Java-Welt. Innerhalb eines Moduls befinden sich vor allem Klassen, die zusammengehören. Eine Funktion eines Controllers repräsentiert eine Route und kümmert sich um die Weitergabe in einen Provider (meistens "Service"). Ein "Service" beinhaltet dann die eigentliche Logik und gibt ggf. einen Wert zurück an den Controller. Ein klassisches Interface oder eine "normale" Klasse wird als Modell verwendet. Um die Geschäftslogik noch besser zu trennen gibt es dazu noch Guards, Middlewaress und Pipes. Diese drei sorgen dafür, dass während einer Anfrage etwas validiert oder überprüft wird. Erst danach wird die eigentliche Route erreicht. Ein Guard "beschützt" bestimmte Routen. D.h. er überprüft die Anfrage auf Berechtigung. Ein bekanntes und (wahrscheinlich) meist verwendetes Beispiel wäre ein Authentifizierungs-Guard. Middlewares können zwar auch eine Authentifizierung durchführen (Standard beim Express-Framework), können aber noch Werte setzen oder ähnliches. Pipes sorgen dafür, dass Parameters oder Query-Komponenten validiert bzw. transformiert werden.

Durch diese Klassen ergibt sich folgende Ordnerstruktur (Beispiel eines Kartenspiels von mir):

  • src/
    • main.ts
    • app.module.ts
    • v1/
      • database/
        • database.module.ts
        • database.service.ts
      • cards/
        • dto/
          • ...
        • card.controller.ts
        • card.module.ts
        • card.service.ts
      • game/
        • dto/
          • ...
        • deck.service.ts
        • end-turn.service.ts
        • game.controller.ts
        • game.module.ts
        • game.service.ts
      • middlewares/
        • player-in-room.middleware.ts
        • set-user-agent.middleware.ts
        • ...
      • guards/
        • auth.guard.ts
      • ...

Die Ordnerstruktur wird auch mithilfe der Verwendung des CLI von NestJS eingehalten/vorgegeben.

Programmierung

Mit dem Grundwissen aus Basiswissen (Guards, Middlewares und Pipes sind sogar optional) kann bereits mit der Programmierung begonnen werden. Die meisten Sachen werden sowieso erst in der Praxis verstanden und festigen sich dort ;)

Wir betrachten hier aber vor allem Controllers. Providers und Modules sind relativ uninteressant. Ein Modul beinhaltet lediglich Imports und wendet Middlewares auf Routen an. Ein Provider ist eine "normale" Klasse, allerdings besitzt dieser einen Decorator.

Ein wichtiger Bestandteil von NestJS sind die Decorators. Ob es sich um ein Modul, Controller oder Provider handelt wird nämlich durch diese bestimmt.

@Controller('ue') // 'ue' als Route-Prefix (optional)
export class UEController {
  constructor(private readonly ueService: UEService) {}

  @Get('hello') // erzeugt eine GET-Route. Ansprechbar über GET 'ue/hello'
  		// 'ue' als Zusatz durch den Prefix oben
  async sayHello(): Promise<string> {
    return await this.ueService.giveMeAHello();
  }
}

Durch den Controller-Decorator wird die Klasse als Controller erkannt. Jede Funktion kann weitere Decorators besitzen, damit diese als Route erkannt wird. So existiert etwa die GET-Route "ue/hello", welche über den Browser erreichbar ist (sofern UEController in dem App-Modul importiert ist). Die komplette Geschäftslogik kann nun in TypeScript bzw. JavaScript in dem Service implementiert werden.

Nun gibt es nicht nur einen GET-Decorator, sondern selbstverständlich auch POST, PUT und DELETE. Allerdings wird es nun schwieriger, diese im Browser zu testen. Aus diesem Grund:

Swagger, Swagger, Swagger

NestJS macht es sehr einfach, eine Swagger-Dokumentation nebenbei aufzubauen. Dafür braucht es lediglich eine kleine Anpassung der main.ts-Datei - der Einstiegspunkt von NestJS.

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Swagger
  const options = new DocumentBuilder()
    .setTitle('UE API')
    .setDescription('...')
    .setVersion('1.0')
    .addTag('v1')
    .setContact('UEBERBIT GmbH', 'https://www.ueberbit.de/', 'contact [at] ueberbit.de')
    .addBearerAuth(); // optional falls irgendeine Route Bearer-Authentifizierung besitzt
  
  const doc = options.build();

  const document = SwaggerModule.createDocument(app, doc);
  SwaggerModule.setup('/api/v1/', app, document);

  await app.listen(PORT);

  console.log(`Listening on port ${PORT}`);
}
bootstrap();

Das allein genügt bereits für folgende Ausgabe
Standard Ausgabe in Swagger ohne Decorators

Mit der Verwendung weiterer Decorators ist es möglich, Swagger noch informativer zu machen.

@Controller('ue') // 'ue' als Route-Prefix (optional)
export class UEController {
  constructor(private readonly ueService: UEService) {}

  @Get('hello') // erzeugt eine GET-Route. Ansprechbar über GET 'ue/hello'
                // 'ue' als Zusatz durch den Prefix oben
  @ApiOperation({
    summary: 'Operation um eine Zeichenkette zu bekommen',
    description:
      'Diese Operation ermöglicht es, eine Zeichenkette zu bekommen mit sinnvollen Inhalt.',
  })
  @ApiResponse({
    status: 201,
    description: 'Gibt ein "Hello UEBERBIT" zurück bei Erfolg.',
  })
  @ApiInternalServerErrorResponse({
    description:
      'Dieser Fehler wird geworfen, wenn der Service durch Null teilt.',
  })
  async sayHello(): Promise<string> {
    return this.ueService.giveMeAHello();
  }
}

Swagger Ausgabe mit Decorators

Swagger Schemas

Swagger bietet auch das Anzeigen von Schemata an. Durch eine kleine Ergänzung der nest-cli.json-Datei werden diese von NestJS für Swagger automatisch gefunden und geniert.

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "plugins": [
      {
        "name": "@nestjs/swagger/plugin",
        "options": {
          "dtoFileNameSuffix": [".dto.ts", ".response.ts", ".model.ts"]
        }
      }
    ]
  }
}

Swagger Schemata Liste

Und wenn alle Schemata ordentlich dokumentiert sind (mit Decorators wie bei den Routen), dann zeigt Swagger dies in etwa so an:

Swagger Route Dokumentation mit Scheme

Pipes

Zu guter Letzt wende ich mich noch kurz den nützlichen Pipes zu. Wie bereits gesagt validieren und transformieren Pipes Parameters und/oder Query-Komponenten.

Ein anschauliches Beispiel ist folgendes Szenario:
Eine Route nimmt eine MongoDB-ObjectID entgegen und soll den Datensatz aus der Datenbank zurückgeben. Verwendet wird Mongoose. Problem: Mongoose wirft einen Fehler, wenn das Format (zu lang, zu kurz, etc.) für die ObjectID nicht passt.

Nun stellt sich die Frage: Wo soll die Überprüfung stattfinden? Innerhalb des Service ist es schlecht, weil die Logik dort nichts zu suchen hat. Soll eine Funktion geschrieben werden, damit diese mehrfach benutzt werden kann, falls weitere Routen dazu kommen? Wohin damit? Utility-Klasse in ein “shared-module”?

Die Ausgangsposition ohne Überprüfung sieht eventuell wie folgt aus:

@Controller('ue')
export class UEController {
  // ...

  @Get('node')
  async getDBNode(@Query('id') objectID: string): Promise<{}> {
    return this.ueService.getNode(objectID);
  }
}

Nun erwarten wir, dass die Query-Komponente "id" eine Zeichenkette ist. Schlussfolgerung ist, dass getNode nun auch eine Zeichenkette erwartet. Dies macht jedoch wenig Sinn, weil man hier als Entwickler eine ObjectID erwartet. Mit der Verwendung einer Pipe kann die Zeichenkette auf das Format einer ObjectID überprüft und transformiert werden. Im Controller selbst entsteht noch nicht einmal eine weitere Zeile.

@Controller('ue')
export class UEController {
  // ...

  @Get('node')
  async getDBNode(
    @Query('id', ParseObjectIdPipe) objectID: Types.ObjectId,
  ): Promise<{}> {
    return this.ueService.getNode(objectID);
  }
}

Die Pipe ParseObjectIdPipe wird in einer extra Datei ausgelagert.

@Injectable()
export default class ParseObjectIdPipe implements PipeTransform {
  transform(value: any) {
    try {
      return new Types.ObjectId(value);
    } catch (e) {
      throw new BadRequestException('Validation of the id failed');
    }
  }
}

Schlusswort

Schon mit diesem Wissen lässt sich zumindest eine einfache REST-API aufbauen. NestJS ermöglicht dies nicht nur durch Abstraktion, sondern auch durch die einfache und gut nachvollziehbare Architektur. Zugleich wird eine Swagger-Dokumentation erzeugt, womit das Testen des Backends vereinfacht wird und die zukünftigen (Frontend-)Entwicklern als eine Anlaufstelle für die Schnittstelle dient.

Quellen

[1] Twitter - Marko ⚡ Denic