JavaScript ist eine der meistgenutzten Skriptsprachen der Welt. Es ist plattformunabhängig, schnell und flexibel. Und dennoch hakt es an der ein oder anderen Stelle. Zum Beispiel bei der sauberen Strukturierung des Codes. Mit ein Grund dafür sind die unzählig ineinander verschachtelten Callback-Funktionen.
Nehmen wir folgendes Beispielskript: Es werden zwei API-Requests abgesetzt, wobei der zweite Request auf die Antwort des ersten warten muss, um einen Wert (in diesem Fall die User ID) übergeben zu können. In PHP würde das in etwa so aussehen:
require_once 'vendor/autoload.php'; class Posts { public function load() { $user = $this->getUser(); $this->posts = $this->getPosts($user->userId); } protected function getUser() { $response = (new \GuzzleHttp\Client())->get("https://www.restful.api/v1/user"); return json_decode((string) $response->getBody()); } protected function getPosts($userId) { $response = (new \GuzzleHttp\Client())->get("https://www.restful.api/v1/user/".$userId."/posts"); return json_decode((string) $response->getBody()); } } $postsObject = new Posts(); $postsObject->load(); |
Wenn ich das obige Beispiel nun in JavaScript nachbauen wollte, würde ich das – sofern ich mich noch nie tiefgreifend mit der Sprache befasst hätte – ungefähr so umsetzen:
const request = require("request"); class Posts { load() { let user = this.getUser(); this.posts = this.getPosts(user.userId); } getUser() { let response = request("https://www.restful.api/v1/user"); return JSON.parse(response.body); } getPosts(userId) { let response = request("https://www.restful.api/v1/user/"+userId+"/posts"); return JSON.parse(response.body); } } let postsObject = new Posts(); postsObject.load(); |
Der erfahrene JavaScript-Entwickler wird sofort sehen: Das funktioniert so nicht! Denn: Der Programmablauf bei JavaScript ist zum Teil asynchron. Das heißt, Funktionen können aufgerufen werden, warten aber nicht zwingend darauf, dass die vorherigen Funktionen auch bis zu Ende ausgeführt wurde.
In unserem Beispiel sorgt der Aufruf von request() dafür, dass der weitere Ablauf eben nicht erst auf den Response wartet. Stattdessen geht er direkt weiter zum nächsten Request und übergibt ihm natürlich eine leere User ID bzw. es kommt zu einer Fehlermeldung, dass die Variable User ID nicht existiert.
Um das obige Beispiel so umzubauen, damit es die Funktionalität des PHP-Skripts exakt nachbildet, müssen wir daher Callbacks einsetzen. Das sieht dann so aus:
const request = require("request"); class Posts { load() { let me = this; let user = this.getUser(function(error, response, body) { let user = JSON.parse(body); me.getPosts(user.userId, function(error, response, body) { me.posts = JSON.parse(body); }); }); } getUser(callback) { request("https://www.restful.api/v1/user", callback); } getPosts(userId, callback) { request("https://www.restful.api/v1/user/"+userId+"/posts", callback); } } let postsObject = new Posts(); postsObject.load(); |
Schön ist das nicht mehr. Wir haben es nun mit zwei ineinander verschachtelten Funktionen zu tun, bei denen der Zugriff auf das übergeordnete Objekt so nicht mehr gegeben ist. Eigentlich müsste man die Klasse nun ganz anders aufbauen. Problematisch wird es, wenn noch ein dritter Request dazukäme, der sich auf den Response des zweiten Requests verlässt, was zu einer weiteren Verschachtelung führen würde.
Das Gleiche haben sich 2015 auch die Entwickler der ECMAScript-Spezifikation gedacht und eine Alternative ausgearbeitet: Promises. Damit lässt sich das obige Beispiel zumindest etwas sauberer gestalten. Aussehen würde das dann in etwa so:
const request = require("request-promise"); class Posts { load() { let me = this; this.getUser().then(function(body) { let user = JSON.parse(body); me.getPosts(user.userId).then(function (body) { me.posts = JSON.parse(body); }); }); } getUser() { return request("https://www.restful.api/v1/user"); } getPosts(userId) { return request("https://www.restful.api/v1/user/"+userId+"/posts"); } } let postsObject = new Posts(); postsObject.load(); |
Sieht schon besser aus. Aber so richtig schön ist das immer noch nicht. Dachten sich auch die Entwickler der ECMAScript-Spezifikation und arbeiteten 2017 eine zweite Alternative aus: die async/await-Syntax. Dabei wird die übergeordnete Funktion als „async“ deklariert und die normalerweise asynchron ausgeführte Funktion (in diesem Fall der Request) mit dem Schlüsselwort await versehen.
Bauen wir das Beispiel also erneut um:
const request = require("request-promise"); class Posts { load() { let user = this.getUser(); this.posts = this.getPosts(user.userId); } async getUser() { let body = await request("https://www.restful.api/v1/user"); return JSON.parse(body); } async getPosts(userId) { let body = await request("https://www.restful.api/v1/user/"+userId+"/posts"); return JSON.parse(body); } } let postsObject = new Posts(); postsObject.load(); |
Sehr schön! Genau so sollte es aussehen. Das Problem ist nur: Das funktioniert in der Form gar nicht. Innerhalb der getUser() und getPosts()-Methode wird nun brav gewartet, bis der Request einen Response liefert und gibt diesen auch zurück. Durch die async-Deklaration geht die load()-Methode aber gleich zur getPosts()-Methode, ohne zu warten, bis getUser() fertig ist. Das Problem hat sich also nur verschoben.
Bauen wir das Beispiel noch ein aller letztes Mal um:
const request = require("request-promise"); class Posts { async load() { let body = await request("https://www.restful.api/v1/user"); let user = JSON.parse(body); let body = await request("https://www.restful.api/v1/user/"+user.userId+"/posts"); this.posts = JSON.parse(body); } } let postsObject = new Posts(); postsObject.load(); |
So, jetzt ist die Klasse schön kompakt geworden. Allerdings mussten wir dafür das Prinzip des SoC aufgeben und die Aufteilung in zwei Methoden weglassen. Das mag uns in diesem Beispiel nicht weiter stören, bei komplexeren Anwendungen dürfte das aber schnell ins Gewicht fallen. Die Anwendung wird dann unübersichtlich und für Neueinsteiger schwerer nachvollziehbar.
Man hätte die beiden Methoden auch mit einer eigenen Promise-Anweisung ausstatten können, um das SoC zu bewahren, aber das hätte dann wieder zu einer unschönen Callback-Verschachtelung geführt.
Fazit
JavaScript-Anwendungen zu strukturieren, ist gar nicht so einfach. Zu viele verschachtelte Callbacks machen den Code unleserlich. Mit der Promise-Anweisung lässt sich das Callback-Desaster zwar eindämmen, der große Wurf ist damit aber auch nicht so recht geglückt. JavaScript braucht dringend ein einfaches Mittel, um die asynchrone Ausführung von Funktionen im Einzelfall auszuschalten. Das ist meiner Meinung nach JavaScripts größte Schwäche und einer der Hauptgründe, warum es in den nächsten Jahren weder Java noch Python oder PHP komplett verdrängen wird.