Typensicher auf dem Server: Web-APIs mit Node.js und TypeScript

Leichtgewichtig und performant: Web-APIs mit Node.js und TypeScript
Keine Kommentare

JavaScript bzw. TypeScript werden mehr und mehr für die Cliententwicklung eingesetzt. Egal ob einfache interaktive Websites oder große Cross-Plattform-Anwendungen, die im Web, auf mobilen Geräten und auf dem Desktop lauffähig sind – überall kann JavaScript bzw. TypeScript eingesetzt werden. Mit Node.js besteht die Möglichkeit, die leichtgewichtige Sprache auch auf dem Server einzusetzen. Vom Command-Line-Interface über regelmäßige Jobs bis hin zu Web-APIs lässt sich alles mit Node.js entwickeln. Wie das geht, erläutert dieser Artikel.

JavaScript war lange eine dem Client bzw. dem Browser vorbehaltene Sprache und wird genutzt, um Dynamik auf dem Client zu ermöglichen. Auf dem Server waren wir Entwickler gezwungen, eine andere Sprache zum Bereitstellen eines Web-API zu nutzen. Das heißt auch, dass Logik, die auf dem Client und auf dem Server benötigt wird, dupliziert werden musste. Seit dem Release von Node.js im Jahre 2009 waren wir erstmals in der Lage, JavaScript auch auf dem Server zu nutzen – und somit auch Code zwischen Client und Server zu teilen (Universal JavaScript oder Isomorphic JavaScript). Das Wissen um JavaScript, das wir uns bei der Cliententwicklung angeeignet haben, können wir also auch auf dem Server einsetzen.

Doch was ist die Motivation zur Nutzung von TypeScript? Während die ersten Anwendungen mit JavaScript noch recht klein und überschaubar waren, sind heutige Anwendungen groß und können unter anderem viel Geschäftslogik beinhalten. Viel Disziplin und klare Strukturen sind nötig, um eine große Codebase in JavaScript zu verwalten. Auch unsere IDEs haben mit großen JavaScript-Projekten nach wie vor Probleme, eine passende IntelliSense anzubieten. Die Vorschläge sind zwar bereits sehr gut, da die IDEs im Hintergrund das JavaScript parsen und versuchen zu verstehen, aber die Vorschläge sind oftmals ein reines Best Guess.

Genau hier kann TypeScript eingesetzt werden und uns beim täglichen Entwickeln unterstützen. TypeScript ist ein Superset von JavaScript, das bedeutet, dass jede gültige JavaScript-Datei auch eine gültige TypeScript-Datei ist. Damit kann jedes bestehende Projekt durch den TypeScript Compiler (TSC) verarbeitet werden, ohne dass es zu einem Fehler kommt. Wie der Name von TypeScript schon andeutet, können wir damit JavaScript um Typen anreichern und erlangen damit Typsicherheit in einer sonst sehr dynamischen Sprache. Durch das Superset können wir auch nur dort Typen einsetzen, wo wir sie benötigen, oder bewusst dort weglassen, wo wir die maximale Dynamik von JavaScript einsetzen wollen.

Wenn wir Typen in unserer Anwendung einsetzen, erleichtern wir unserer IDE die statische Codeanalyse. Im Gegenzug erhalten wir eine sehr gute IntelliSense und können weiteres Tooling einsetzen, das auf statischer Codeanalyse aufbaut, bspw. Refactoring oder Linting.

Zusätzlich erhalten wir mit TypeScript die Möglichkeit, Features von JavaScript zu nutzen, die erst in kommenden Sprachversionen unterstützt werden. Durch den TypeScript-Compiler wird im Kompilierungsprozess der TypeScript-Code so umgeschrieben, dass er mit den aktuellen Sprachversionen von JavaScript funktioniert. Als Beispiele wären hier Decorators, Interfaces, Enums, Access Modifiers oder Generics zu nennen. Alles aus anderen Programmiersprachen bekannte Features, die so (noch) nicht in JavaScript existieren. Mit TypeScript können wir diese Features – und noch einige weitere – bereits nutzen, um unseren Code noch besser zu strukturieren.

Wichtig zu verstehen ist allerdings, dass TypeScript weder vom Browser noch von Node.js direkt interpretiert wird, sondern immer dessen JavaScript-Abbildung. Das bedeutet: auch wenn wir ein Feld oder eine Methode als private deklarieren, haben wir von außen Zugriff darauf, da Access Modifier in JavaScript selbst nicht existieren. Auch sind im JavaScript keine Informationen auf die TypeScript-Typen mehr vorhanden. Es handelt sich bei TypeScript also generell um reine Metainformationen für den Compiler.

Übrigens: TypeScript wird von Microsoft entwickelt und von Anders Hejlsberg designt, der auch schon die Sprachen C# oder Turbo Pascal/Delphi maßgeblich mitentwickelt hat.

Der Weg zum eigenen Web-API

Im Rahmen dieses Artikels wollen wir gemeinsam ein kleines Web-API entwickeln, das folgende Endpunkte bereitstellen soll:

  • GET /customers liefert eine Liste von einfachen Kundenmodellen zurück
  • GET /customer/:id liefert einen Kunden, basierend auf seiner ID, zurück
  • POST /customer erstellt einen neuen Kunden
  • PUT /customer/:id ändert einen Kunden, basierend auf seiner ID
  • DELETE /customer/:id löscht einen Kunden, basierend auf seiner ID
  • GET /customer/:id/bills liefert eine Liste an Rechnungen in Bezug auf die ID des Kunden zurück
  • GET /customer/:id/bill/:bid liefert eine Rechnung in Bezug auf deren ID eines Kunden zurück
  • POST /customer/:id/bill erstellt eine neue Rechnung für einen bestimmten Kunden
  • DELETE /customer/:id/bill/:bid löscht eine Rechnung, basierend auf dessen ID aus einem bestimmten Kunden

Die Daten sollen in einer Datenbank gespeichert werden. Im Rahmen dieses Artikels werden wir hierfür PostgreSQL benutzen. Die Benutzung wird mithilfe eines Object Relational Mappers (ORM) umgesetzt, daher können auch andere relationale Datenbanken, wie MySQL oder MS SQL Server, eingesetzt werden. Das fertige Beispielprojekt kann auf GitHub angesehen und ausprobiert werden.

Bevor wir starten können, muss Node.js installiert sein. Zum Zeitpunkt der Drucklegung ist die aktuelle Version Node.js v8.2.1. Auf Basis dieser Version wird das Beispiel entwickelt. Zudem lohnt sich die Installation von Postman, einem Tool zum Erstellen von HTTP-Anfragen, mit dem wir unser Web-API manuell testen können. Vorbereitend erstellen wir einen neuen Ordner nodejs-typescript, in dem wir unser Web-API entwickeln werden. In diesem Ordner führen wir folgenden Befehl auf der Kommandozeile aus: npm init –y. Damit erstellen wir eine package.json-Datei mit ihren Standardeinträgen.

Um mit TypeScript arbeiten zu können, müssen weitere Abhängigkeiten installiert werden: npm i typescript ts-node nodemon rimraf.

  • typescript beinhaltet den TypeScript-Compiler und wird unsere Sourcen später in JavaScript übersetzen.
  • ts-node: Dieses Paket dient zum direkten Ausführen von TypeScript unter Node.js; prinzipiell übersetzt das Paket auf Basis des zuvor installierten TSC die Anwendung nach JavaScript, bevor sie durch Node.js ausgeführt wird.
  • nodemon: Da wir während der Entwicklung den Server nicht händisch bei einer Änderung neustarten wollen, überwacht nodemon das Dateisystem auf Änderungen und wird bei jeder Änderung unseren Server neu starten.
  • rimraf: Ein kleines Paket zum Ausführen des Befehls rm -rf auf allen von Node.js unterstützten Plattformen

In einem Editor unserer Wahl öffnen wir die Datei package.json und überschreiben den Eintrag scripts:

"scripts": {
  "build": "rimraf build && tsc",
  "start": "nodemon --exec ts-node index.ts"
}

Das Skript build löscht zuerst mithilfe von rimraf den Ordner build. Danach wird der Server mithilfe des TSCs in JavaScript übersetzt und im Ordner build abgelegt, was wir gleich in einer weiteren Konfigurationsdatei definieren werden.

Das Skript start werden wir zur Entwicklungszeit starten. Wie in der Aufzählung zuvor erläutert, startet es nodemon zur Überwachung des Dateisystems auf Änderungen. Erkennt es eine Änderung, wird der —exec-Befehl aufgerufen, unsere Anwendung neu kompiliert und gestartet.

Als abschließende Vorbereitung legen wir die Datei tsconfig.json an. Der Inhalt der Datei ist Listing 1 zu entnehmen.

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es2017",
    "sourceMap": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "outDir": "build"
  },
  "exclude": [
    "node_modules"
  ]
}

In dieser Datei finden wir folgende Einstellungen für den Compiler:

  • module bestimmt, in welches Modulsystem der Code kompiliert werden soll. Node.js nutzt das CommonJS-Format, daher wandeln wir den TypeScript-Code in das CommonJS-Format um.
  • target bestimmt, in welches JavaScript-Versionslevel übersetzt werden soll. Da Version 8.2.1 bereits alle Features von ES2017 unterstützt, die wir für unsere Anwendung benötigen, nutzen wir dies als Ziel.
  • sourceMap gibt an, ob Source Maps generiert werden sollen. Source Maps werden beim Debugging in der IDE benutzt, um den TypeScript- statt JavaScript-Code zu debuggen.
  • experimentalDecorators erlaubt das Benutzen von Decorators.
  • emitDecoratorMetadata generiert Metadaten für die Decorators, wenn sie via JavaScript später ausgelesen werden sollen.
  • outDir gibt an, wohin der kompilierte JavaScript-Code abgelegt werden soll.

Das erste Lebenszeichen – der HTTP Server

Für unseren Server werden wir Restify einsetzen, ein schlankes, Middleware-basiertes Framework (Abb. 1) zur Erstellung von HTTP-basierten Servern. Zur Installation wird folgender Befehl ausgeführt: npm i restify @types/restify.

Restify ist ein in JavaScript entwickeltes Framework. Daher werden noch zusätzlich die Typdefinitionen via @types/restify installiert, sodass der TSC eine statische Codeanalyse von Restify durchführen kann.

Abb. 1: Middlewares in Restify

Abb. 1: Middlewares in Restify

Als Nächstes legen wir die Datei src/server/httpServer.ts an:

import {RequestHandler} from 'restify';

export interface HttpServer {
  get(url: string, requestHandler: RequestHandler): void;
  post(url: string, requestHandler: RequestHandler): void;
  del(url: string, requestHandler: RequestHandler): void;
  put(url: string, requestHandler: RequestHandler): void;
}

Das Interface HttpServer definiert, welche Methoden auf unserem Server existieren müssen, sodass unsere Controller ihre Routen hinzufügen können. Um das Interface zu benutzen, legen wir die Datei src/server/index.ts mit dem Inhalt aus Listing 2 an.

import {HttpServer} from './httpServer';
import {RequestHandler, Server as RestifyServer} from 'restify';
import * as restify from 'restify';

export class Server implements HttpServer {
  private restify: RestifyServer;

  public get(url: string, requestHandler: RequestHandler): void {
    this.addRoute('get', url, requestHandler);
  }

  public post(url: string, requestHandler: RequestHandler): void {
    this.addRoute('post', url, requestHandler);
  }

  public del(url: string, requestHandler: RequestHandler): void {
    this.addRoute('del', url, requestHandler);
  }

  public put(url: string, requestHandler: RequestHandler): void {
    this.addRoute('put', url, requestHandler);
  }

  private addRoute(method: 'get' | 'post' | 'put' | 'del', url: string, requestHandler: RequestHandler): void {
    this.restify[method](url, requestHandler);
    console.log(`Added route ${method.toUpperCase()} ${url}`);
  }

  public start(port: number): void {
    this.restify = restify.createServer();
    this.restify.use(restify.plugins.queryParser());
    this.restify.use(restify.plugins.bodyParser());

    this.addControllers();

    this.restify.listen(port, () => console.log(`Server is up & running on port ${port}`));
  }

  private addControllers(): void { }
}

Die Klasse Server implementiert unser Interface HttpServer und implementiert die Methoden durch einen Aufruf der privaten Methode addRoute() aus. Auf dem durch Restify bereitgestellten Server existiert für jedes HTTP-Verb eine gleichnamige Methode, die als ersten Parameter einen URL entgegennimmt und als weitere Parameter sogenannte RequestHandler. Der RequestHandler ist ein Callback mit den drei Parametern req, res und next und stellt damit einen Zugriff auf den Request (req), die Response (res) und die nächste Middleware (next) zur Verfügung.

Die Methode start() startet den Server unter dem gegebenen Port. Dazu wird ein neuer Restify-Server erstellt und zwei Plug-ins werden hinzugefügt. Die Plug-ins dienen zum Parsen der Query-Parameter und des Bodys, da wir auf beides im späteren Verlauf zugreifen wollen. Danach wird die private Methode addControllers() aufgerufen. Wir werden diese Methode implementieren, sobald wir den ersten Controller entwickelt haben.

Über restify.listen() wird der Server gestartet. Der Callback wird aufgerufen, sobald Anfragen an den Server gestellt werden können. Bevor wir den Server starten können, benötigen wir noch die Datei src/index.ts:

import {Server} from './server/index';

const server = new Server();
server.start(+process.env.PORT || 8080);

Um den Server zu starten, wird der Port über die Umgebungsvariable PORT gesetzt, was bei einem Hosting des Servers, bspw. auf Azure oder Heroku, erforderlich ist. Falls die Umgebungsvariable nicht zur Verfügung steht, starten wir den Server auf Port 8080. In einer Kommandozeile kann mit dem folgenden Befehl der Server gestartet werden: npm start.

Nach einem kurzen Moment sollte der Text Server is up & running on port 8080 angezeigt werden. Fortan wird der Server bei Codeänderungen automatisch neu gestartet.

HTTP-Anfragen mit Controllern entgegennehmen

Bei einem Web-API bezeichnen wir Controller als diejenige Klasse, die die HTTP-Anfragen entgegennimmt und an einen Service weiterleitet. Für unser Web-API werden wir zwei Controller entwickeln: CustomerController und BillController. Hierfür erstellen wir ein neues Interface, das wir in der Datei src/controllers/controller.ts ablegen:

import {HttpServer} from '../server/httpServer';

export interface Controller {
  initialize(httpServer: HttpServer): void;
}

Das Interface Controller definiert eine Methode initialize(), die als Parameter eine Instanz vom Typ HttpServer erhält. Wir geben hier bewusst keinen Restify-Typ an, da es für den Controller prinzipiell keine Rolle spielen sollte, wer den tatsächlichen Server bereitstellt. Korrekterweise dürfte unser Interface HttpServer den von Restify bereitgestellten RequestHandler auch nicht benutzen, um diese Abhängigkeit zu entfernen. Da es sich in diesem Fall allerdings nur um eine reine Typdefinition handelt, ohne Auswirkung auf den ausführenden Code, können wir ein Auge zudrücken. Um das Interface zu implementieren, erstellen wir eine neue Datei src/controllers/customer.ts mit dem Inhalt aus Listing 3.

import {Controller} from './controller';
import {HttpServer} from '../server/httpServer';
import {Request, Response} from 'restify';

export class CustomerController implements Controller {
  public initialize(httpServer: HttpServer): void {
    httpServer.get('customers', this.list.bind(this));
    httpServer.get('customer/:id', this.getById.bind(this));
    httpServer.post('customer', this.create.bind(this));
    httpServer.put('customer/:id', this.update.bind(this));
    httpServer.del('customer/:id', this.remove.bind(this));
  }

  private async list(req: Request, res: Response): Promise { }
  private async getById(req: Request, res: Response): Promise { }
  private async create(req: Request, res: Response): Promise { }
  private async update(req: Request, res: Response): Promise { }
  private async remove(req: Request, res: Response): Promise { }
}

Der CustomerController implementiert das Interface Controller und fügt die eingangs definierten Routen für einen Kunden hinzu. Die Methoden, die je nach URL aufgerufen werden sollen, werden wir implementieren, wenn wir den passenden Service dafür entwickelt haben. Auffällig ist hier noch, dass wir in manchen URLs einen Platzhalter :id hinterlegt haben, der bei einem konkreten Aufruf, wie bspw. /customer/1 mit dem tatsächlichen Wert gefüllt werden wird. Dann erstellen wir die Datei src/controllers/index.ts:

import {CustomerController} from './customer';

export const CONTROLLERS = [
  new CustomerController()
];

Die Datei index.ts definiert ein Array mit Instanzen aller zur Verfügung stehenden Controller. Dieses Array nutzen wir, um die Methode addControllers() in der Datei src/server/index.ts fertig zu implementieren. Dazu fügen wir in die Methode folgende Zeile Code ein: CONTROLLERS.forEach(controller => controller.initialize(this));.

Nach dem Speichern aller geänderten Dateien zeigt die Konsole, in der unser Befehl npm start läuft, an, dass vier Routen hinzugefügt wurden.

Definition eines Kunden

Unser Web-API hat den ersten Controller, der bisher noch nicht viel tun kann, da wir noch keine Daten zum Verarbeiten haben. Bevor wir das Modell für den Kunden erstellen, benötigen wir noch drei weitere Abhängigkeiten: npm i typeorm pg reflect-metadata.

  • typeorm ist ein ORM für TypeScript. Es erlaubt das Erstellen von Entitäten mithilfe von Decorators, ähnlich wie bei .NETs Entity Framework oder Javas Hibernate. Als Alternative zu TypeORM kann auch SequelizeJS genutzt werden. In Bezug auf TypeScript fühlt sich die Benutzung von TypeORM allerdings etwas runder an, da das API sich natürlich aller durch TypeScript bereitgestellter Features bedient. Dennoch sind beide Frameworks eine valide Option, wenn man ein ORM benötigt.
  • Das Paket pg ist der Treiber für PostgreSQL-Datenbanken. Möchte man hier lieber mit MySQL oder MS SQL arbeiten, können Alternativ die Pakete mysql oder mssql installiert werden. Weitere Datenbanktreiber können der Seite von TypeORM entnommen werden.
  • Das Paket reflect-metadata wird von TypeORM genutzt, um die Decorators der Modellklasse zu lesen und zu verarbeiten.

Generell sollte man die Entitäten, die in die Datenbank geschrieben werden, von den Datentransferobjekten (DTO), die über das Netzwerk geschickt werden, trennen. Entitäten in der Datenbank haben oftmals einen größeren Datenumfang als das DTO, das der Benutzer angefordert hat. So würde unser Web-API ein sehr dediziertes Interface erhalten, das nur die benötigten Daten erhält und versendet. Der Einfachheit halber werden wir im Rahmen des Artikels ein Modell als Entität und DTO benutzen. Unser Kundenmodell legen wir in der Datei src/models/customer.ts ab. Der Inhalt ist in Listing 4 zu finden.

import {Entity, Column, PrimaryGeneratedColumn} from 'typeorm';

@Entity()
export class Customer {
  @PrimaryGeneratedColumn()
  public id: number;

  @Column()
  public firstName: string;

  @Column()
  public lastName: string;
}

Unser Modell Customer ist definiert durch drei Eigenschaften: ID, Vorname und Nachname. Mehr benötigt das Kundenmodell vorerst nicht. Interessant sind hier die Decorators von TypeORM:

  • Der Decorator @Entity() gibt an, welche Klassen als Tabelle in der Datenbank gespeichert werden sollen. Über Optionen könnten wir auch angeben, wie die Tabelle in der Datenbank benannt werden soll. Geben wir nichts an, wird die Tabelle gemäß dem Klassennamen benannt.
  • Der Decorator @PrimaryGeneratedColumn() sorgt dafür, dass eine Eigenschaft in einer Klasse als Spalte in der Tabelle angelegt wird – in diesem speziellen Fall als Primärschlüssel mit einem automatisch inkrementierenden Wert. Über Optionen könnte angegeben werden, wie die Spalte in der Tabelle benannt werden oder ob statt eines inkrementierenden Integer-Werts eine GUID erzeugt werden soll.
  • Der Decorator @Column() wird jeder Eigenschaft hinzugefügt, die in der Tabelle als Spalte angelegt werden soll. Auch hier können über Optionen weitere Eigenschaften definiert werden, z. B. die Beschränkungen der maximalen Länge der Spalte bei Textspalten oder die Präzision bei numerischen Spalten.

Für das erste Kundenmodell soll diese Definition genügen. Schauen wir uns an, wie wir eine Verbindung zur Datenbank aufbauen können. Dazu erstellen wir die Datei src/database/index.ts mit dem Inhalt aus Listing 5.

import {Connection, createConnection} from 'typeorm';
import {Customer} from '../models/customer';

export interface DatabaseConfiguration {
  type: 'postgres' | 'mysql' | 'mssql';
  host: string;
  port: number;
  username: string;
  password: string;
  database: string;
}

export class DatabaseProvider {
  private static connection: Connection;
  private static configuration: DatabaseConfiguration;

  public static configure(databaseConfiguration: DatabaseConfiguration): void {
    DatabaseProvider.configuration = databaseConfiguration;
  }

  public static async getConnection(): Promise<Connection> {
    if (DatabaseProvider.connection) {
      return DatabaseProvider.connection;
    }

    if (!DatabaseProvider.configuration) {
      throw new Error('DatabaseProvider is not configured yet.');
    }

    const { type, host, port, username, password, database } = DatabaseProvider.configuration;
    DatabaseProvider.connection = await createConnection({
      type, host, port, username, password, database,
      entities: [Customer],
      autoSchemaSync: true
    } as any); 
    // as any to prevent complaining about the object does not fit to MongoConfiguration, which we won't use here

    return DatabaseProvider.connection;
  }
}

Diese Datei exportiert zuerst ein Interface einer Datenbankkonfiguration. Der zweite Export ist eine Klasse DatabaseProvider mit statischen Feldern und Methoden. Die Felder dienen zum Caching der Konfiguration sowie zum Caching der aufgebauten Datenbankverbindung. Die Methode configure() konfiguriert den Datenbankprovider mithilfe des zuvor definierten Interface.

Die Methode getConnection() kümmert sich um mehrere Dinge: Zum einen schaut sie, ob bereits eine Datenbankverbindung aufgebaut wurde und gibt sie zurück, wenn das der Fall ist. Wenn nicht, prüft die Methode, ob der Datenbankprovider konfiguriert wurde und gibt ggf. einen Fehler aus. Ist der Provider konfiguriert, wird eine Verbindung zur Datenbank hergestellt. Dazu wird die Konfiguration der Methode createConnection() von TypeORM übergeben. Zusätzlich muss TypeORM wissen, welche Entitäten in der Datenbank abgelegt werden sollen. Über autoSchemaSync werden Modelländerungen direkt in der Datenbank festgehalten. Doch Vorsicht, diese Einstellung sollte nur im Entwicklungsmodus benutzt werden, da bei einer Modelländerung alle Tabellen gelöscht und neu erstellt werden. Die erstellte Verbindung wird im statischen Feld connection gespeichert, sodass sie für künftige Zugriffe genutzt werden kann. Alternativ zu diesem Ansatz kann auch der TypeORM-eigene ConnectionManager genutzt werden, der eine sehr ähnliche Funktion darstellt.

Für die Konfiguration unseres DatabaseProviders müssen wir eine Änderung an der Datei index.ts vornehmen. Der Code aus Listing 6 muss vor dem Starten des Servers eingefügt werden.

import 'reflect-metadata';
import {DatabaseProvider} from './database/index';

DatabaseProvider.configure({
  type: process.env.DATABASE_TYPE as any || 'postgres',
  database: process.env.DATABASE_NAME || 'entwicklerde',
  username: process.env.DATABASE_USERNAME || 'entwicklerde',
  password: process.env.DATABASE_PASSWORD || 'entwicklerde',
  host: process.env.DATABASE_HOST || 'localhost',
  port: +process.env.DATABASE_PORT || 5432
});

Datenbankzugriff via Kundenservice

Für den Zugriff auf die Datenbank wollen wir einen CustomerService implementieren. Dazu legen wir die Datei src/services/customer.ts mit dem Inhalt aus Listing 7 an.

import {Customer} from '../models/customer';
import {DatabaseProvider} from '../database/index';

export class CustomerService {
  public async getById(id: number): Promise<Customer> {
    const connection = await DatabaseProvider.getConnection();
    return await connection.getRepository(Customer).findOneById(id);
  }

  public async create(customer: Customer): Promise<Customer> {
    // Normally DTO !== DB-Entity, so we "simulate" a mapping of both
    const newCustomer = new Customer();
    newCustomer.firstName = customer.firstName;
    newCustomer.lastName = customer.lastName;

    const connection = await DatabaseProvider.getConnection();
    return await connection.getRepository(Customer).save(newCustomer);
  }

  public async list(): Promise<Customer[]> {
    const connection = await DatabaseProvider.getConnection();
    return await connection.getRepository(Customer).find();
  }

  public async update(customer: Customer): Promise<Customer> {
    console.log(customer);
    const connection = await DatabaseProvider.getConnection();
    const repository = connection.getRepository(Customer);
    const entity = await repository.findOneById(customer.id);
    entity.firstName = customer.firstName;
    entity.lastName = customer.lastName;
    return await repository.save(entity);
  }

  public async delete(id: number): Promise<void> {
    const connection = await DatabaseProvider.getConnection();
    return await connection.getRepository(Customer).removeById(id)
  }
}

export const customerService = new CustomerService();

Der Kundenservice nutzt den Datenbankprovider, um Zugriff auf die Datenbank zu erhalten. Dazu muss in jeder Methode die Verbindung via getConnection() abgerufen werden. Die Verbindung hat die Methode getRepository(), hierbei handelt es sich um ein Repository vom im Parameter angegebenen Typ. Ein Repository abstrahiert den Zugriff auf eine Entität und stellt passende Methoden zum Datenzugriff bereit.

  • findOneById() findet eine Entität anhand ihres Primärschlüssels.
  • find() findet alle Entitäten. Sollte bei Tabellen mit vielen Zeilen vermieden werden, da alle Entitäten aus der Datenbank geladen werden. Über zusätzliche Optionen können WHERE-Bedingungen gesetzt werden.
  • save() speichert die angegebene Entität in der Datenbank.
  • removeById() löscht eine Entität anhand ihrer ID aus der Datenbank.

Zum Schluss wird eine Instanz des Service exportiert, sodass diese Instanz als Singleton in unserer Anwendung genutzt werden kann. Dazu wollen wir die Methoden unseres CustomerControllers implementieren. Der Code für die Methoden ist in Listing 8 zu finden.

private async list(req: Request, res: Response): Promise<void> {
  res.send(await customerService.list());
}

private async getById(req: Request, res: Response): Promise<void> {
  const customer = await customerService.getById(req.params.id);
  res.send(customer ? 200 : 404, customer);
}

private async create(req: Request, res: Response): Promise<void> {
  res.send(await customerService.create(req.body));
}

private async update(req: Request, res: Response): Promise<void> {
  res.send(await customerService.update({...req.body, id: req.params.id}));
}

private async remove(req: Request, res: Response): Promise<void> {
  res.send(await customerService.delete(req.params.id));
}

Generell rufen die Methoden des Controllers die passenden Methoden des Service auf. Sind Eingabeparameter notwendig, werden sie aus dem Request ausgelesen. Hierzu stellt Restify die Objekte params für Daten aus Query-Parametern und body für Daten aus dem Body zur Verfügung. In Bezug auf unsere Query-Parameter werden die Platzhalter, die wir im URL hinterlegt haben, bspw. :id, als Schlüssel für das params-Objekt genutzt, sodass wir mit req.params.id Zugriff auf den Inhalt des Platzhalters erlangen.

Manuelles Testen des API mit Postman

Mit dem letzten Code-Listing haben wir die Anwendung soweit implementiert, dass wir unser Web-API ausprobieren können. Dazu können wir mit Postman einen POST-Request erstellen, der an /customer geschickt wird und die nötigen Angaben als JSON-Body mit sich trägt. Ein Beispiel für den Body:

{
  "firstName": "Max",
  "lastName": "Mustermann"
}

Alternativ kann auch cURL genutzt werden:

curl -X POST \
  http://localhost:8080/customer \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json' \
  -d '{
    "firstName": "Max",
    "lastName": "Mustermann"
}'

Hat alles funktioniert, antwortet unser API mit dem eben erstellten Datensatz. Das erkennen wir daran, dass in der Antwort die ID des Kunden gesetzt ist (Abb. 2).

Abb. 2: Antwort in Postman nach dem Einfügen von Beispieldaten

Abb. 2: Antwort in Postman nach dem Einfügen von Beispieldaten

Zur Kasse bitte: Rechnungen für den Kunden

Unser eingangs definiertes Web-API bietet auch die Möglichkeit der Erfassung von Rechnungen für einen Kunden. Im Folgenden wollen wir diese Möglichkeit implementieren. Einem Kunden sollen mehrere Rechnungen zugewiesen werden können, während eine Rechnung immer genau einem Kunden zugewiesen ist. Eine klassische 1:n-Beziehung.

Hierzu erstellen wir zuerst das Modell für die Rechnung und legen die Datei src/models/bill.ts mit dem Inhalt aus Listing 9 an.

import {Column, Entity, ManyToOne, PrimaryGeneratedColumn} from 'typeorm';
import {Customer} from './customer';

@Entity()
export class Bill {
  @PrimaryGeneratedColumn()
  public id: number;

  @Column()
  public title: string;

  @Column()
  public sum: number;

  @ManyToOne(type => Customer, customer => customer.bills)
  public customer: Customer;
}

Eine Rechnung besteht aus einer ID, einem Titel und einer Rechnungssumme. Alle Felder und die Klasse sind mit den Decorators versehen, die wir bereits beim Modell Customer kennen gelernt haben.

Das Modell Bill hat indes in weiteres Feld customer mit einem neuen Decorator @ManyToOne(). Bevor wir uns anschauen, was es damit auf sich hat, wollen wir noch dem Modell Customer ein weiteres Feld spendieren:

@OneToMany(type => Bill, bill => bill.customer)
public bills: Bill[];

Ausgehend vom Modell Customer besitzt ein Kunde eine Liste von Rechnungen. Damit der ORM dies weiß, wird der Decorator @OneToMany() genutzt. Er gibt an, dass das Modell Customer eine 1:n-Beziehung zu einem anderen Modell hat. Damit der ORM weiß, zu welchem Modell eine Beziehung hergestellt werden soll – denn diese Information kann er durch den Kompilierungsvorgang nicht mehr aus der Felddefinition bestimmen – verweist der erste Parameter des Decorators auf den Typ der Beziehung. Der zweite Parameter gibt an, welches Feld für die inverse Beziehung im verweisenden Modell genutzt werden soll. Hierbei handelt es sich um das Feld customer, das wir eben dem Modell Bill hinzugefügt haben.

Wenn wir erneut einen Blick auf den Decorator @ManyToOne() vom Modell Bill werfen, sehen wir, dass wir damit die inverse Beziehung zum Modell Customer erstellen. Auch hier geben wir im ersten Parameter an, zu welchem Modell eine Beziehung hergestellt werden soll, während der zweite Parameter das Feld der inversen Beziehung angibt.

Übrigens: Durch die Einstellung autoSchemaSync müssen wir uns im Artikel nicht um die Migration des Datenbankschemas kümmern – das wäre nochmal ein eigenes Thema, das von TypeORM auch unterstützt wird.

Rechnungen in der Datenbank persistieren

Im nächsten Schritt wollen wir einen Service für unser neues Modell anlegen. Dazu erstellen wir die Datei src/services/bill.ts und füllen sie mit dem Inhalt aus Listing 10.

import {Bill} from '../models/bill';
import {DatabaseProvider} from '../database/index';
import {Customer} from '../models/customer';

export class BillService {
  public async list(customerId: number): Promise<Bill[]> {
    const connection = await DatabaseProvider.getConnection();
    return connection.getRepository(Bill).find({
      where: {
        customer: customerId
      }
    });
  }

  public async create(customerId: number, bill: Bill): Promise<Bill> {
    const connection = await DatabaseProvider.getConnection();

    // Normally DTO !== DB-Entity, so we "simulate" a mapping of both
    const newBill = new Bill();
    newBill.title = bill.title;
    newBill.sum = bill.sum;

    const customer = await connection.getRepository(Customer).findOneById(customerId);

    if (!customer) {
      return;
    }

    newBill.customer = customer;

    return await connection.getRepository(Bill).save(newBill);
  }

  public async getById(id: number): Promise<Bill> {
    const connection = await DatabaseProvider.getConnection();
    return connection.getRepository(Bill).findOneById(id);
  }

  public async delete(id: number): Promise<void> {
    const connection = await DatabaseProvider.getConnection();
    return await connection.getRepository(Bill).removeById(id);
  }
}

export const billService = new BillService();

Der BillService sieht dem CustomerService recht ähnlich. Auf zwei Dinge wollen wir weitere Blicke werfen.

Den ersten Blick werfen wir auf den Aufruf der Methode find() innerhalb der Methode list(). Wir hatten die Methode find() zuvor benutzt, um alle Kunden aus der Datenbank zu laden. Doch dieses Mal wollen wir alle Rechnungen in Bezug auf einen Kunden aus der Datenbank laden, was mit klassischem SQL mithilfe einer WHERE-Bedingung (oder via JOIN) erledigt wird. Hier unterstützt uns TypeORM, indem es optional die Angabe einer WHERE-Bindung zulässt. Als mögliche Werte für das where-Objekt können wir alle Felder aus dem Modell Bill nutzen. Da wir uns nur auf den Kunden einschränken wollen, nutzen wir das Feld customer und geben als Wert die ID des Kunden an, da darüber die Beziehung beider Modelle hergestellt wird. Dies ist eine kleine Abkürzung in TypeORM, da es sich um einen Fall handelt, der oft benötigt wird. Daher ist auch nur die Angabe der ID des Kunden zulässig. Für komplexere Abfragen kann in TypeORM der Query Builder genutzt werden.

Den zweiten Blick werfen wir auf die Methode create(). Innerhalb dieser Methode laden wir zuerst den Kunden, dem die Rechnung gestellt werden soll. Finden wir diesen Kunden in der Datenbank, setzen wir das Feld customer unseres Objekts newBill, um die Beziehung beider Instanzen herzustellen. Beim Abspeichern sorgt TypeORM dafür, dass auf der Tabelle Bill die richtige ID des Kundens hinterlegt wird. Damit das Modell auch von TypeORM tatsächlich erkannt wird, müssen wir im DatabaseProvider noch eine kleine Änderung vornehmen. Wir erinnern uns daran, dass dort eine Liste von entities spezifiziert war, die zu Beginn nur ein Element enthält, nämlich das des Kunden. Diese Liste erweitern wir mit unserem Bill-Modell: entities: [Customer, Bill].

Der zweite Controller für die Rechnungen

Modell und Service haben wir fertig entwickelt, doch aufrufen können wir sie noch nicht, da wir den dafür passenden Controller implementieren müssen. Dazu erstellen wir die Datei src/controllers/bill.ts und fügen den Inhalt aus Listing 11 ein.

import {Controller} from './controller';
import {HttpServer} from '../server/httpServer';
import {Request, Response} from 'restify';
import {billService} from '../services/bill';

export class BillController implements Controller {
  public initialize(httpServer: HttpServer): void {
    httpServer.get('customer/:id/bills', this.list.bind(this));
    httpServer.get('customer/:id/bill/:bid', this.getById.bind(this));
    httpServer.post('customer/:id/bill', this.create.bind(this));
    httpServer.del('customer/:id/bill/:bid', this.remove.bind(this));
  }

  private async list(req: Request, res: Response): Promise<void> {
    res.send(await billService.list(req.params.id));
  }

  private async getById(req: Request, res: Response): Promise<void> {
    const bill = await billService.getById(req.params.id);
    res.send(bill ? 200 : 404, bill);
  }

  private async create(req: Request, res: Response): Promise<void> {
    res.send(await billService.create(req.params.id, req.body));
  }

  private async remove(req: Request, res: Response): Promise<void> {
    res.send(await billService.delete(req.params.bid));
  }
}

Der Controller ähnelt sehr stark dem Controller des Kunden und definiert die eingangs definierten URLs. Mithilfe des billService implementiert der Controller die URL-Aufrufe.

Auch hier dürfen wir nicht vergessen, den Controller in unserem CONTROLLER-Array hinzuzufügen. Dazu erweitern wir das Array um die Erzeugung der Instanz des BillControllers: export const CONTROLLERS = [new CustomerController(), new BillController()];.

Manuelles Testen des APIs – die Zweite

Mit diesen weiteren wenigen Zeilen Code haben wir das API weiterentwickelt, sodass es abhängige Daten versteht, die wir erzeugen, ansehen und löschen können. Zeit, unsere Änderungen auszuprobieren. Damit wir mit dem neuen API arbeiten können, müssen wir mindestens einen Kunden angelegt haben, dessen ID wir kennen. Für das Beispiel hier im Artikel gehen wir davon aus, dass der Kunde die ID 1 hat. Um eine Rechnung für ihn zu erstellen, posten wir ein Rechnungsobjekt an den URL /customer/1/bill. Ein Beispiel für den JSON-Body:

{
  "title": "Rechnung für ein neues MacBook Pro",
  "sum": 3999.99
}

Und hier ein cURL-Beispiel:

curl -X POST \
  http://localhost:8080/customer/2/bill \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json' \
  -d '{
    "title": "Rechnung für ein neues MacBook Pro",
    "sum": 3999.99
}'

Hat der Aufruf geklappt, antwortet das API mit dem vollständigen Rechnungsobjekt, das sowohl die erzeugte ID enthält als auch den Kunden, der diese Rechnung erhalten hat.

Das Innenleben eines Node.js-Servers

Entwickelt man einen Server mit Node.js, wie wir es mit unserem Web-API gemacht haben, muss man sich einer Sache bewusst sein: Node.js hat nur einen Thread. Dieser Thread, die sogenannte Event Loop, kümmert sich um die Ausführung unseres JavaScript-Codes (Abb. 3). Blockieren wir mit JavaScript den Thread, wird unsere gesamte Anwendung blockiert. Diese Entscheidung hat Node.js ganz bewusst getroffen, um hochskalierbare und performante Anwendungen zu erzeugen. Wie das zusammenpasst? Ganz einfach: Node.js liebt Use Cases, die asynchrone I/O-Aufgaben erledigen. Sei es das Lesen oder Schreiben auf das Dateisystem, das Stellen von HTTP-Anfragen oder das Laden von Daten aus einer Datenbank. All diese Sachen sind Stellen im Code, bei der die Anwendung eigentlich auf eine Antwort warten müsste. Und genau hier übergibt Node.js diese Aufgabe an einen echten Betriebssystemthread, der die eigentliche Arbeit erledigt. In dieser Zeit läuft die Event Loop weiter und Node.js kann auf weitere Anfragen reagieren. Hat der Betriebssystemthread die nötigen Informationen, ruft er einen Callback innerhalb unserer Anwendung auf und die Event Loop bearbeitet diesen Callback. Solange wir also die Event Loop nicht mit Berechnungen blockieren, bleibt unser Node.js auch mit vielen Anfragen performant. Ein Grund, warum Firmen wie Netflix, Paypal, Groupon oder auch die NASA Node.js einsetzen.

Abb. 3: Schema eines Node.js-Servers

Abb. 3: Schema eines Node.js-Servers

Fazit

In diesem Artikel haben wir gesehen, wie einfach und schnell sich ein kleines Web-API entwickeln lässt, das eine grundlegende Architektur mit sich bringt. Das Web-API ist in der Lage, Daten über Query-Parameter und via Body entgegenzunehmen und an einen Service weiterzureichen. Exemplarisch liefert der Controller einen passenden Statuscode zur Antwort des Service aus. Der Service nutzt TypeORM, um die Daten in einer Datenbank zu speichern. Mithilfe von Decorators konnten wir auf einfache Art und Weise unsere Modelle in Datenbankentitäten wandeln und typsicher abfragen. Zum Schluss haben wir einen kleinen Ausflug in das Innenleben eines Node.js-Servers gemacht.

Doch ist das etwa schon das Ende der Fahnenstange? Sicher nicht. Viele Themen haben wir im Rahmen des Artikels noch gar nicht angeschnitten, bspw. Throtting/Rate-Limiting des APIs, Fehlerbehandlung und Logging, Versionierung und Paging von Daten. Auch ist unsere Entwicklung noch nicht über Unit Tests abgedeckt. Es bleibt daher noch viel zu entdecken und zu erforschen.

Entwickler Magazin

Entwickler Magazin abonnierenDieser Artikel ist im Entwickler Magazin erschienen.

Natürlich können Sie das Entwickler Magazin über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist das Entwickler Magazin ferner im Abonnement oder als Einzelheft erhältlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -