Bisher haben wir uns in diesem Buch hauptsächlich mit synchronen Programmen beschäftigt. Das sind Programme, die Eingaben entgegennehmen, verarbeiten und in einem Durchgang bis zum Ende durchlaufen. Die wirklich interessanten Programme, die Bausteine echter Applikationen, funktionieren dagegen anders. Sie führen Netzwerk-Anfragen durch, interagieren mit Datenbanken und Dateisystemen, reagieren auf Benutzerinteraktionen und lagern CPU-intensive Aufgabe in andere Threads aus. Sie alle verwenden asynchrone API-artige Callbacks, Promises und Streams.
Bei diesen asynchronen Aufgaben zeigt JavaScript seine wahre Stärke und unterscheidet sich von anderen beliebten Multithreading-fähigen Programmiersprachen wie Java und C++. Populäre JavaScript-Engines wie V8 und SpiderMonkey sind schlau genug, mehrere Aufgaben auf einem Thread laufen zu lassen, während andere Aufgaben sich gerade im »Leerlauf« befinden, und schaffen so mit einem Thread, wofür üblicherweise mehrere Threads gebraucht wurden. Diese Eventschleife gilt als Standardmodell für JavaScript-Engines, und wir gehen davon aus, dass auch Sie sie benutzen. Aus Sicht des Endbenutzers macht es keinen Unterschied, ob Sie eine Engine mit einer Eventschleife oder mit einem Multithreading-Ansatz verwenden. Allerdings hat es einen Einfluss darauf, wie ich die Funktionsweise verschiedener Dinge und bestimmte Designentscheidungen erkläre.
Mit dem Eventschleifen-basierten Concurrency-Modell vermeidet JavaScript elegant die üblichen Fallstricke, die bei der Programmierung mit mehreren Threads auftreten können. Außerdem wird der Overhead von synchronisierten Datentypen, Mutexes, Semaphoren und vielen anderen Stichwörtern aus dem Multithreading-Programming vermieden. Und wenn Sie JavaScript trotzdem auf mehrere Threads verteilen, wird nur selten der gleiche Speicher (»shared memory«) verwendet. Typischerweise werden Daten zwischen Threads stattdessen durch Message Passing und Datenserialisierung ausgetauscht. Dieses Design erinnert an Erlang, Aktorensysteme und andere rein funktionale Concurrency-Modelle und macht das Mulithreading in JavaScript quasi narrensicher.
Dennoch sind asynchrone Programme oft schwerer zu verstehen, weil Sie das Programm im Kopf nicht einfach Zeile für Zeile durchgehen können. Sie müssen wissen, wann Sie anhalten und die Ausführung auslagern sollten, und wann Sie weitermachen müssen.
TypeScript stellt uns die Werkzeuge zur Verfügung, asynchrone Programme zu verstehen und nachzuvollziehen: Anhand von Typen können wir den Verlauf asynchroner Vorgänge mitverfolgen. Durch die eingebaute Unterstützung für async/ await können wir bekannte synchrone Denkmuster auf asynchrone Programme anwenden. Außerdem können wir TypeScript benutzen, um strikte Message-Passing-Protokolle zu definieren (das ist viel einfacher, als es hier klingt). Und wenn alles andere fehlschlägt, gibt TypeScript Ihnen notfalls auch eine Nackenmassage, falls der asynchrone Code Ihres Kollegen zu kompliziert wird und Sie spätabends noch mit dem Debugging beschäftigt sind (hinter einem Compiler-Flag, versteht sich).
Bevor wir beginnen, mit asynchronen Programmen zu arbeiten, wollen wir darauf eingehen, wie die asynchrone Programmierung in modernen JavaScript-Engines eigentlich funktioniert. Wie ist es möglich, die Ausführung anzuhalten und wieder anzustoßen, wenn es sich doch nur um einen Thread zu handeln scheint?
Beginnen wir mit einem Beispiel. Wir definieren zwei Timer. Einer läuft nach einer Millisekunde ab, der andere nach zwei.
setTimeout(() => console.info('A'), 1)
setTimeout(() => console.info('B'), 2)
console.info('C')
Was wird jetzt wohl in der Konsole ausgegeben? Ist es A, B, C?
Als JavaScript-Programmierer wissen Sie intuitiv, dass die Antwort nein ist. Die tatsächliche Reihenfolge ist C, A und dann B. Wenn Sie bisher noch nicht mit JavaScript oder TypeScript gearbeitet haben, scheint Ihnen das vermutlich rätselhaft und nicht gerade intuitiv. In der Realität ist die Sache aber ziemlich klar. Es kommt hier einfach nicht das gleiche Concurrency-Modell zum Einsatz wie beispielsweise bei sleep in C oder das Scheduling von Aufgaben in einem anderen Thread in Java.
Von außen betrachtet, simuliert die JavaScript-VM Nebenläufigkeit wie folgt (siehe Abbildung 8-1):
Abbildung 8-1: Die Eventschleife von JavaScript: Was beim Aufruf einer asynchronen API passiert.
Mit diesen Informationen im Hinterkopf können wir uns wieder unserem set Timeout-Beispiel zuwenden. Dabei passiert Folgendes:
Deshalb lautet die Ausgabe auf der Konsole C, A, B und nicht A, B, C. Nachdem wir diesen grundsätzlichen Punkt geklärt haben, können wir endlich darüber reden, wie asynchroner Code sicher typisiert werden kann.
Der Grundbaustein eines asynchronen JavaScript-Programms ist das Callback (oder die Callback-Funktion). Ein Callback ist einfach eine Funktion, die als Argument an eine andere Funktion übergeben wird. Wie in einem synchronen Programm ruft die andere Funktion Ihre Funktion auf, sobald sie mit ihrer Aufgabe (eine Netzwerk-Anfrage etc.) fertig ist. Von asynchronem Code aufgerufene Callbacks sind einfache Funktionen. Aufgrund ihrer Typsignaturen lässt sich nicht herausfinden, ob sie asynchron aufgerufen wurden.
Bei nativen NodeJS-APIs wie fs.readFile (zum asynchronen Lesen aus einer Datei) und dns.resolveCname (zum asynchronen Auflösen von CNAME-Einträgen) ist der erste Parameter für Callbacks per Konvention ein Fehler oder null. Der zweite Parameter ist ein Ergebnis oder null.
Hier die Signatur von readFile:
function readFile(
path: string,
options: {encoding: string, flag?: string},
callback: (err: Error | null, data: string | null) => void
): void
Wie Sie sehen, gibt es weder für den Typ von readFile noch von callback etwas Besonderes: Beide sind reguläre JavaScript-Funktionen. Die Signatur enthält keinerlei Anzeichen dafür, dass readFile asynchron ist. Es ist nicht zu erkennen, dass die Kontrolle direkt nach dem Aufruf von readFile an die folgende Zeile übergeben wird (ohne auf das Ergebnis zu warten).
Um das folgende Beispiel selbst zu testen, müssen Sie zunächst die nötigen Typdeklarationen für NodeJS installieren: npm install @types/node --save-dev Weitere Informationen zu Typdeklarationen von Drittanbietern finden Sie unter »JavaScript, für das es Typdeklarationen auf DefinitelyTyped gibt« auf Seite 249. |
Im folgenden Beispiel schreiben wir ein NodeJS-Programm, das lesend und schreibend auf Ihr Apache-Access-Log zugreift:
import * as fs from 'fs'
// Daten aus dem Access-Log von Apache lesen
fs.readFile(
'/var/log/apache2/access_log',
{encoding: 'utf8'},
(error, data) => {
if (error) {
console.error('error reading!', error)
return
}
console.info('success reading!', data)
}
)
// Gleichzeitig Daten in dasselbe Access-Log schreiben
fs.appendFile(
'/var/log/apache2/access_log',
'New access log entry',
error => {
if (error) {
console.error('error writing!', error)
}
})
Als TypeScript- oder JavaScript-Programmierer, der sich mit den NodeJS-eigenen APIs auskennt und weiß, dass diese asynchron sind, ist Ihnen bekannt, dass die Reihenfolge der API-Aufrufe im Code nicht unbedingt identisch sein muss mit der Reihenfolge, in der die Dateisystem-Operationen tatsächlich ausgeführt werden. Sind sie das nicht, hätten Sie nicht gemerkt, dass sich ein fieser kleiner Bug eingeschlichen hat: Je nachdem, wie beschäftigt das Dateisystem beim Ausführen unseres Codes ist, hat der Aufruf von readFile das Access-Log entweder mit oder ohne die angehängte Zeile zurückgegeben.
Vielleicht wissen Sie aus Erfahrung dass readFile asynchron ist. Vielleicht haben Sie es auch in der NodeJS-Dokumentation gelesen. Oder Sie haben erfahren, dass es folgende NodeJS-Konvention gibt: Ist das letzte Argument einer Funktion 1 eine Funktion 2, die ihrerseits zwei Argumente vom Typ Error | null bzw. T | null übernimmt, dann ist Funktion 1 üblicherweise asynchron. Vielleicht sind Sie aber auch nur schnell zum Nachbarn gegangen, um sich etwas Zucker zu borgen. Beim dazugehörigen Schwätzchen sind Sie dann auf das Thema asynchrone Programmierung in NodeJS gekommen, und Ihr Nachbar hat erzählt, wie er ein ähnliches Problem vor ein paar Monaten gelöst hat.
Wie auch immer, die Typen helfen hier jedenfalls nicht weiter.
Abgesehen davon, dass Typen kein Indikator dafür sind, ob eine Funktion synchron ist oder nicht, lassen sich Callbacks zudem nur schwer verketten und können möglicherweise zu sogenannten »Callback-Pyramiden« führen, wie hier:
async1((err1, res1) => {
if (res1) {
async2(res1, (err2, res2) => {
if (res2) {
async3(res2, (err3, res3) => {
// ...
})
}
})
}
})
Beim Verketten von Operationen soll normalerweise mit dem nächsten Schritt weitergemacht werden, sofern eine Operation erfolgreich war, oder abgebrochen werden, falls es einen Fehler gab. Bei Callbacks müssen Sie das manuell erledigen. Wenn Sie außerdem noch auf synchrone Fehler eingehen wollen (in NodeJS wird bei einem falsch typisierten Argument üblicherweise ein Fehler per throw ausgelöst, anstatt das angegebene Callback mit einem Error-Objekt aufzurufen), kann die ordentliche Verkettung von Callbacks leicht zu Problemen führen.
Dabei ist die Verkettung nur eine der Aufgaben, die über asynchrone Tasks ausgeführt werden kann. Vielleicht wollen Sie bestimmte Funktionen parallel ausführen, um zu sehen, welche zuerst beendet wird, oder sie zu einem Wettrennen antreten lassen und das Ergebnis des Siegers weiterverwenden etc.
Die guten alten Callbacks haben aber auch ihre Grenzen. Ohne weitere Abstraktionen für die Arbeit mit asynchronen Aufgaben kann die Arbeit mit mehreren voneinander abhängigen Callbacks schnell für Unordnung sorgen.
Zur Erinnerung:
Glücklicherweise sind wir nicht die ersten Programmierer, die sich mit solchen Problemen herumschlagen müssen. In diesem Abschnitt entwickeln wir das Konzept der Promises (»Versprechen«). Promises bieten eine weitere Abstraktionsebene für die Komposition und Verkettung asynchroner Aufgaben. Auch wenn Sie schon mit Promises oder Futures gearbeitet haben, kann dieser Abschnitt beim Verständnis ihrer Funktionsweise helfen.
Die meisten modernen JavaScript-Plattformen haben die Unterstützung für Promises bereits an Bord. In diesem Abschnitt entwickeln wir als Übung eine eigene Teil-Implementierung von Promise. In der Praxis sollten Sie besser eine »bordeigene« oder vorprogrammierte Implementierung verwenden. Hier (http://bit.ly/2uMxkk5) können Sie überprüfen, ob die von Ihnen bevorzugte Plattform Promises unterstützt. Unter »lib« auf Seite 258 erfahren Sie außerdem, wie man Polyfills verwendet, falls eine Plattform Promises nicht nativ unterstützt. |
Wir beginnen mit einem Beispiel. Wir verwenden Promise, um einer Datei am Ende etwas anzuhängen und das Ergebnis wieder auszulesen:
function appendAndReadPromise(path: string, data: string): Promise<string> {
return appendPromise(path, data)
.then(() => readPromise(path))
.catch(error => console.error(error))
}
Wie Sie sehen, gibt es hier keine Callback-Pyramide. Wir haben unsere Aufgabe effektiv zu einer einzelnen, leicht verständlichen Kette asynchroner Aufgaben linearisiert. War eine Aufgabe erfolgreich, wird die nächste ausgeführt. Schlägt sie fehl, springen wir in die .catch-Klausel. Bei einer Callback-basierten API sähe das eher so aus:
function appendAndRead(
path: string,
data: string
cb: (error: Error | null, result: string | null) => void
) {
appendFile(path, data, error => {
if (error) {
return cb(error, null)
}
readFile(path, (error, result) => {
if (error) {
return cb(error, null)
}
cb(null, result)
})
})
}
Im nächsten Schritt entwickeln wir eine Promise-API, mit der das möglich ist.
Selbst Promise fängt klein und bescheiden an:
class Promise {
}
Ein neues (new) Promise übernimmt eine Funktion, die wir als Executor (das ist keine Metal-Band) bezeichnen. Sie wird von der Promise-Implementierung mit zwei Argumenten aufgerufen: einer resolve-Funktion und einer reject-Funktion:
type Executor = (
resolve: Function,
reject: Function
) => void
class Promise {
constructor(f: Executor) {}
}
Wie funktionieren resolve und reject? Das zeigen wir, indem wir eine Callback-basierte NodeJS-API wie fs.readFile mit einem Promise-basierten API-Wrapper umgeben. Die NodeJS-eigene API fs.readFile wird verwendet, wie hier gezeigt:
import {readFile} from 'fs'
readFile(path, (error, result) => {
// ...
})
Wenn wir die API mit unserer Promise-Implementierung umgeben, sieht das so aus:
import {readFile} from 'fs'
function readFilePromise(path: string): Promise<string> {
return new Promise((resolve, reject) => {
readFile(path, (error, result) => {
if (error) {
reject(error)
} else {
resolve(result)
}
})
})
}
Der Parametertyp von resolve hängt also davon ab, welche spezifische API wir benutzen (in diesem Fall entspricht er dem Typ von result). Der Parametertyp von reject ist grundsätzlich eine Form von Error. In unserer Implementierung können wir die unsicheren Function-Typen jetzt durch spezifischere Typen ersetzen:
type Executor<T, E extends Error> = (
resolve: (result: T) => void,
reject: (error: E) => void
) => void
// ...
Da ein Blick auf Promise ausreichen soll, um herauszufinden, zu welchem Typ es aufgelöst wird (z.B. steht Promise<number>für einen asynchronen Task, dessen Ergebnis den Typ number hat), machen wir Promise generisch und übergeben seinen Parametertyp an den Executor-Typ in seinem Konstruktor:
// ...
class Promise<T, E extends Error> {
constructor(f: Executor<T, E>) {}
}
Bisher haben wir die Konstruktor-API für Promise definiert und verstehen, welche Typen hier eine Rolle spielen. Denken wir als Nächstes über die Verkettung nach. Welchen Operationen soll es möglich sein, eine Reihe von Promises auszuführen, ihre Ergebnisse weiterzugeben und Ausnahmen abzufangen? Wie im ursprünglichen Codebeispiel am Anfang dieses Abschnitts zu sehen, sind then und catch hierfür die richtigen Kandidaten. Also fügen wir sie unserem Promise-Typ hinzu:
// ...
class Promise<T, E extends Error> {
constructor(f: Executor<T, E>) {}
then<U, F extends Error>(g: (result: T) => Promise<U, F>): Promise<U, F>
catch<U, F extends Error>(g: (error: E) => Promise<U, F>): Promise<U, F>
}
then und catch sind zwei Möglichkeiten, Promises zu verketten: then bildet das erfolgreiche Ergebnis eines Promise auf ein neues Promise ab2, und catch fängt einen möglichen Fehler ab, indem es ihn auf ein neues Promise abbildet.
Die Verwendung von then sieht so aus:
let a: () => Promise<string, TypeError> = // ...
let b: (s: string) => Promise<number, never> = // ...
let c: () => Promise<boolean, RangeError> = // ...
a()
.then(b)
.catch(e => c()) // b won't error, so this is if a errors
.then(result => console.info('Done', result))
.catch(e => console.error('Error', e))
Da das zweite Arguments von b den Typ never hat (d.h., b löst niemals einen Fehler aus), wird die erste catch-Klausel nur aufgerufen, wenn a fehlerhaft ist. Bei der Verwendung eines Promise müssen wir uns keine Gedanken darum machen, dass a einen Fehler auslösen könnte, aber b nicht: Ist a erfolgreich, bilden wir das Promise auf b ab. Ansonsten springen wir zur ersten catch-Klausel und bilden das Promise auf c ab. Ist c erfolgreich, protokollieren wir Done. Wird es abgelehnt (reject), benutzen wir ein weiteres catch. Dieses Vorgehen entspricht dem guten alten try/ catch bei synchronen Aufgaben (siehe Abbildung 8-2).
Abbildung 8-2: Die Promise-Zustandsmaschine
Außerdem brauchen wir eine Vorgehensweise für Promises, die tatsächlich Ausnahmen auslösen (wie bei throw Error('foo')). Das lösen wir bei der Implementierung von then und catch, indem Code mit try/catch-Blöcken umgeben wird und bei Bedarf in der catch-Klausel ablehnen. Daraus folgt allerdings:
Also lockern wir unsere Definition von Promise ein wenig und verzichten auf die Typisierung von Fehlern:
type Executor<T> = (
resolve: (result: T) => void,
reject: (error: unknown) => void
) => void
class Promise<T> {
constructor(f: Executor<T>) {}
then<U>(g: (result: T) => Promise<U>): Promise<U> {
// ...
}
catch<U>(g: (error: unknown) => Promise<U>): Promise<U> {
// ...
}
}
Und damit haben wir ein ausgewachsenes Promise-Interface.
Ich überlasse es Ihnen als Übung, die Einzelteile mit Implementierungen für then und catch zu verbinden. Es ist ziemlich schwierig, die Implementierung von Promise richtig zu schreiben. Wenn Sie gerade sehr motiviert sind und ein paar Stunden Zeit übrig haben, werfen Sie einen Blick auf die ES2015-Spezifikation (http://bit.ly/2JT3KUh). Dort finden Sie genauere Informationen dazu, wie die Zustandsmaschine von Promise im Einzelnen funktionieren sollte.
Promises bieten eine mächtige Form der Abstraktion für die Arbeit mit asynchronem Code. Sie sind so beliebt, dass es in JavaScript (und damit auch in TypeScript) eine eigene Syntax dafür gibt: async und await. Über diese Syntax können Sie mit asynchronen Operationen genauso arbeiten wie mit synchronen.
Stellen Sie sich await als syntaktischen Zucker für .then auf Sprachebene vor. Wenn Sie per await auf ein Promise warten, müssen Sie dies in einem async-Block tun. Anstelle von .catch können Sie Ihr await mit einem einfachen try/catch-Block umgeben. |
Angenommen, Sie haben das folgende Promise (wir haben finally im vorigen Abschnitt nicht behandelt, es verhält sich aber genau so, wie Sie denken: Es wird ausgeführt, nachdem then und catch eine Chance zur Ausführung hatten):
function getUser() {
getUserID(18)
.then(user => getLocation(user))
.then(location => console.info('got location', location))
.catch(error => console.error(error))
.finally(() => console.info('done getting location'))
}
Um diesen Code für die Verwendung von async und await anzupassen, müssen Sie ihn in eine async-Funktion verpacken und dann per await auf das Ergebnis des Promises warten:
async function getUser() {
try {
let user = await getUserID(18)
let location = await getLocation(user)
console.info('got location', user)
} catch(error) {
console.error(error)
} finally {
console.info('done getting location')
}
}
Da async und await Teile von JavaScript sind, werden wir sie hier nicht weiter behandeln. Sie werden vollständig von TypeScript unterstützt und sind komplett typsicher. Benutzen Sie sie, wann immer Sie mit Promises arbeiten, um verkettete Operationen transparenter zu machen und sich eine Menge thens zu sparen. Weitere Informationen zu async und await finden Sie im MDN (https://mzl.la/2TJLFYt).
Promises eignen sich hervorragend für die Modellierung, Sequenzierung und Komposition zukünftiger Werte. Was machen Sie aber, wenn Sie mehrere Werte haben, die zu verschiedenen Zeiten in der Zukunft zur Verfügung stehen können? Das ist weniger exotisch, als es klingt. Stellen Sie sich das als Bits einer Datei vor, die aus einem Dateisystem gelesen werden, oder als Pixel eines Videos, das vom Netflix-Server über das Internet auf Ihren Laptop gestreamt wird, oder die Tastatureingaben beim Ausfüllen eines Webformulars, ein paar Freunde, die zum Abendessen kommen oder Wahlzettel, die im Laufe eines Tages in einer Urne landen. Auch wenn Sie oberflächlich scheinbar keine Gemeinsamkeiten haben, stellen alle eine Form asynchroner Streams dar: Sie alle sind Listen von Ereignissen, die nach und nach irgendwann in der Zukunft passieren.
Das lässt sich auf verschiedene Weise abbilden. Meistens benutzt man hier einen Event-Emitter (z.B. EventEmitter in NodeJS) oder eine reaktive Programmierbibliothek wie RxJS (https://www.npmjs.com/package/@reactivex/rxjs).3 Der Unterschied zwischen beiden ähnelt dem Unterschied zwischen Callbacks und Promises: Events sind schnell und schlank, während reaktive Programmierbibliotheken vielseitiger sind und die Möglichkeit bieten, Event-Streams zu komponieren und zu sequenzieren.
Im folgenden Abschnitt geht es um Event-Emitter. Weitere Informationen zu reaktiver Programmierung finden Sie jeweils in der Dokumentation zu Ihrer bevorzugten Bibliothek, zum Beispiel RxJS (https://www.npmjs.com/package/@reactivex/rxjs), MostJS (https://github.com/mostjs/core) oder xstream (https://www.npmjs.com/package/xstream).
An der Oberfläche stellen Event-Emitter APIs zur Verfügung, die Events auf einem Kanal senden (»emittieren«) und auf dem Kanal auf eingehende Events »lauschen« können:
interface Emitter {
// Ein Event senden
emit(channel: string, value: unknown): void
// Etwas tun, wenn ein Event gesendet wurde
on(channel: string, f: (value: unknown) => void): void
}
Event-Emitter sind ein in JavaScript beliebtes Entwurfsmuster. Vielleicht sind sie Ihnen bei der Arbeit mit DOM-Events, JQuery-Events oder dem EventEmitter-Modul von NodeJS schon einmal begegnet.
In den meisten Sprachen sind solche Event-Emitter unsicher, weil der Typ von value vom jeweiligen Kanal (channel) abhängt und es in den meisten Sprachen nicht möglich ist, diese Beziehung mit Typen abzubilden. Sofern Ihre Sprache nicht überladene Funktionssignaturen und literale Typen unterstützt, wird es schwierig, zu sagen: »Auf diesem Kanal wird genau dieser Typ von Event gesendet«. Das Problem wird dann oft mit Makros umgangen. Diese erzeugen Methoden, die wiederum Events senden und auf jedem Kanal »lauschen«. In TypeScript können Sie das dagegen natürlich und typsicher über das Typsystem ausdrücken.
Angenommen, wir verwenden den NodeRedis-Client (https://github.com/NodeRedis/node_redis), eine NodeJS-API für die beliebte In-Memory-Datenbank Redis. Dann könnten wir so vorgehen:
import Redis from 'redis'
// Neue Instanz eines Redis-Clients erzeugen
let client = redis.createClient()
// Auf ein paar vom Client gesendete Events lauschen
client.on('ready', () => console.info('Client is ready'))
client.on('error', e => console.error('An error occurred!', e))
client.on('reconnecting', params => console.info('Reconnecting...', params))
Als Programmierer, die die Redis-Bibliothek nutzen, wollen wir wissen, welche Typen von Argumenten wir bei der Verwendung der on-API in unseren Callbacks erwarten können. Da der Typ der Argumente jedoch davon abhängt, auf welchem Kanal Redis »sendet«, reicht ein einzelner Typ hier nicht aus. Wären wir die Autoren der Bibliothek, könnten wir die Sicherheit am einfachsten durch einen überladenen Typ herstellen:
type RedisClient = {
on(event: 'ready', f: () => void): void
on(event: 'error', f: (e: Error) => void): void
on(event: 'reconnecting',
f: (params: {attempt: number, delay: number}) => void): void
}
Das funktioniert schon recht gut, ist aber noch ziemlich wortreich. Versuchen wir, das anhand eines abgebildeten Typs (siehe »Abgebildete Typen« auf Seite 139) auszudrücken, indem wir die Event-Definitionen in einen eigenen Typ namens Events auslagern:
ready: void
error: Error
reconnecting: {attempt: number, delay: number}
}
type RedisClient = {
on<E extends keyof Events>(
event: E,
f: (arg: Events[E]) => void
): void
}
Danach können wir diesen Typ verwenden, um die Node-Redis-Bibliothek besser abzusichern, indem wir beide Methoden, emit und on, so sicher wie möglich typisieren:
// ...
type RedisClient = {
on<E extends keyof Events>(
event: E,
f: (arg: Events[E]) => void
): void
emit<E extends keyof Events>(
event: E,
arg: Events[E]
): void
}
Das Vorgehen, Eventnamen und Argumente in eine eigene Form auszulagern und darüber zu iterieren, um Listener und Emitter zu erzeugen, ist in echtem TypeScript-Code ziemlich verbreitet. Es ist zudem knapp und sehr sicher. Wird ein Emitter auf diese Weise typisiert, sind Tippfehler bei den Schlüsseln, falsche Typisierung von Argumenten oder das Vergessen der Übergabe eines Arguments nicht möglich. Gleichzeitig dient dieses Muster als Dokumentation für Programmierer, die Ihren Code benutzen, weil ihre Codeeditoren ihnen vorschlagen, welche möglichen Events und Parametertypen in den Callbacks des Events zu erwarten sind.
Emitter in freier Wildbahn
Die Verwendung abgebildeter Typen für die Erstellung typsicherer Event-Emitter ist recht beliebt. Auf diese Weise werden in TypeScripts Standardbibliothek beispielsweise die DOM-Events typisiert. WindowEventMap ist eine Abbildung von Eventnamen auf Eventtypen, die von den APIs .addEventListener und .removeEventListener verwendet werden, um bessere und genauere Eventtypen zu erzeugen als der Standard-Eventtyp:
// lib.dom.ts
interface WindowEventMap extends GlobalEventHandlersEventMap {
// ...
contextmenu: PointerEvent
dblclick: MouseEvent
devicelight: DeviceLightEvent
devicemotion: DeviceMotionEvent
deviceorientation: DeviceOrientationEvent
drag: DragEvent
// ...
}
interface Window extends EventTarget, WindowTimers, WindowSessionStorage,
WindowLocalStorage, WindowConsole, GlobalEventHandlers, IDBEnvironment,
WindowBase64, GlobalFetch {
// ...
addEventListener<K extends keyof WindowEventMap>(
type: K,
listener: (this: Window, ev: WindowEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
): void
removeEventListener<K extends keyof WindowEventMap>(
type: K,
listener: (this: Window, ev: WindowEventMap[K]) => any,
options?: boolean | EventListenerOptions
): void
}
Bisher haben wir nur von asynchronen Programmen gesprochen, die auf einem einzelnen CPU-Thread laufen. Zu dieser Klasse von Programmen gehören die meisten JavaScript- und TypeScript-Applikationen. Für sehr CPU-intensive Aufgaben wird manchmal aber echte Parallelität gebraucht. Damit meine ich die Fähigkeit, die Arbeit auf mehrere Threads zu verteilen, um diese schneller ausführen zu können und die Antwortzeiten des Haupt-Threads kurz zu halten. In diesem Abschnitt erforschen wir verschiedene Muster zum Schreiben sicherer paralleler Programme im Browser und auf dem Server.
Web Workers sind ein gut unterstützter Mechanismus, Multithreading im Browser zu realisieren. Hierfür erstellen Sie eine Reihe von Workers, spezielle beschränkte Hintergrund-Threads. Diese werden vom Haupt-JavaScript-Thread »abgezweigt« und können für Dinge verwendet werden, die den Haupt-Thread ansonsten blockieren und die Antwortzeiten der Benutzerschnittstelle zu stark erhöhen würden (z.B. CPU-intensive Aufgaben). Mit Web Workers können Sie Code im Browser mit echter Parallelität ausführen. Während asynchrone APIs wie Promise oder setTimeout den Code in Konkurrenz (oder »nebenläufig«) ausführen, können Web Workers parallel auf einem anderen CPU-Thread laufen. Web Workers können mit nur wenigen Einschränkungen Netzwerkanfragen senden, ins Dateisystem schreiben und vieles mehr.
Da Web Workers eine vom Browser bereitgestellte API sind, haben ihre Entwickler großen Wert auf die Sicherheit gelegt. Damit ist nicht die von uns so geschätzte Typsicherheit gemeint, sondern Speichersicherheit. Jeder, der schon einmal in C, C++, Objective C oder Multithreaded Java programmiert hat, kennt die Fallstricke beim gleichzeitigen Zugriff auf geteilten Speicher. Wenn mehrere Threads den gleichen Speicherbereich lesen und schreiben, kann es schnell zu Problemen durch die Nebenläufigkeit wie Nichtdeterminismus oder Blockaden kommen.
Da Browsercode besonders sicher sein muss, um Browserabstürze und damit eine schlechte Nutzererfahrung zu verhindern, kommt als wichtigstes Kommunikationswerkzeug zwischen dem Haupt-Thread und den Web Workers das Message Passing zum Einsatz.
Um die Beispiele in diesem Abschnitt selbst auszuprobieren, müssen Sie TSC mitteilen, dass Sie diesen Code in einem Browser ausführen wollen. Aktivieren Sie hierfür die dom-Bibliothek in Ihrer tsconfig.json: { "compilerOptions": { "lib": ["dom", "es2015"] } } Für den Code, der in einem Web Worker laufen soll, verwenden Sie außerdem die webworker-Bibliothek: { "compilerOptions": { "lib": ["webworker", "es2015"] } } Wenn Sie eine einzelne tsconfig.json für das Web-Worker-Skript und Ihren Haupt-Thread benutzen, sollten Sie beides gemeinsam aktivieren. |
Die API für das Message Passing funktioniert so: Zuerst zweigen Sie aus einem Thread einen neuen Web Worker ab:
// MainThread.ts
let worker = new Worker('WorkerScript.js')
Dann übergeben Sie Nachrichten (messages) an den Worker:
// MainThread.ts
let worker = new Worker('WorkerScript.js')
worker.postMessage('some data')
Mithilfe der postMessage-API können Sie fast jede Art von Daten an einen anderen Thread übergeben.4
Bevor die Daten an den Worker-Thread übergeben werden, erstellt der Haupt-Thread einen Klon davon.5 Auf der Seite des Web Workers lauschen Sie auf eingehende Evens mit der global verfügbaren onmessage-API:
// WorkerScript.ts
onmessage = e => {
console.log(e.data) // Gibt 'some data' auf der Konsole aus
}
Um in der anderen Richtung zu kommunizieren, also vom Worker zum Haupt-Thread, können Sie das global verfügbare postMessage verwenden, um eine Nachricht an den Haupt-Thread zu senden. Im Haupt-Thread kommt wiederum die globale .onmessage-Methode zum Einsatz, um auf eingehende Nachrichten zu lauschen. Zusammengefasst sieht das jetzt so aus:
// MainThread.ts
let worker = new Worker('WorkerScript.js')
worker.onmessage = e => {
console.log(e.data) // Gibt 'Ack: "some data"' auf der Konsole aus
}
worker.postMessage('some data')
// WorkerScript.ts
onmessage = e => {
console.log(e.data) // Gibt 'some data' auf der Konsole aus
postMessage(Ack: "${e.data}")
}
Diese API hat große Ähnlichkeit mit der Event-Emitter-API aus »Event-Emitter« auf Seite 186. Sie bietet eine einfache Möglichkeit, Nachrichten auszutauschen. Ohne Typisierung wissen wir jedoch nicht, ob wirklich alle möglichen Nachrichten-Typen korrekt behandelt werden.
Da diese API eigentlich auch nur ein Event-Emitter ist, können wir hier die gleichen Techniken zur Typisierung verwenden. Zur Illustration erstellen wir ein einfaches Messaging-Layer für einen Chat-Client, das wir in einem Worker-Thread ausführen wollen. Das Messaging-Layer gibt mögliche Aktualisierungen an den Haupt-Thread zurück. Um das Beispiel einfach zu halten, kümmern wir uns hier nicht um Dinge wie Fehlerbehandlung, Benutzerrechte etc. Wir beginnen mit der Definition einiger ein- und ausgehender Nachrichtentypen (der Haupt-Thread schickt Commands an den Worker-Thread, und dieser schickt Events an den Haupt-Thread zurück).
// MainThread.ts
type Message = string
type ThreadID = number
type UserID = number
type Participants = UserID[]
type Commands = {
sendMessageToThread: [ThreadID, Message]
createThread: [Participants]
addUserToThread: [ThreadID, UserID]
removeUserFromThread: [ThreadID, UserID]
}
type Events = {
receivedMessage: [ThreadID, UserID, Message]
createdThread: [ThreadID, Participants]
addedUserToThread: [ThreadID, UserID]
removedUserFromThread: [ThreadID, UserID]
}
Wie können wir diese Typen auf die Web-Worker-Nachrichten-API anwenden? Am einfachsten scheint es, eine Vereinigungsmenge aller möglichen Nachrichten-Typen zu definieren und diese anhand einer switch-Anweisung für den Typ Message zu unterscheiden.| (Pipe-Zeichen), für Vereinigungstypen Das kann jedoch ziemlich mühsam werden. Für unseren Command-Typ könnte das etwa so aussehen:
// WorkerScript.ts
type Command =
| {type: 'sendMessageToThread', data: [ThreadID, Message]}
| {type: 'createThread', data: [Participants]}
| {type: 'addUserToThread', data: [ThreadID, UserID]}
| {type: 'removeUserFromThread', data: [ThreadID, UserID]}
onmessage = e =>
processCommandFromMainThread(e.data)
function processCommandFromMainThread(
command: Command
) {
switch (command.type) {
case 'sendMessageToThread':
let [threadID, message] = command.data
console.log(message)
// ...
}
}
Diese schlichte Web-Worker-API wollen wir nun hinter einer bekannten Event Emitter-basierten API verstecken. So halten wir die Typen unserer ein- und ausgehenden Nachrichten möglichst kurz.
Wir beginnen mit der Erstellung eines typsicheren Wrappers für die NodeJS-Event Emitter-API (die für Browser über das events-Package (https://www.npmjs.com/package/events) im NPM zur Verfügung steht):
import EventEmitter from 'events'
class SafeEmitter<
Events extends Record<PropertyKey, unknown[]>
> {
private emitter = new EventEmitter
emit<K extends keyof Events>(
channel: K,
...data: Events[K]
) {
return this.emitter.emit(channel, ...data)
}
on<K extends keyof Events>(
channel: K,
listener: (...data: Events[K]) => void
) {
return this.emitter.on(channel, listener)
}
}
Mithilfe von SafeEmitter können wir die für eine sichere Implementierung des Listener-Layers nötige Codemenge deutlich verringern. Auf der Worker-Seite delegieren wir alle onmessage-Aufrufe an unseren Emitter, während Consumer eine bequeme und sichere Listener-API nutzen können:
// WorkerScript.ts
type Commands = {
sendMessageToThread: [ThreadID, Message]
createThread: [Participants]
addUserToThread: [ThreadID, UserID]
removeUserFromThread: [ThreadID, UserID]
}
type Events = {
receivedMessage: [ThreadID, UserID, Message]
createdThread: [ThreadID, Participants]
addedUserToThread: [ThreadID, UserID]
removedUserFromThread: [ThreadID, UserID]
}
// Auf Events aus dem Haupt-Thread lauschen
let commandEmitter = new SafeEmitter <Commands>()
// Events an den Haupt-Thread zurückschicken
let eventEmitter = new SafeEmitter <Events>()
// Eingehende Befehle vom Haupt-Thread anhand unseres
// typsicheren Event-Emitters mit einem Wrapper umgeben
onmessage = command =>
commandEmitter.emit(
command.data.type,
...command.data.data
)
// Auf Events vom Worker lauschen und an den Haupt-Thread zurückschicken
eventEmitter.on('receivedMessage', data =>
postMessage({type: 'receivedMessage', data})
)
eventEmitter.on('createdThread', data =>
postMessage({type: 'createdThread', data})
)
// etc.
// Auf einen sendMessageToThread-Befehl aus dem Haupt-Thread reagieren
commandEmitter.on('sendMessageToThread', (threadID, message) =>
console.log(OK, I will send a message to threadID ${threadID})
)
// Ein Event an den Haupt-Thread zurückschicken
eventEmitter.emit('createdThread', 123, [456, 789])
Auf der anderen Seite können wir ebenfalls eine EventEmitter-basierte API verwenden, um Befehle vom Haupt-Thread an den Worker-Thread zurückzuschicken. Wenn Sie dieses Muster in Ihrem eigenen Code verwenden, sollten Sie vermutlich einen Emitter mit vollem Funktionsumfang verwenden (wie beispielsweise Paolo Fragomenis ausgezeichneten EventEmitter2 (https://www.npmjs.com/package/eventemitter2)), der übrigens auch Wildcard-Listener unterstützt, wodurch Sie nicht für jeden möglichen Eventtyp manuell einen eigenen Listener definieren müssen:
// MainThread.ts
type Commands = {
sendMessageToThread: [ThreadID, Message]
createThread: [Participants]
addUserToThread: [ThreadID, UserID]
removeUserFromThread: [ThreadID, UserID]
}
type Events = {
receivedMessage: [ThreadID, UserID, Message]
createdThread: [ThreadID, Participants]
addedUserToThread: [ThreadID, UserID]
removedUserFromThread: [ThreadID, UserID]
}
let commandEmitter = new SafeEmitter <Commands>()
let eventEmitter = new SafeEmitter <Events>()
let worker = new Worker('WorkerScript.js')
// Auf vom Worker kommende Events lauschen und
// anhand unseres typsicheren Event-Emitters neu versenden
worker.onmessage = event =>
eventEmitter.emit(
event.data.type,
...event.data.data
)
// Auf von diesem Thread verschickte Befehle lauschen und an den Worker weiterleiten
commandEmitter.on('sendMessageToThread', data =>
worker.postMessage({type: 'sendMessageToThread', data})
)
commandEmitter.on('createThread', data= >
worker.postMessage({type: 'createThread', data})
)
// etc.
// Etwas tun, wenn der Worker uns mitteilt, dass ein neuer Thread erzeugt wurde
eventEmitter.on('createdThread', (threadID, participants) =>
console.log('Created a new chat thread!', threadID, participants)
)
// Einen Befehl an den Worker schicken
commandEmitter.emit('createThread', [123, 456])
Das war schon alles! Wir haben einen einfachen, typsicheren Wrapper für die bekannte Event-Emitter-Abstraktion erstellt, den wir in verschiedenen Szenarien einsetzen können. Das können beispielsweise Cursor-Events im Browser sein oder auch die sichere Kommunikation zwischen verschiedenen Threads. Dieses Muster kommt in TypeScript recht oft vor: Selbst wenn etwas unsicher ist, lässt es sich oft mit einer typsicheren API umgeben.
Bisher haben wir uns mit dem Nachrichtenaustausch zwischen zwei Threads beschäftigt. Wie müssen wir diese Technik erweitern, damit ein bestimmter Befehl immer ein bestimmtes Event als Antwort erhält?
Wir wollen jetzt ein einfaches Frage-Antwort-Protokoll erstellen, mit dem wir die Auswertung von Funktionen auf verschiedenen Threads laufen lassen können. Funktionen selbst können nur schwer zwischen Threads ausgetauscht werden. Aber Sie können Funktionen in einem Worker-Thread definieren, ihnen Argumente übergeben und dann die Ergebnisse zurückschicken. Als Beispiel erstellen wir eine Matrizen-Engine, die drei Operationen unterstützt: das Ermitteln der Determinante der Matrix, die Berechnung des Skalarprodukts und die Invertierung der Matrix.
Wir gehen vor wie üblich. Zunächst skizzieren wir die Typen für die drei Operationen:
type Matrix = number[][]
type MatrixProtocol = {
determinant: {
in: [Matrix]
out: number
}
'dot-product': {
in: [Matrix, Matrix]
out: Matrix
}
invert: {
in: [Matrix]
out: Matrix
}
}
Die Matrizen werden in unserem Haupt-Thread definiert. Die Berechnungen werden dagegen in Worker-Threads ausgeführt. Auch hier wollen wir unsichere Operationen (den Austausch unsicherer Nachrichten mit einem Worker) mit einem sicheren Wrapper umgeben, der Consumern eine klar definierte API zur Verfügung stellt. In dieser recht einfachen Implementierung definieren wir zuerst ein schlichtes Frage-Antwort-Protokoll namens Protocol. Zu Beginn gibt es einfach eine Liste der möglichen Operationen inklusive der erwarteten Ein- und Ausgabetypen für einen Worker aus.6 Dann erstellen wir eine generische createProtocol-Funktion, die ein Protocol und einen Dateipfad zu einem Worker übernimmt und eine Funktion zurückgibt. Sie übernimmt einen Befehl (command) in diesem Protokoll und gibt ihrerseits eine finale Funktion zurück, mit der wir den Befehl für einen bestimmten Satz an Argumenten tatsächlich ausführen können. Los geht’s:
type Protocol = {
[command: string]: {
in: unknown[]
out: unknown
}
}
function createProtocol<P extends Protocol>(script: string) {
return <K extends keyof P>(command: K) =>
(...args: P[K]['in']) =>
new Promise<P[K]['out']>((resolve, reject) => {
let worker = new Worker(script)
worker.onerror = reject
worker.onmessage = event => resolve(event.data.data)
worker.postMessage({command, args})
})
}
Jetzt können wir createProtocol mit unserem MatrixProtocol und dem Pfad zu unserem Web-Worker-Skript aufrufen (wir ersparen uns hier die Details und gehen davon aus, dass Sie die Determinantenberechnung in MatrixWorkerScript.ts implementiert haben). Wir erhalten eine Funktion zurück, mit der wir einen bestimmten Befehl des Protokolls ausführen können:
let runWithMatrixProtocol = createProtocol<MatrixProtocol>(
'MatrixWorkerScript.ts'
)
let parallelDeterminant = runWithMatrixProtocol('determinant')
parallelDeterminant([[1, 2], [3, 4]])
.then(determinant =>
console.log(determinant) // -2
)
Ziemlich cool, oder? Wir haben etwas vollkommen Unsicheres – untypisierten Nachrichtenaustausch zwischen Threads – mit einem vollkommen typsicheren Frage-Antwort-Protokoll abstrahiert. Alle ausführbaren Befehle befinden sich an einem Platz (MatrixProtocol), wobei die Kernlogik (createProtocol) von unserer konkreten Protokollimplementierung (runWithMatrixProtocol) getrennt ist.
Sobald Sie zwischen zwei Prozessen kommunizieren müssen – ob auf dem gleichen Rechner oder in einem Netzwerk –, sind typsichere Protokolle ein gutes Mittel, um die Kommunikation abzusichern. Auch wenn dieses Kapitel geholfen hat, ein Verständnis für die Probleme zu bekommen, die mit Protokollen gelöst werden können, greifen Sie für eine tatsächliche Applikation vermutlich besser zu Werkzeugen wie Swagger, gRPC, Thrift oder GraphQL. Einen Überblick finden Sie unter »Typsichere APIs« auf Seite 212.
Um diese Beispiele selbst auszuprobieren, müssen Sie zunächst die Typdeklarationen für NodeJS aus dem NPM installieren: npm install @types/node --save-dev Mehr über die Verwendung von Typdeklarationen finden Sie unter »JavaScript, für das es Typdeklarationen auf DefinitelyTyped gibt« auf Seite 249. |
Typsicherer Parallelismus funktioniert in NodeJS auf die gleiche Weise wie Web-Worker-Threads im Browser (siehe »Typsichere Protokolle« auf Seite 196). Auch wenn die Schicht für den Nachrichtenaustausch an sich unsicher ist, kann sie leicht mit einer typsicheren API umgeben. Die Kindprozess-API von NodeJS sieht so aus:
// MainThread.ts
import {fork} from 'child_process'
let child = fork('./ChildThread.js')
console.info('Child process sent a message', data)
)
child.send({type: 'syn', data: [3]})
In unserem Kind-Thread benutzen wir die process.on-API, um auf Nachrichten vom Haupt-Thread zu lauschen. Um Nachrichten zurückzuschicken, benutzen wir process.send:
// ChildThread.ts
process.on('message', data =>
console.info('Parent process sent a message', data)
)
process.send({type: 'ack', data: [3]})
Die Funktionsweise hat große Ähnlichkeit mit Web-Workers. Daher überlasse ich es Ihnen als Übung, ein typsicheres Protokoll zu implementieren, das die Interprozesskommunikation in NodeJS abstrahiert.
In diesem Kapitel haben wir die Grundlagen von JavaScripts Eventschleife kennengelernt. Danach haben wir uns mit den wichtigsten Bausteinen für asynchronen Code in JavaScript beschäftigt und gesehen, wie diese sicher in TypeScript ausgedrückt werden können: mit Callbacks, Promises, async/await und Event-Emittern. Daraufhin haben wir uns das Multithreading angesehen, den Nachrichtenaustausch zwischen Threads erforscht (im Browser und im Server) und komplette Protokolle für die Kommunikation zwischen Threads erstellt.
Wie in Kapitel 7 bleibt es letztlich Ihnen überlassen, wie Sie die hier vorgestellten Konzepte nutzen:
import {readFile} from 'fs'
let readFilePromise = promisify(readFile)
readFilePromise('./myfile.ts')
.then(result => console.log('success reading file', result.toString()))
.catch(error => console.error('error reading file', error))