Rezepte für Webanwendungen mit Node.js, Express.js und TypeScript

Keine Kommentare

Node.js brachte die Programmiersprache JavaScript aus den Browsern auf die Serverseite und in die Kommandozeilen dieser Welt. Seitdem hat sich das Ökosystem von Node.js zu einer etablierten Wahl für die Softwareentwicklung gemausert. Express.js gilt dabei als beliebte Wahl für Webanwendungen. TypeScript ist laut Ergebnissen von The State of JavaScript (sowie Google) die populärste Superset-Sprache für die Entwicklung von JavaScript-Anwendungen.

Google Trends der letzten 5 Jahre über JavaScript Superset Sprachen – TypeScript gewinnt

Dieser Artikel beschäftigt sich mit den Grundlagen von Express.js und Node.js sowie aufeinander aufbauenden Rezepten zum Bootstrapping einer Webanwendung. Es werden Anregungen und Ansätze aufgezeigt, wie lokal entwickelt, getestet und gebaut werden kann. Daraus entwickelt sich mit der Zeit dieses Boilerplate-Projekt für Web-Apps auf diesem Stack.

In weiteren Artikeln dieser Reihe soll es künftig tiefer in die Materie gehen. Es werden, wie in einem Kochbuch, bewährte Rezepte beschrieben, mit denen Herausforderungen bei der Webentwicklung begegnet werden können.

Setup der Node-Express-Anwendung in TypeScript – Schritt 1

Auf dem Entwicklungssystem wird vor allem Node.js benötigt. NPM (Node Package Manager) – das Build-Tool und die Schnittstelle zum riesigen Node-Ökosystem – ist darin enthalten und reicht für unser Vorhaben aus. Es empfiehlt sich, einen Versionsmanager wie NVM (Node Version Manager) für Node zu verwenden. So kann in unterschiedlichen Projekten die Node-Version gewechselt werden und neue Versionen sind schnell installiert. Durch die Datei .nvmrc wird in einem Projekt die zu nutzende Node-Version hinterlegt.

NVM, Node und NPM können nun direkt auf dem Stand von Schritt #1 des begleitenden Beispiel-Repositories dieser Artikelreihe getestet werden. Der initiale Branch wird zuerst geklont:
git clone --branch 01-setup git@github.com:jverhoelen/node-express-typescript-boilerplate.git

Dieser Stand des Codes beinhaltet bis jetzt nur großzügig verteiltes Bootstrapping eines ExpressServer und die erforderlichen Konfigurationen tsconfig.json und package.json. Der Stand des Projektes kann wie folgt ausprobiert werden:

nvm use
nvm install lts/dubnium # wird nur bei erster Benutzung nötig sein
npm install
npm start

Der Express-Server läuft, kann bisher aber nur wieder beendet werden. Um die wichtigsten Konzepte von Express.js zu erläutern und kennenzulernen, wird im nächsten Schritt das erste Feature hinzugefügt.

Verwendung von Express.js Middlewares und Request Mappings – Schritt 2

In Express.js dreht sich alles um HTTP Request und Response. Sie beschreiben genau das, was man erwartet: Body, Parameter, Header usw. Der Request hat State – er kann an allen Stellen der Verarbeitung gelesen, erweitert und modifiziert werden.

Die Request-Verarbeitung wird vollständig von einer Menge von Request Handlers (auch Middlewares genannt) übernommen. Dies sind Funktionen, die als Parameter den Request, Response und die Next-Function bekommen. Der Request kann beliebig gelesen und modifiziert werden. Response stellt ein API dar, um den HTTP Response aufzubauen und ihn zu senden. JSON Body, HTML String, Template Rendering, Redirect und vieles mehr steht zur Verfügung. Die Next-Function wird aufgerufen, wenn die Verarbeitung des Requests an den nächsten Request Handler in der Kette übergeben werden soll. Der letzte Request Handler in der Kette liefert üblicherweise den Response, der die HTTP Request-Behandlung abschließt. Middlewares können global in der Reihenfolge definiert werden, in der sie ausgeführt werden sollen.

Außerdem können weitere Middleware-Ketten vor einzelne Request Mappings geschaltet werden. Im folgenden Code werden drei Middlewares zu einer Request-Behandlung hinzugefügt, die für Webanwendungen oft benötigt werden und noch nicht automatisch aktiviert sind:

private setupStandardMiddlewares(server: Express) {
    server.use(bodyParser.json())
    server.use(cookieParser())
    server.use(compress())
}

Request Mappings definieren, wie ein Request-Muster behandelt wird, nachdem die globalen Middlewares ausgeführt worden sind. Zum Beispiel:

server.get('/api/cat/:catId', noCache, this.catEndpoints.getCatDetails)

Der GET-Request /api/cat/123 wird nach den globalen Middlewares noch von der Middleware noCache behandelt und sendet durch die Methode this.catEndpoints.getCatDetails den Response an den Client.

Der Stand von Schritt #2 der Anwendung veranschaulicht diese Konzepte und implementiert ein Beispiel. Zusätzlich sind noch ein paar npm-Abhängigkeiten hinzugekommen, die die Middlewares und TypeScript-Typen für diese liefern. Der Stand nach Schritt #2 kann ausgecheckt werden; die Änderungen seit Schritt #1 lassen sich im Pull Request betrachten.

Nach dem Ausführen von npm install und einem Neustart des Servers steht der REST-Endpunkt zur Verfügung, zum Beispiel auf http://localhost:8000/api/cat/123. Ist die ID der Katze unter 90, wird lediglich der Status 404 zurückgegeben.

Schnitt und Design der Express.js-Anwendung – Schritt 3

Dieser Artikel empfiehlt eine fachliche Gliederung von Ordnern und Dateien. Dies bedeutet, dass Code der zu einer Thematik und zu einem Feature gehört, zusammen liegt. Beispielsweise liegt Code für Geschäftslogik, Datenzugriff, Mapping und Darstellung eines Katzen-API optimalerweise nah beieinander. Diese voneinander abhängigen Komponenten werden wahrscheinlich zusammen entwickelt und getestet. Es sollte also nicht nach Art der Datei (z. B. Repository, Model, Service, Middleware) aufgeteilt werden, sondern nach fachlicher Zugehörigkeit.
Manchmal gibt es natürlich Dateien mit Klassen, Typen, Konstanten und Funktionen, die sich nicht fachlich schneiden lassen. Code für Aufgaben wie Logging, Sicherheit oder Telemetrie kann hingegen gut, abhängig von seiner Menge, in Ordnern wie middlewares oder security erfasst werden.

Auf dem nächsten Stand des Repositorys, auf Branch 03-server-application-design, werden zur Verdeutlichung die bisher rein serverseitigen Dateien in service/server/ verschoben. Dort finden wir nun einen CatService sowie ein CatRepository im service/server/cats/ Verzeichnis. Eine neue Middleware kommt ebenfalls hinzu, sodass alle „Custom-Middlewares“ des Repositorys sich nun in service/server/middlewares/ befinden.

> tree service
service
└── server
    ├── Application.ts
    ├── ExpressServer.ts
    ├── cats
    │   ├── Cat.d.ts
    │   ├── CatEndpoints.ts
    │   ├── CatRepository.ts
    │   └── CatService.ts
    ├── index.ts
    ├── middlewares
    │   ├── DatadogStatsdMiddleware.ts
    │   ├── NoCacheMiddleware.ts
    │   └── ServiceDependenciesMiddleware.ts
    └── types
        ├── CustomRequest.d.ts
        ├── connect-datadog
        │   └── index.d.ts
        └── hot-shots
            └── index.d.ts

Nach dem Auschecken des 03-Branches (Pull Request mit Änderungen seit Schritt #2) wird der Server zunächst manuell neu gestartet. Die neuen REST-Endpunkte /api/cat, /api/statistics/cat und /api/cat/<id> enthalten interessante Informationen über Katzen (deren Attribute schwer an eine Band aus Birmingham in Großbritannien erinnern). Sie können im Browser manuell ausprobiert werden.

In diesem Schritt ist die Anwendung um eine handvoll Dateien gewachsen, die im nächsten Abschnitt mit Unit- und Integrationstests versehen werden sollen.

Unit und Integration Testing in Express.js-Anwendungen – Schritt 4

Unit Tests prüfen das Verhalten einer oder mehrerer zusammenhängender Code-Komponenten wie Klassen oder Funktionen. Wenn Verhalten oder Ergebnis von abhängigen Komponenten in Unit-Tests bewusst kontrolliert oder deren Aufrufe auf Korrektheit geprüft werden sollen, wird Stubbing und Mocking verwendet.

In Integrationstests werden ganze Features z. B. auf Ebene eines HTTP-API oder gar eines einfachen UI in ihrer Gesamtheit getestet. Dafür wird der Service temporär gestartet oder auch auf einer kontinuierlich bespielten Umgebung wie der Dev-Stage ausgerollt. Bei Integrationstests kann mit Fake-Daten gearbeitet werden, um die Inputs für die Anwendungslogik zu kontrollieren und damit vorhersehbare Testergebnisse zu liefern.

Die Vielfalt und Kombinierbarkeit von JavaScript Testing-Frameworks, Libraries und Erweiterungen ist nicht einfach zu überschauen. Der Artikel “An Overview of JavaScript Testing in 2019” von Vitali Zaidman ist lesenswert, wenn man sich genauer über die Welt der JavaScript-Testing-Tools und deren Unterschiede informieren möchte.

Ich schlage mit den Tools Mocha, Chai, Sinon und Supertest eine Kombination vor, die sich in einem langzeitigen Projekten mit diesem Stack etabliert hat. UI-Tests, die Spitze der klassischen Testpyramide, werden in einem zukünftigen Artikel der Serie behandelt.

JavaScript-Testpyramide für Express.js und Node.js

Unit-Tests, Integrationstests und UI-Tests – die klassische Testing-Pyramide konkretisiert mit einer handvoll Tools aus der JavaScript-Welt.

Der Test-Runner Mocha ist dafür bekannt, einfach mit anderen Tools kombinierbar zu sein. Dafür ist nur eine überschaubare Menge Konfiguration nötig. Chai ist die beliebteste Bibliothek für Test-driven development (TDD) und Behaviour-driven development (BDD) Assertions. Es kann neben dem großen Basis-Vokabular für die Formulierung von Tests auch durch Third-Party-Bibliotheken einfach erweitert werden. Sinon ist die wohl mächtigste und meistbenutzte Bibliothek für Test Spies, Mocks und Stubs in JavaScript und sollte daher nicht im Arsenal fehlen. Supertest ist eine passende Bibliothek, mit der REST APIs in Integration Tests angesprochen werden können.

Im Branch 04-unit-and-integration-tests wird das Tooling eingebaut und ein einige Unit Tests kommen hinzu. Hierbei werden Repository, Service- und Endpoint-Klasse sowie eine Middleware getestet. Die Testdateien liegen dabei neben den Implementierungen und enden mit „Spec.ts“. Dies hat den Vorteil, dass man einen Test unabhängig von den Features der IDE sofort findet. Auch in Pull bzw. Merge Requests von GitHub und GitLab werden betroffene Änderungen an den Dateien alphabetisch geordnet. Das führt dazu, dass die Kollegen es während des Code Reviews einfacher haben. Das folgende Beispiel testet den CatService:

import * as sinon from 'sinon'
import { expect } from 'chai'
import { CatService } from './CatService'
import { exampleCats } from './exampleCats'
 
describe('CatService', () =&gt; {
    const sandbox = sinon.createSandbox()
    let catService: CatService
    let catRepository: any
 
    beforeEach(() =&gt; {
        catRepository = { getAll: sandbox.stub().returns(exampleCats) }
        catService = new CatService(catRepository)
    })
 
    describe('getCatsStatistics', () =&gt; {
        it('should reflect the total amount of cats', () =&gt; {
            expect(catService.getCatsStatistics().amount).to.eq(5)
        })
 
        it('should calculate the average age of all cats', () =&gt; {
            expect(catService.getCatsStatistics().averageAge).to.eq(69.2)
        })
 
        it('should calculate an average age of zero if the amount of cats is zero', () =&gt; {
            catRepository.getAll.returns([])
            expect(catService.getCatsStatistics()).to.deep.equal({
                amount: 0,
                averageAge: 0
            })
        })
    })
})

Alle Unit-Tests können mit npm run test:unit ausgeführt werden.
Integrationstests, die das laufende Katzen-REST-API testen, liegen in test/integration/ und enden mit Test.ts. Sie werden mit npm run test:integration ausgeführt. Das Beispiel CatsApiTest.ts aus dem Projekt sieht folgendermaßen aus:

import * as request from 'supertest'
import { expect } from 'chai'
import * as HttpStatus from 'http-status-codes'
import TestEnv from './TestEnv'
const { baseUrl } = TestEnv
 
describe('Cats API', () =&gt; {
    describe('Cat details', () =&gt; {
        it('should respond with a positive status code if the cat is known', () =&gt; {
            return request(baseUrl)
                .get('/api/cat/1')
                .expect(HttpStatus.OK)
        })
 
        it('should respond with 404 status code if the cat is not known', () =&gt; {
            return request(baseUrl)
                .get('/api/cat/666')
                .expect(HttpStatus.NOT_FOUND)
        })
 
        it('should respond with cat details data if the cat is known', () =&gt; {
            return request(baseUrl)
                .get('/api/cat/1')
                .expect((res: Response) =&gt; {
                    expect(res.body).to.deep.equal({
                        id: 1,
                        name: 'Tony Iommi',
                        breed: 'British Shorthair',
                        gender: 'male',
                        age: 71
                    })
                })
        })
    })
})

Bei Tests für in Request-Mappings genutzte Request Handler, das letzte Stück der Verarbeitung eines HTTP-Requests, kann die Bibliothek „expressmocks“ punkten. Beispiele für die Verwendung finden sich in der README auf GitHub von „expressmocks“ sowie im Branch für diesen Schritt (siehe CatEndpointsSpec.ts).

Um auf Tuchfühlung mit dem Tooling zu gehen, kann der Branch für diesen Schritt ausgecheckt werden:
git checkout 04-unit-and-integration-tests.
Die Änderungen zu Schritt 3 sind in diesem Pull Request sichtbar.

Frontend ausliefern, Code teilen, Hot-Reloading – Schritt 5

In diesem Schritt findet eine Erweiterung statt, die es ermöglicht, dass ein einfaches Frontend vom Server ausgeliefert wird und Frontend sowie Backend etwas Code miteinander teilen. Zudem wird Tooling für Hot-Module-Replacement (HMR) installiert und konfiguriert. Dies ermöglicht schneller sichtbare und testbare Änderungen während der Entwicklung.

Die Begriffe Isomorphic JavaScript oder Universal JavaScript mögen nicht mehr „hip“ sein und werden kontrovers diskutiert. Trotzdem gibt es Gründe dafür, Code zwischen Front- und Backend zu teilen. Hierbei kann es z. B. um Interfaces mit Schnittstellen-Definitionen zwischen Backend und Frontend gehen.
Um dies ohne viel Aufwand zu bewerkstelligen, wird die Codebasis in Frontend-, Backend- und Shared-Ordner unterteilt. Webpack soll jedoch nur zwei Bundles bauen, Frontend und Server, und beiden Bundles auch die Dateien in „Shared“ zugänglich machen. Frontend- und Backend-Bundle dürfen also Dateien aus Shared importieren, jedoch nicht andersherum. Ebenso können sich Dateien in Frontend und Backend nicht gegenseitig importieren. Geteilter Code muss über Dependencies oder den „Shared“-Ordner bereitgestellt werden.

Code Sharing über Librarys ist jedoch mit Vorsicht zu genießen. Betreut ein Team beispielsweise fünf Microservices (oder drei umfassendere Self-Contained Systems), kann es sich dazu entscheiden, gemäß “Don’t Repeat Yourself” (DRY) Code in einer eigenen Library zu teilen. Das Team kann entscheiden, ob fachlicher, technischer oder jeglicher Code darin geteilt wird. Allerdings wird es dafür bezahlen, dass alle Services von der „Shared Library“ abhängig sind. Durch Änderungen dieser Library müssen ggf. alle Services wieder angefasst werden, um auf API-Änderungen zu migrieren. Releasing der Library macht die Weiterentwicklung der Services außerdem vom Release-Prozess derselben abhängig, was ebenfalls zusätzliche Zeit und Pflege bedeutet.
Im Umfeld von Microservices und Self-Contained Systems sind Shared Libraries nicht empfehlenswert. Um Kopplung zu vermeiden, sollte stattdessen Code-Duplikation in Kauf genommen werden. Ist dennoch zu viel duplikater Code über Services hinweg das Ergebnis, ist das wohlmöglich ein Indiz für falsch definierte Bounded Contexts oder einen fehlenden Service. Bei Shared Code zwischen Frontend und Backend hingegen lassen sich Vorteile erzielen, die nicht mit den teuren Nachteilen einer Shared Library bezahlt werden müssen.

Um Frontend sowie Backend bauen zu können, werden Webpack und awesome-typescript-loader genutzt. Für das Bauen des TypeScript-Frontends ist eine eigene tsconfig-frontend.json erforderlich, die konfiguriert, dass zu ES5 transpiliert wird, JSX-Syntax für React genutzt werden darf und mit dem DOM interagiert wird.

Unter dem Einsatz von webpack-dev-middleware und webpack-hot-middleware als Express-Middlewares wird dem Server während der lokalen Entwicklung die Aufgabe überlassen, Hot Module Replacement des Frontends zu ermöglichen.
Für das Reloading des Backends bei Änderungen am Code wird nodemon genutzt. Dabei handelt es sich um ein Node.js-Tool, das einen Node-Prozess neu startet, sobald Änderungen an Dateien im konfigurierten Pfad auftreten. In der neuen Definition von npm start steuert nun also nodemon die Ausführung des Node.js Codes in TypeScript.

Dieser Pull Request zeigt alle Änderungen zwischen Schritt 4 und 5 und somit, wie die Neuerungen korrekt implementiert und konfiguriert werden. Wie sich die Entwicklung nun anfühlt, kann nach Auschecken des Branches für Schritt 5 ebenfalls ausprobiert werden:

An diesem Punkt ist die Codebasis für diesen Artikel final. Welche npm-Skripte ab jetzt zur Verfügung stehen, wird in der Dokumentation des Repositories erklärt.

Ein Blick in den brodelnden Kochtopf

Die minimale Express-Anwendung aus dem ersten Schritt ist zu einer technisch versierteren Anwendung geworden, die sich angenehm entwickeln, testen und bauen lässt. Parallel dazu wurden einige Grundzüge von Express.js, Node.js, TypeScript und den kennengelernten Testing und Build-Tools klar. Mit dem Katzen-API wurde minimale Fachlichkeit implementiert, die allerdings noch keine Persistenz oder komplexe Geschäftslogik aufweist.

Kochtopf

Es wird klar, dass Express und Node alleine nicht alle wünschenswerten Rezepte für moderne Webentwicklung mitbringen. Jedoch hält das Node-Universum viele gute, in sich abgeschlossene, aber trotzdem kombinierbare Bibliotheken bereit, die genutzt werden sollten. Im Vergleich zu Frameworks wie Spring Boot liegt nicht direkt jedes Werkzeug zur Nutzung bereit, sondern muss gefunden, evaluiert und mit etwas Aufwand konfiguriert werden. Dafür erhält man mehr Flexibilität und Kontrolle darüber, wie die Webanwendung entwickelt wird und für welche Tools und Abhängigkeiten man sich entscheidet.

Im nächsten Artikel geht es weitere spannende Herausforderungen und Rezepte: Wie geht man mit Security, Hardening und Rate Limiting um? Auch Themen wie Configuration Management und Secrets werden beleuchtet. Nach und nach wird die Anwendung immer weiter anhand von Prinzipien wie Self-Contained Systems und Twelve-Factor Apps aufgebaut.

Zum zweiten Teil der Artikelserie

Jonas Verhoelen

Jonas brennt dafür, im Team Geschäftswert durch gute Software zu schaffen. Er entwickelt am liebsten in Type- oder JavaScript sowie verschiedenen JVM-Sprachen und arbeitet dabei gerne full-stack. Zudem steht er dem Kunden mit Expertenwissen, Tools und Methoden zur Beratung rund um Distributed Ledger Technology und IT Security zur Verfügung. Teamwork, Selbstorganisation und ein agiles Mindset sind die Basis seiner täglichen Arbeit.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.