Über unsMediaKontaktImpressum
Sebastian Springer 09. Mai 2017

Webapplikationen mit Node.js

JavaScript hat dank der Node.js-Plattform seit einigen Jahren auch auf der Serverseite Einzug gehalten. Der Erfolg dieser Plattform basiert unter anderem auf dem größten Ökosystem, das sich um eine Programmiersprache gebildet hat. Die Rede ist vom NPM, dem Node Package Manager. Dieser hat sich in kurzer Zeit von einem Werkzeug zur Verwaltung von Paketen für Node.js zum De-facto-Standard für die gesamte JavaScript-Welt entwickelt. Die losen Regeln des Paketmanagers haben zu einem explosiven Wachstum der Anzahl der Pakete in der NPM-Registry geführt. Was für Node.js und JavaScript ein klarer Vorteil ist, entpuppt sich für Entwickler zu einem Alptraum. Für nahezu jede Problemstellung existieren bereits zahlreiche Lösungsansätze. Und genau hier beginnen die Probleme. Welches Paket sollen Sie nun verwenden und von welchem lieber die Finger lassen? Im Folgenden zeige ich Ihnen, wie sich eine Web-Applikation mit Node.js umsetzen lässt. Dabei lernen Sie einige häufig verwendete Pakete kennen und erfahren außerdem, wie Sie sich im Dschungel der NPM-Pakete zurechtfinden können.

Die Aufgabenstellung

In diesem Artikel stelle ich Ihnen anhand eines Beispiels vor, wie Sie eine Applikation mit Node.js entwickeln können. Bei der Aufgabe handelt es sich um ein einfaches Issue Tracking-System. Mehrere Benutzer können über ein Web-Frontend Issues verwalten. Ein Benutzer muss sich zunächst am System anmelden, dann stehen ihm die verschiedenen Funktionen zur Verfügung. Neben der reinen Datenverwaltung lernen Sie weitere Aspekte einer Node.js-Applikation wie Logging oder Sicherheit kennen.

Initialisierung

Bevor Sie mit der eigentlichen Arbeit an Ihrem Projekt beginnen, sollten Sie es zunächst initialisieren. Diese Initialisierung beinhaltet vor allem die Erzeugung der Projektkonfiguration in Form der package.json-Datei. Mit dem Kommando npm init können Sie eine grundlegende package.json-Datei über einen interaktiven Prozess auf der Kommandozeile erzeugen lassen. Hierfür müssen Sie eine Reihe von Fragen beantworten, beispielsweise, wie der Name oder die Versionsnummer des Projekts lautet. Die von NPM vorgeschlagenen Standardeinstellungen sind häufig schon ausreichend. Sie können die getroffene Auswahl jederzeit in der generierten package.json-Datei anpassen. Eine wichtige Ergänzung, die Sie nach der Erzeugung noch vornehmen sollten, ist das Einfügen des Eintrags private: true. Dieser verhindert ein versehentliches Publizieren des Projekts in das NPM-Repository über das Kommando npm publish. Eine weitere Aufgabe, die häufig im Zuge der Initialisierung erledigt wird, ist das Ignorieren bestimmter Dateien und Verzeichnisse für die Versionskontrolle. Im Fall von Git geschieht dies über die Datei .gitignore. In einem Node.js-Projekt wird in der Regel das node_modules-Verzeichnis ignoriert. In diesem Verzeichnis werden alle für das Projekt installierten NPM-Pakete gespeichert. Ihre Namen und die Versionsnummern werden in der package.json-Datei festgehalten, sodass der Inhalt dieses Verzeichnisses jederzeit über die NPM-Registry wiederhergestellt werden kann. Als Alternative zu NPM existiert der von Facebook entwickelte Paketmanager Yarn [1]. Dieser ist weitestgehend kompatibel zu NPM, beinhaltet jedoch einige Verbesserungen.

Die Wahl der Sprache

Die erste Frage, die Sie sich zu Beginn stellen sollten, ist, welche Programmiersprache Sie verwenden wollen. Sie müssen bei einer Node.js-Applikation keineswegs immer JavaScript nutzen, sondern können aus einer Vielzahl von Dialekten wählen. Häufig wird beispielsweise auf TypeScript zurückgegriffen, das JavaScript mit einem Typensystem ausstattet und damit die Entwicklung von Applikationen weniger fehleranfällig macht.

Um TypeScript verwenden zu können, müssen Sie das Paket zunächst mit npm install —save-dev typescript installieren. Mit NPM haben Sie zwar die Möglichkeit, solche Pakete auch systemweit zu installieren. Dies hat jedoch den Nachteil, dass Sie die gleiche Version für alle Projekte auf Ihrem System nutzen müssen. Mit tsc —init erzeugen Sie eine initiale Konfiguration in Form der tsconfig.json für Ihr Projekt. Hier legen Sie die Optionen für den TypeScript-Compiler fest. Für Ihr Projekt sind das vor allem der Schlüssel target mit dem Wert es2016, der dafür sorgt, dass ECMAScript2016-kompatibles JavaScript erzeugt wird. Außerdem geben Sie mit files an, welche Dateien kompiliert werden sollen. Hier reicht es aus, wenn Sie die Einstiegsdatei angeben, um den Rest kümmert sich das Modulsystem. Über den Schlüssel modules mit dem Wert commonjs legen Sie fest, dass das ECMAScript-Modulsystem in das CommonJS-Modulsystem von Node.js übersetzt werden soll.

Da Node.js zunächst einmal nicht mit TypeScript kompatibel ist, installieren Sie die Type Definitions für Node.js mit dem Kommando npm install —save-dev @types/node. Dieses Paket enthält die Schnittstellendefinitionen für Node.js, sodass die einzelnen Module, Klassen und Funktionen dem Compiler bekannt sind. Sämtliche Definitions-Pakete, die unter node_modules/@types gespeichert sind, werden vom Compiler standardmäßig eingelesen. Mit tsc -w starten Sie schließlich den TypeScript-Compiler im watch-Modus. Bei jeder Änderung des Quellcodes wird der Compiler aktiv und übersetzt den TypeScript-Code in JavaScript. Das führt dazu, dass Sie Ihre Node.js-Applikation bei jedem Compiler-Durchlauf theoretisch neu starten müssten.

Abhilfe schafft hier das Paket pm2, der Process Manager für Node.js. Diesen installieren Sie mit npm install —save-dev pm2. Mit dem Kommando pm2 start index.js —watch starten Sie Ihre Applikation ebenfalls im watch-Modus, was zur Auswirkung hat, dass der Node.js-Prozess bei jeder Änderung der JavaScript-Dateien neu gestartet wird. Dieser Modus ist natürlich nur für den Entwicklungsbetrieb und keinesfalls für den Produktivbetrieb geeignet. Um die Ausgaben des Prozesses zu sehen, verwenden Sie den Befehl pm2 logs.

Express.js als Application Framework

Node.js beinhaltet alles, was Sie zur Umsetzung einer Web-Applikation benötigen. Mit dem http-Modul erzeugen Sie einen Webserver, der eingehende Anfragen annimmt und die passenden Antworten erzeugen kann. Nutzen Sie das http-Modul direkt, müssen Sie sich um die Lösung einiger grundlegender Problemstellungen wie beispielsweise dem Umgang mit verschiedenen URL-Pfaden selbst kümmern. Aus diesem Grund gibt es Frameworks, die diese Probleme für Sie lösen, Ihnen aber trotzdem maximale Flexibilität bei der Entwicklung Ihrer Applikation bieten. Das bekannteste Web-Application-Framework für Node.js ist Express [2]. Express ist ein Open Source-Framework, das auf dem http-Modul aufbaut und den Webserver um zusätzliche Features wie Routing und eine Plugin-Infrastruktur, die Middleware, erweitert. Hinter Express steht das Unternehmen StrongLoop, das wiederum eine Tochterfirma von IBM ist.

Express liegt als NPM-Paket unter demselben Namen vor, das Sie mit npm install —save express installieren können. Die zugehörige Typdefinition erhalten Sie mit dem Befehl npm install —save-dev @types/express.

Listing 1: Grundlegende App

const express = require('express');
const app = express();

app.get('/', (req, res) => {
    res.end('Hello Express!');
});

app.listen(8080, () => {
    console.log('Listening to http://localhost:8080');
});

Der Aufbau einer Express-Applikation folgt immer dem gleichen Schema: Zu Beginn laden Sie die benötigten Dateien und Pakete mit dem Modulsystem. In diesem Fall greifen Sie auf das import-Schlüsselwort von TypeScript zu, das durch den Compiler zu require übersetzt wird. Danach rufen Sie Express auf und erzeugen so Ihr App-Objekt, mit dem Sie im weiteren Verlauf arbeiten. An diesem Objekt werden Routen und Middleware-Komponenten registriert. Zum Schluss binden Sie Ihre Applikation noch an einen TCP-Port und fertig ist Ihre Applikation.

In einer modernen Web-Applikation ist es ein absolutes No-go, unverschlüsselte Verbindungen zum Client anzubieten. Aus diesem Grund sollten Sie grundsätzlich HTTPS verwenden, sobald Sie personenbezogene oder unternehmenskritische Daten verarbeiten. Außerdem sollten Sie darüber nachdenken, HTTP in der Version 2 zu verwenden. Solange Node.js HTTP2 noch nicht nativ unterstützt, greifen Sie auf das spdy-Paket zurück. Anders als der Name vermuten lässt, handelt es sich hier um das quasi-Standard-Paket, wenn es um HTTP2-Unterstützung geht. Nach der Installation mit npm install —save spdy müssen Sie für die Entwicklung noch ein Zertifikat generieren. Das erreichen Sie am einfachsten, wenn Sie das openssl-Kommandozeilenwerkzeug verwenden.

openssl genrsa -out key.pem 2048
openssl req -new -key key.pem -out client.csr
openssl x509 -req -in client.csr -signkey key.pem -out cert.pem

Das spdy-Paket funktioniert ähnlich wie das http-Modul. Sie binden es ein, definieren ein Options-Objekt, das den Schlüssel und das Zertifikat enthält. Dieses Objekt und eine Referenz auf das app-Objekt Ihrer Express-Applikation übergeben Sie der createServer-Methode des spdy-Pakets. Danach öffnen Sie den TCP-Socket mit der listen-Methode und Ihre Applikation ist betriebsbereit.

Listing 2: HTTP2-Integration

const spdy = require('spdy');
const fs = require('fs');
const express = require('express');
const app = express();

const options = {
    key: fs.readFileSync('./key.pem'),
    cert: fs.readFileSync('./cert.pem')
};

app.get('/', (req, res) => {
    res.end('Hello Express!');
});

spdy.createServer(options, app).listen(8080, () => {
    console.log('Listening to https://localhost:8080');
});

Routing

Einer der wichtigsten Unterschiede zwischen dem http-Modul von Node.js und Express ist das Routing-Feature von Express. Es ermöglicht Ihnen auf einfache Weise, die eingehenden Anfragen zu unterscheiden. Die Routing-Funktionen tragen den Namen der HTTP-Methode, um die sie sich kümmern sollen, also beispielsweise get, post, put oder delete. Eine Ausnahme ist die all-Methode, sie gilt für alle HTTP-Methoden. Das erste Argument der Routing-Funktion ist der URL-Pfad, auf den sie zutrifft. So entspricht die URL https:// localhost:8080/list, die mit der get-Methode aufgerufen wird, der Routing-Funktion app.get(‘/list’, (req, res) => {…}). Bei Bedarf können Sie auch Variablen in Ihrem URL-Pfad definieren. Diese kennzeichnen Sie durch einen Doppelpunkt. Möchten Sie beispielsweise eine Detailseite für die Issue-Anzeige implementieren, könnte der Pfad wie folgt lauten: /issue/:id. In diesem Fall stellt :id die Variable dar. In der Callback-Funktion der Route können Sie über die params-Eigenschaft des Request-Objekts auf alle Variablen zugreifen.

Zusätzlich zu den Routing-Funktionen bietet Ihnen Express die sogenannten Middleware-Funktionen, die Ihnen noch größere Flexibilität im Umgang mit den eingehenden Anfragen erlauben.

Middleware

Der Begriff Middleware bezeichnet in Express eine Funktion, die zwischen der eingehenden Anfrage und der ausgehenden Antwort aufgerufen wird. Die Signatur einer solchen Middleware-Funktion weist drei Parameter auf: die Request- und Response-Objekte und eine Callback-Funktion, die dafür sorgt, dass die nächste Funktion in der Kette aufgerufen wird. Als Best Practice für die Benennung der Callback-Funktion hat sich der Name next etabliert. Dies ermöglicht es Ihnen, pro Anfrage eine Kette von Funktionen auszuführen, von denen sich jede nur auf einen ganz bestimmten Zweck konzentriert.

Listing 3: Einfache Middleware-Funktion

app.use((req, res, next) => {
    console.log('Middleware called: ', req.url);
    next();
});

Dieser modulare Ansatz hat den Vorteil, dass sie solche Middleware-Funktionen für verschiedene Requests wiederverwenden können. Der Ansatz sorgt außerdem dafür, dass Ihre Funktionen kurz und übersichtlich bleiben. Bei der Verwendung von Middleware-Funktionen ist die Reihenfolge wichtig, in der Sie die Funktionen registrieren. Express durchsucht die Funktionen in der Reihenfolge, in der sie registriert wurden, nach Funktionen, die für die aktuelle Route angewendet werden sollen. Das können entweder generelle Middleware-Funktionen mit der use-Methode sein oder bestimmte HTTP-Methoden wie get, post oder put. Wird in der jeweiligen Callback-Funktion nicht die next-Funktion aufgerufen, ist das Ende der Kette erreicht und nachfolgende Funktionen werden nicht mehr aufgerufen.

Anstatt Middleware-Funktionen zu schreiben, werden Sie häufiger auf bereits existierende Middleware zurückgreifen, die Sie als NPM-Paket in Ihrer Applikation installieren und einfach einbinden können. Neben diesen externen Funktionen bringt auch Express einige hilfreiche Middleware-Funktionen mit.

Statische Daten

Eine Express-Applikation kommt in der Regel ohne einen zusätzlichen Webserver wie Apache oder Nginx aus. Zu diesem Zweck muss Ihre Applikation jedoch in der Lage sein, auch statische Inhalte wie HTML-, CSS- und JavaScript-Dateien sowie Bilder und andere Medieninhalte auszuliefern. Theoretisch würde sich das auch mit dem fs-Modul von Node.js umsetzen lassen, indem Sie den Inhalt der angefragten Datei auslesen und in die Antwort an den Client schreiben. Eine viel einfachere und elegantere Lösung besteht jedoch aus der Verwendung der static-Middleware von Express.

Die static-Middleware ist Teil von Express. Das bedeutet, dass Sie kein zusätzliches Paket installieren müssen. Um diese Middleware zu verwenden, verwenden Sie einfach die express.static-Methode, der Sie den Pfad übergeben, in dem die Dateien liegen, die Express ausliefern soll. Auch hier gilt wieder, dass die Reihenfolge der Registrierung eine große Rolle spielt. Wird eine Datei gefunden, die statisch ausgeliefert wird, werden nachfolgende Middleware- und Routing-Funktionen nicht aufgerufen.

Listing 4: static-Middleware

app.use(express.static('public'));

Mit der static-Middleware können Sie nun beispielsweise eine statische Login-Seite für Ihre Applikation ausliefern. Dabei handelt es sich um eine einfache HTML-Seite mit einem Formular, über das ein Benutzer seinen Benutzernamen und sein Passwort eingeben kann. Das Formular wird per HTTP-POST an den URL-Pfad /login gesendet. Die Authentifizierung übernimmt dann Node.js.

Authentifizierung

Auch um Standardaufgaben wie Authentifizierung müssen Sie sich in einer Express-Applikation nicht selbst kümmern. Die am weitesten verbreitete Lösung ist hier Passport, diese Middleware weist eine Plugin-Infrastruktur auf, über die Sie verschiedene Authentifizierungsmechanismen, Strategien genannt, einbinden können. Die verfügbaren Strategien reichen von der einfachen Authentifizierung über Benutzername und Passwort über HTTP Basic und Digest Authentifizierung bis hin zur Anbindung verschiedener Drittanbieter wie Github, Facebook oder Twitter.

Zunächst müssen Sie Passport mit dem Kommando npm install —save passport installieren. Anschließend benötigen Sie noch eine konkrete Strategie. In unserem Fall ist dies die passport-local-Strategie, die Sie mit npm install —save passport-local installieren. Die zugehörigen Typdefinitionen sind @types/passport und @types/passport-local, die Sie ebenfalls über NPM installieren sollten.

Damit die Authentifizierung in Ihrer Applikation korrekt funktionieren kann, müssen Sie noch einige zusätzliche Pakete installieren: den cookie-parser, den body-parser und die express-session-Middleware. Mit diesen Paketen können Cookies und eingehende Anfragen verarbeitet und die Anmeldeinformationen in die Session gespeichert werden.

In Ihrer Applikation konfigurieren Sie Passport, indem Sie der use-Methode des passport-Objekts ein neues Strategy-Objekt übergeben. Der Konstruktor unserer Local-Strategy erhält den Benutzernamen und das Passwort, die der Benutzer an den Server gesendet hat. Das dritte Argument des Konstruktors ist eine Callback-Funktion, die Sie nach erfolgter Überprüfung der Benutzerinformationen aufrufen. Diese Callback-Funktion wird im Erfolgsfall mit dem Wert null und einem Benutzerobjekt aufgerufen. Schlägt die Authentifizierung fehl, rufen Sie die Callback-Funktion mit null und dem Wert false auf. Tritt ein Fehler wie zum Beispiel eine fehlschlagende Verbindung zur Datenbank auf, können Sie die Objektrepräsentation dieses Fehlers als erstes Argument übergeben. Zur Wiederherstellung der Benutzerinformationen bei einem erneuten Besuch muss Passport wissen, wie die Benutzerinformationen serialisiert beziehungsweise deserialisiert werden. Dies geschieht über die Methoden serializeUser und deserializeUser. Die passport.initialize- und passport.session-Middleware-Methoden aktivieren schließlich Passport für Ihre Applikation.

Listing 5: Integration von Passport zur Authentifizierung

const passport = require('passport');
const Strategy = require('passport-local').Strategy;

const User = require('./user').User;

passport.use(new Strategy((username, password, cb) => {
    if (username === 'admin' && password === 'test') {
        const user = new User(1, username, password);
        return cb(null, user);
    }
    return cb(null, false);
}));

passport.serializeUser((user, cb) => cb(null, user.id));
passport.deserializeUser((id, cb) => {
    const user = new User(1, 'admin', 'test');
    cb(null, user);
});

app.use(require('cookie-parser')());
app.use(require('body-parser').urlencoded()); 
app.use(require('express-session')({ secret: 'top secret', resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());

Mit dieser Konfiguration können Sie nun dafür sorgen, dass sich Ihre Benutzer an Ihrer Applikation an- und auch wieder abmelden können.

Für die Anmeldung senden Sie die Daten des Login-Formulars per POST an /login. Damit Sie zusätzliche Funktionen wie beispielsweise Authentifizierung für eine bestimmte Route implementieren können, haben Sie die Möglichkeit, zusätzlich zur normalen Callback- auch eine Middleware-Funktion als zweites Argument zu übergeben. Für den Login-Prozess rufen Sie an dieser Stelle passport.authenticate auf. Um alles weitere kümmert sich dann Passport für Sie.

Nachdem Ihr Benutzer authentifiziert ist, können Sie ihn in der POST-login-Route mit der redirect-Methode des Response-Objekts auf eine beliebige Zielseite weiterleiten. Ab diesem Zeitpunkt haben Sie über die user-Eigenschaft des Request-Objekts auch Zugriff auf die Benutzerinformationen.

Passport erweitert die Features von Express und so lässt sich auch der Logout sehr einfach umsetzen. Sie definieren eine neue Route für den Pfad /logout und rufen in der Callback-Funktion die logout-Methode auf dem Request-Objekt auf. Dadurch wird der Benutzer abgemeldet und Sie können die Anfrage entsprechend umleiten.

Ihre Applikation gliedert sich nun in zwei Bereiche: einen öffentlichen Bereich, der für jeden Benutzer zugänglich ist, und einen geschützten Bereich, auf den nur nach erfolgter Authentifizierung zugegriffen werden darf. Um nun einen bestimmten Pfad in Ihrer Applikation zu schützen, können Sie auf die Middleware des connect-ensure-login-Pakets zurückgreifen.

Datenbankanbindung

In der aktuellen Umsetzung können Sie die Daten Ihrer Benutzer sowie alle übrigen Informationen Ihrer Applikation entweder statisch in den Quellcode-Dateien oder im Arbeitsspeicher vorhalten. Beides sind keine dauerhaften Lösungen. Aus diesem Grund sollten Sie spätestens jetzt über den Einsatz einer Datenbank nachdenken. Node.js selbst unterstützt keinerlei Datenbanken, da dies dem Grundsatz eines schlanken und überschaubaren Kerns der Plattform widersprechen würde. Stattdessen gibt es für nahezu alle Datenbanken Treiber in Form von NPM-Paketen. Für dieses Beispiel verwenden wir die Datenbank SQLite [3]. Natürlich sind Sie nicht auf relationale Datenbanken beschränkt. Auch andere Datenbanken wie Redis oder MongoDB können problemlos angeschlossen werden.

Bevor Sie auf die Datenbank zugreifen können, müssen Sie mit dem Kommando npm install —save sqlite3 den Treiber installieren.

Models und Datentypen

Die Berührungspunkte mit TypeScript waren bisher zugegebenermaßen relativ überschaubar. Bis auf den Zwischenschritt des Kompilierens, die Installation der Typdefinitionen und einer etwas veränderten Modulsyntax haben Sie noch nichts von TypeScript gesehen. Das ändert sich nun, da wir zur Umsetzung der Applikationslogik übergehen. In der Regel definieren Sie für sämtliche Entitäten, die in Ihrer Applikation vorkommen, eigene Datentypen. In unserem Fall sind dies Issues und User. Diese Datentypen legen vor allem die Struktur der Daten fest und können sowohl als Interfaces als auch als Klassen definiert werden. Verwenden Sie Klassen, können Sie diese direkt instanziieren und außerdem Methoden definieren. Bei Interfaces verlassen Sie sich auf das Ducktyping von TypeScript. Das bedeutet, ein Objekt ist ein User, sobald es die erforderlichen Eigenschaften und Methoden aufweist. Sobald ein solcher Datentyp jedoch mehr als eine reine Definition von Eigenschaften ist, sollten Sie auf jeden Fall Klassen verwenden.

Listing 6: Datenklasse

import { User } from './user';

export enum Status {open, inProgress, done};

export class Issue {
    constructor(
        public id?: number,
        public titlte?: string,
        public description?: string,
        public status?: Status,
        public created?: Date,
        public creator?: User,
        public assignee?: User) {}
}

Nun, da Sie die Datenstrukturen definiert haben, geht es im nächsten Schritt darum, diese mit Informationen aus Ihrer Datenbank zu füllen. Zu diesem Zweck kommen Model-Klassen zum Einsatz. Diese erhalten im Konstruktor eine Referenz auf die Datenbankverbindung und führen auf dieser die verschiedenen Lese- und Schreiboperationen durch. Typischerweise verfügt ein solches Model über Methoden zum Auslesen, Erstellen, Modifizieren und Löschen von Datensätzen. Das folgende Listing zeigt ein verkürztes Beispiel des Issue-Models.

Listing 7: Modelklasse

import { Database } from 'sqlite3';
import { Issue } from '../issue';
import { User } from '../user';
import { UserModel } from './user';

export class IssueModel {

    constructor(private db: Database) {}

    fetchOne(id: number): Promise<Issue> {…}
    fetchAll(): Promise<Array<Issue>> {…}
    save(issue: Issue): Issue {…}
    remove(id: number): Promise<boolean> {…}
}

Wichtig bei der Implementierung eines Models ist, dass Sie auf die Escaping-Funktionalität des Datenbank-Treibers zurückgreifen, um keine Datenbank-Injections zuzulassen. Da die Kommunikation zur Datenbank asynchron stattfindet, sind auch die Methoden des Models asynchron. Sie können diese entweder mit Callbacks umsetzen oder, besser noch, auf Promises, Streams oder RxJS zurückgreifen [4]. Im Beispiel verwenden wir Promises.

Template Engines

Da Sie nun sowohl die Datenstrukturen als auch die Kommunikation zur Datenbank umgesetzt haben, können Sie sich um die Anzeige der Inhalte in einer Listendarstellung kümmern. Hier helfen Ihnen statische Dateien wenig. Stattdessen sollten Sie auf eine Template Engine zurückgreifen. Eine Template Engine ermöglicht statische HTML-Templates mit dynamischen Elementen wie Variablen, Schleifen und Bedingungen zu erweitern. Für Node.js existieren eine Reihe von Template Engines wie Handlebars, Pug oder EJS [5]. Sie unterscheiden sich sowohl in ihrer Syntax als auch im Funktionsumfang. Allen drei Engines ist gemeinsam, dass sie sich sehr gut in Express integrieren lassen. Für unsere Beispiel-Applikation kommt EJS zum Einsatz. Diese Template Engine verwendet im Gegensatz zu Pug standardkonformes HTML als Template-Sprache. Außerdem beinhaltet die Engine alle Features, die man für eine Applikation benötigt. Die Installation erfolgt über npm install —save ejs. Eingebunden wird die Template Engine mit einem Aufruf von app.set(‘view engine’, ‘ejs’). Mit dem Setzen der Template Engine wird die render-Methode des Response-Objekts verfügbar. Ihr übergeben Sie das Template, das Sie anzeigen möchten, und ein optionales Objekt mit Werten für die Platzhalter im Template. Die Templates liegen normalerweise im views-Verzeichnis Ihres Projekts.

Zur Auszeichnung von Engine-Elementen wie Variablen, Bedingungen oder Schleifen kommt die <% … %>-Syntax zum Einsatz. Möchten Sie beispielsweise den Inhalt der Variablen user im Template ausgeben, können Sie außerdem auf eine weitere Kurzschreibweise mit dem =-Zeichen zurückgreifen: <%= user %>.

Mit der Kombination aus Datenbankanbindung und Template Engine sind Sie nun in der Lage, die Listendarstellung Ihrer Issues zu erzeugen. Als Einstieg dient die Routing-Funktion für den /list-Pfad. Innerhalb der Callback-Funktion greifen Sie auf Ihr Model zurück und lesen alle Datensätze aus der Datenbank. Anschließend rufen Sie die render-Methode des Response-Objekts mit dem Templatenamen, in diesem Fall list, und einem Objekt mit dem Ergebnis der Datenbankabfrage auf.

Listing 8: Verwendung der render-Methode

app.get('/list',
  require('connect-ensure-login').ensureLoggedIn(),
  function(req, res){
    issueModel.fetchAll().then((issues) => {
      res.render('list', {
        issues: issues
      });
    });
  });

Im Template selbst durchlaufen Sie die Liste der Issues in einer Schleife und geben jeden Datensatz als Tabellenzelle aus.

Listing 9: Template

<table>
    <thead>
        <tr>
            <th>id</th>
            <th>title</th>
            <th>description</th>
            <th>status</th>
            <th>created</th>
            <th>creator</th>
            <th>assignee</th>
            <th>delete</th>
        </tr>
    </thead>
    <tbody>
        <% issues.forEach(function(issue) { %>
            <tr>
                <td><%= issue.id %></td>
                <td><%= issue.title %></td>
                <td><%= issue.description %></td>
                <td><%= issue.status %></td>
                <td><%= issue.created %></td>
                <td><%= issue.creator.username %></td>
                <td><%= issue.assignee.username %></td>
                <td>
                    <a href="/remove/<%= issue.id %>">
                        remove
                    </a>
                </td>
            </tr>
        <% }); %>
    </tbody>
</table>

Löschen von Datensätzen

Nachdem Sie die Löschoperation in Ihrem Datenmodel implementiert haben, ist die Umsetzung in Ihrer Applikation kein Problem. Sie fügen in der Listendarstellung pro Datensatz einen Link auf die URL /remove/ gefolgt von der jeweiligen ID ein. Serverseitig führen Sie die Löschoperation durch und leiten die Anfrage wieder zurück auf die Listenansicht.

Listing 11: Löschen von Datensätzen

app.get('/remove/:id',
require('connect-ensure-login').ensureLoggedIn(),
(req, res) => {
  issueModel.remove(req.params.id).then(() => {
    res.redirect('/list');
  });
});

Anlegen und Aktualisieren

Beim Anlegen eines Datensatzes zeigen Sie für den Pfad /new ein HTML-Formular an, über das der Benutzer seine Eingaben machen kann. Dieses Formular wird per HTTP-POST an den Pfad /save gesendet. Der Benutzer kann die zugeordneten Benutzerkonten für Creator und Assignee per DropDown auswählen. Hierfür müssen Sie die Daten aus der Datenbank auslesen und über eine Schleife im Template die entsprechenden Option-Tags erzeugen. Sie können außerdem festlegen, dass der aktuell angemeldete Benutzer im Creator-DropDown bereits vorausgewählt sein soll. Dies erreichen Sie, indem Sie in der Schleife im Template vergleichen, ob der aktuelle Datensatz die selbe ID aufweist wie der angemeldete Benutzer. Die ID des Benutzers müssen Sie beim render-Aufruf an das Template übergeben.

Zum Aktualisieren eines Datensatzes erzeugen Sie eine Route mit einer Variablen, also beispielsweise /edit/:id. Die übergebene ID verweist auf die ID des zu bearbeitenden Datensatzes. In der Routing-Funktion lesen Sie die Informationen des Datensatzes aus der Datenbank aus und übergeben ihn an das Template. Hier können Sie das Formular-Template von der Erstellung wiederverwenden und einfach erweitern. Sie erweitern das Template um die erforderlichen value-Attribute, denen Sie über die Template-Variablen die Werte aus dem Datensatz zuweisen. Auch die Speicher-Routine bleibt unverändert, da sich das Model um die Unterscheidung zwischen dem Anlegen eines neuen Datensatzes und der Aktualisierung kümmert.

Mit diesen Anpassungen haben Sie eine einfache Node.js-Applikation erzeugt, die Datensätze verwalten kann. Im nächsten Schritt erfahren Sie noch, wie Sie eine grundlegende Absicherung für Ihre Applikation aktivieren können.

Sicherheit

Die Sicherheitsgrundsätze aus der Webentwicklung gelten auch für Node.js-Applikationen: Alle Eingaben, die von außerhalb des Systems stammen, sind nicht vertrauenswürdig. Sie sollten sämtliche Eingaben filtern und alle Ausgaben escapen.

Die Schnittstelle zur Datenbank wird meist durch die Datenbank-Treiber abgesichert. Der eingesetzte SQLite-Treiber kümmert sich beim Einsatz der Placeholder-Schreibweise automatisch um das korrekte Escaping der Queries, sodass Sie vor SQL-Injections geschützt sind. Sie sollten also stets auf dieses Feature der Datenbank-Treiber zurückgreifen und Ihre Queries nicht selbst zusammenbauen.

Noch mehr Sicherheit in Ihrer Applikation erreichen Sie, wenn Sie nur bestimmte Werte als Eingaben zulassen. Beim Anlegen von Datensätzen mit Freitext kann dies natürlich schwierig werden. Hier müssen Sie eine gesunde Balance zwischen Sicherheit und Nutzbarkeit finden.

Ein weiteres Sicherheitselement für Ihre Express-Applikation bietet ein Paket mit dem Namen Helmet. Es sorgt dafür, dass sicherheitsrelevante HTTP-Header gesetzt werden. So aktiviert Helmet beispielsweise standardmäßig einen xssFilter oder versteckt den X-Powered-By Header, der normalerweise von Express gesetzt wird und angibt, welche Technologie serverseitig zum Einsatz kommt.

Installiert wird Helmet mit dem Kommando npm install —save helmet. Die Einbindung in Ihre Applikation findet als Middleware-Funktion statt. Achten Sie darauf, dass Sie Helmet möglichst früh in Ihrer Applikation einbinden, um einen möglichst umfangreichen Schutz zu erreichen. Mit einem app.use(helmet()) aktivieren Sie die Standardmodule des Pakets. Sie können einzelne dieser Module auch ganz einfach deaktivieren, indem Sie Helmet beim Aufruf ein Objekt übergeben. Der Schlüssel bezeichnet das Modul. Geben Sie als Wert false an, wird das Modul deaktiviert. Sie können auch einzelne Module gezielt verwenden, indem Sie beispielsweise app.use(helmet.noCache()) angeben. Dieses Statement deaktiviert das clientseitige Caching.

Logging

Eine weitere, sehr häufig auftretende Aufgabe ist das Aufzeichnen von Anfragen in einer Logdatei. Dies ist vergleichbar mit dem Access-Log eines Apache-Webservers. Im Falle Ihrer Express-Applikation können Sie auf eine Middleware mit dem Namen Morgan zurückgreifen, um ein solches Access-Log für Ihre Applikation zu erzeugen [6]. Zunächst installieren Sie Morgan mit dem Kommando npm install —save morgan. Eingebunden wird Morgan wie schon Helmet als Middleware. Bei der Einbindung können Sie Morgan konfigurieren und zum Beispiel das Format der Logeinträge festlegen oder die Zieldatei angeben.

Listing 10: Logging in der Applikation

const morgan = require('morgan');
const accessLogStream = fs.createWriteStream('logs/access.log', {flags: 'a'});
app.use(morgan('common', {stream: accessLogStream}));

Morgan bringt bereits einige vorgefertigte Logformate mit sich. Übergeben Sie bei der Einbindung beispielsweise die Zeichenkette common, wird das gleiche Logformat wie beim Apache Access-Log verwendet. Auf der Github-Seite von Morgan finden Sie eine Liste der verfügbaren Logformate und eine Anleitung, wie Sie Ihr eigenes Format definieren können. Standardmäßig loggt Morgan auf die Standardausgabe. Um dieses Verhalten anzupassen und stattdessen in eine Datei zu schreiben, erzeugen Sie zunächst einen Writable Stream mit dem fs-Modul von Node.js und übergeben diesen gekapselt in einem Objekt mit dem Schlüssel stream als zweiten Parameter an Morgan.

Fazit

Je nachdem, wie groß der Featureumfang Ihrer Applikation ist, können Sie bei der Entwicklung mit Node.js auf eine Vielzahl von Paketen zurückgreifen. Express.js ist eines der bekanntesten und am weitesten verbreiteten Pakete. Express dient außerdem einigen weiteren Frameworks als Grundlage. Ein Beispiel hierfür ist Sails.js.

Bevor Sie sich also unnötig viel Arbeit machen und eine Problemlösung selbst implementieren, sollten Sie prüfen, ob es nicht schon ein Paket gibt, das genau diese Aufgabe bereits löst. Bei der Auswahl der Pakete sollten Sie darauf achten, dass die Pakete mit Ihrem aktuellen Setup kompatibel sind und aktiv weiterentwickelt werden. Die meisten Pakete sind als Open Source-Projekte auf Github verfügbar, sodass Sie auch einen Blick auf den Projektverlauf, den Umgang mit Bugmeldungen, Regelmäßigkeit der Updates und der Qualität des Quellcodes werfen können.

Das Beispiel in diesem Artikel lässt sich, je nach Anforderungen, nahezu beliebig erweitern. So können Sie beispielsweise ein ORM-System einfügen, das den Zugriff auf die Datenbank abstrahiert und Ihnen weitere Arbeit abnimmt. Auch in Sachen Skalierung haben Sie verschiedene Möglichkeiten. Die einfachste besteht darin, mit pm2 die Applikation lokal zu skalieren. Reicht dies nicht aus, kann eine Node.js-Applikation auch auf mehreren Knoten hinter einem Load Balancer betrieben werden.

Diese Eigenschaften machen Node.js zu einer sehr vielseitigen Plattform für die Entwicklung von serverseitigen Webapplikationen.

Quellen
  1. Paketmanager Yarn
  2. Open Source-Framework Express
  3. Datenbank SQLite
  4. Promises, RxJS
  5. Handlebars, pug, EJS
  6. Github: Morgan
  7. Sails.js

Autor
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben