Einführung in die asynchrone JavaScript-Programmierung

Architektur/Methoden  –  8 Kommentare

Ressourcenzugriffe sind zeitaufwendig. Dank asynchroner Programmierung müssen JavaScript und Node.js aber nicht warten, sondern nutzen die Gelegenheit für das Erledigen anderer Aufgaben.

JavaScript behandelt Funktionen als Bürger erster Klasse. Das bedeutet, dass sich Funktionen und Daten identisch verarbeiten lassen: Programmierer können einer Funktion nicht nur Datenwerte wie Zahlen und Zeichenketten übergeben, sondern auch weitere Funktionen. Das Gleiche gilt für Rückgabewerte.

Die Idee für Funktionen, die ebensolche als Parameter erwarten und zurückgeben, entstammt der funktionalen Programmierung. Dort werden derartige Konstrukte als Funktionen höherer Ordnung bezeichnet.

Ein gängiges Beispiel in JavaScript ist in dem Zusammenhang das Verarbeiten aller Elemente eines Arrays. An die Stelle der klassischen Zählschleife tritt ein Aufruf der forEach-Funktion. Sie erwartet eine Funktion als Parameter, die für jeden einzelnen Wert des Arrays aufgerufen wird:

let primes = [ 2, 3, 5, 7, 11 ];

primes.forEach(prime => {
console.log(prime ** 2);
});

// => 4, 9, 25, 49, 121

Aus technischer Sicht handelt es sich bei dem Lambda-Ausdruck

prime => {
console.log(prime ** 2);
}

um einen Callback. Das Konzept ist nicht neu und auch in anderen Sprachen bekannt, unter anderem in C auf Basis von Funktionszeigern und in C#, wo Delegates zum Einsatz kommen.

Gelegentlich stößt man auf die Behauptung, ein Callback sei ein Zeichen für asynchronen Code. Das ist jedoch nicht wahr: Die forEach-Funktion arbeitet synchron, wie der folgende Code beweist:

primes.forEach(prime => {
console.log(prime ** 2);
});

console.log('done');

// => 4, 9, 25, 49, 121, done

Würde der Callback asynchron aufgerufen, hätte die Ausgabe von done vorzeitig erfolgen müssen, beispielsweise so:

// => 4, done, 9, 25, 49, 121

Synchrone und asynchrone Callbacks

Dennoch gibt es auch asynchrone Callbacks, wie ein ebenfalls gängiges Beispiel zeigt:

setTimeout(() => {
console.log('World')
}, 10);

console.log('Hello');

// => Hello, World

Obwohl der Aufruf der Funktion setTimeout vor dem Aufruf der Ausgabe von Hello erfolgt, gibt der Code als Ergebnis erst Hello und danach [/i]World[/i] aus.

Asynchrone Callbacks finden in Node.js vor allem dann Verwendung, wenn ein Zugriff auf eine externe Ressource wie das Dateisystem oder das Netzwerk erfolgt:

const http = require('http');

http.get('http://www.thenativeweb.io', res => {
console.log(res.statusCode); // => 200
});

console.log('Requesting...');

Das Programm gibt zunächst die Meldung Requesting... aus, bevor es den Statuscode des Netzwerkzugriffs abrufen kann. Das Beispiel zeigt daher gut den von Node.js propagierten asynchronen, nichtblockierenden Zugriff auf I/O-Ressourcen.

Was auf den ersten Blick wie eine reine Spitzfindigkeit wirkt, erweist sich bei genauerem Hinsehen als echtes Problem. Fehler in asynchronen Callbacks lassen sich von außen nämlich nicht durch try und catch abfangen und behandeln, wie der folgende Codeausschnitt zeigt:

try {
http.get('http://www.thenativeweb.local', res => {
console.log(res.statusCode);
});
} catch (e) {
console.log('Error', e);
}

// => Unhandled 'error' event
// getaddrinfo ENOTFOUND www.thenativeweb.local www.thenativeweb.local:80

Auch das Verschieben von try und catch in den Callback löst das Problem nicht, da ihn das Programm aufgrund der fehlgeschlagenen Namensauflösung niemals aufrufen könnte.

Lösungsansätze im Vergleich

In Node.js gibt es zwei gängige Verfahren, das Problem zu behandeln. Einige APIs lösen ein error-Ereignis aus, die meisten rufen jedoch den Callback auf, allerdings mit einem Error-Objekt als ersten Parameter. Die Funktion http.get folgt dem ersten Ansatz, weshalb der Abruf der Webseite wie folgt zu implementieren ist:

http.get('http://www.thenativeweb.local', res => {
console.log(res.statusCode);
}).on('error', err => {
console.log('Error', err);
});

// => Error { [Error: getaddrinfo ENOTFOUND www.thenativeweb.local www.thenativeweb.local:80]
// code: 'ENOTFOUND',
// errno: 'ENOTFOUND',
// syscall: 'getaddrinfo',
// hostname: 'www.thenativeweb.local',
// host: 'www.thenativeweb.local',
// port: 80 }

Wesentlich häufiger trifft man allerdings auf Callbacks, die als ersten Parameter einen potenziellen Fehler und als zweiten die eigentlichen Daten erwarten. Ein Beispiel hierfür ist die fs.readFile-Funktion, mit der sich eine Datei vom Dateisystem laden und einlesen lässt:

const fs = require('fs');

fs.readFile('/etc/passwd', (err, data) => {
if (err) {
return console.log('Error', err);
}
console.log(data.toString('utf8'));
});

Wichtig ist es, bei derartigen Funktionen darauf zu achten, den err-Parameter tatsächlich abzufragen und angemessen zu reagieren. Eine fehlende if-Abfrage führt rasch dazu, dass ein aufgetretener Fehler verschluckt wird, was selten dem gewünschten Verhalten entspricht.

Werkzeuge zur Codeanalyse wie ESLint verfügen daher häufig über Regeln, die prüfen, ob das Programm den err-Parameter abfragt. Im Beispiel von ESLint implementiert die Regel handle-callback-err den entsprechenden Mechanismus.

Außerdem ist darauf zu achten, das weitere Ausführen der Funktion im Fehlerfall abzubrechen – beispielsweise mit einer return-Anweisung.