JavaScript Promises Revisited - Teil 1

Unter dem Titel "Promises Revisited" wollen wir noch einmal das Fundament gießen für die nachfolgenden Folgen, in denen wir uns eingehend mit asynchronen und synchronen Operationen beschäftigen.

Wir haben die ersten Folgen mit "revisited" ergänzt, da wir in der Vergangenheit schon Webinare zu Promises gehalten haben. Wir sind in jedem Fall der Meinung, dass das Objekt genug spannende Eigenschaften bietet, um eine ganze Reihe von Vorträgen darüber zu halten. 

 

Asynchroner Code

Als JavaScript-Entwickler haben wir alle sicherlich schon einmal ähnlichen Code wie folgt implementiert - oder zumindest gesehen:

 

const requestSuccess = function (cb, scope) {
    cb.call(scope);
};

const requestFailure = function (cb, scope) {
    cb.call(scope);
};


const loadFile = function (cbsuccess, cbfailure) {
    let request = new XMLHttpRequest();

    const cback = function (response) {
        if (response.status == 200) {
            requestSuccess(cbsuccess);
        } else {
            requestFailure(cbfailure);
        }
    }

    request.onload = cback;
}

loadFile(function () {

    console.log(this.getUser(), " has successfullly signed in");

    return this.onLoginSucces();

}, function () {

    console.log(this.getUser(), " has successfullly signed in");

    return this.onLoginFailure();


});

 

Wir haben hier so viele verschiedene Stellen, die wir einem Refactoring unterziehen können, dass wir zunächst gar nicht wissen, womit wir anfangen sollen. Problematisch ist, dass wir mit den Werkzeugen, die uns ECMAScript vor den Promises zur Hand gegeben hat, irgendwann doch wieder zu einer ähnlich strukturierten Lösung kommen, wenn es darum geht, eine Low Level-API, die XMLHttpRequests - oder ganz allgemein asynchrone Operationen ermöglicht - konfigurierbar zu machen. Eine Unterteilung in verschieden Schichten mit Mocks macht das ganze natürlich testbar(er), aber wenn wir Entwickler uns damit zufrieden gegeben hätten, hätte es nicht ECMAScript 2015 gegeben - im allgemeinen Sprachgebrauch auch bekannt als ECMAScript 6 (ES6) - in der Version wurden nämlich Promises eingeführt und uns zur Verfügung gestellt. ES6 stellte die größte Aktualisierung der Sprache seit ES5 (2009) dar und bescherte uns außerdem ein schickeres Klassensystem (goodbye Prototyping!) sowie Arrow Functions und viel schönen Syntax Zucker.

 

Callbacks

Was früher unter dem Begriff Callback-Hölle bekannt und gefürchtet gewesen ist (und einen auch noch heute bei der Durchsicht so mancher alten JavaScript-Sourcen erschaudern lässt), hat die Entwicklung entsprechend schwer gemacht. Um das Ausführungsende eines asynchronen Prozesses irgendwie zu behandeln, mussten wir uns Callbacks bedienen - also Funktionen, die irgendwo im Speicher herumgeistern und auf ihre Ausführung warten. Das ist, um ehrlich zu sein, eigentlich immer noch so. Denn nur, weil ein paar neue Funktionalitäten in der Sprache zu finden sind, heißt das nicht, dass sich die Sprache grundlegend ändert - denn sie muß ja auch noch rückwärtskompatibel sein, gerade im Hinblick auf die Verbreitung von JavaScript.

Der Einsatzzweck von Callbacks ist natürlich nicht ausschließlich auf asynchronen Code begrenzt, da Funktionen - die ja nur den Namen "callback" erhalten, wenn sie beispielsweise als Argument an eine Funktion übergeben um danach von dieser aufgerufen zu werden - selbstverständlich auch im synchronen Kontext verwendet werden können. Zum Beispiel ist die Funktion , die ich

 

[1, 2, 3].map(value => ++value); 

 

übergebe, auch ein "callback".

Wenn man den Begriff einmal wörtlich betrachtet, wird einem Sinn und Zweck dieses Konzeptes in JavaScript auch viel klarer - es findet ein Rückruf statt, auf eine Funktion, sobald diese benötigt wird. Und dieser Rückruf kann eben sowohl im synchronen als auch im asynchronen Kontext verwendet werden - ob das nun ein Rückruf auf den Eintrag in einem Array ist, um diesen Eintrag zu verändern, sobald auf ihn in der Iteration zugegriffen wird, oder eben die Fortsetzung eines Programmes, wenn sich der Server nach einem Request mit einer erfolgreichen oder fehlerhaften Response gemeldet hat.

Higher-Order Functions in JavaScript sind dann Fluch und Segen zugleich. Dadurch, dass eine Variable eine Referenz auf eine Funktion erhalten kann, eine Funktion selber Funktionen als Argumente übergeben bekommen und auch wieder Funktionen in einer Funktion returnen kann, ist ein sehr tiefes, irgendwann völlig undurchdringliches und nicht mehr transparentes Verschachteln von Funktionen möglich. So werden Higher-Order Functions schnell zur Callback-Hölle. Und wer schon einmal das Vergnügen hatte, asynchronen Code mit möglichst vielen undokumentierten Callbacks zu debuggen, der wird sich verzweifelt wie Dante in Vergils Göttlicher Komödie gefühlt haben, als er die Höllenkreise durchschritt. Und hier schließt sich der Kreis (pun intended!).

 

// callbackhell.com
fs.readdir(source, function (err, files) {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)
            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})

 

Promises, promises

Um den gestiegenen Anforderungen, die moderne Entwickler (die sich in modernen Umgebungen bewegen und für diese auch entwickeln möchten) an eine alternde Programmierspache stellen, gerecht zu werden, wurde zur Vereinfachung der Abarbeitung von (a)synchronen Prozessen das Promise-Objekt in die sechste Version von ECMAScript - ES6 - aufgenommen. Das war 2015, und mittlerweile sollte das Konzept zu jeden durchgedrungen sein, wenn auch Anwendung sowie Namensgebung auf den ersten Blick etwas verwirren können.

Grundsätzlich und in erster Linie geht es bei Promises darum, asynchrone Operationen zu steuern und auf ihre zu Durchführungsbeginn offene und unbekannte Ergebnisse  eleganter reagieren zu können, als das bislang im 9. Höllenkreis von gescopten anonymen über Variablen referenzierte und als Argumente an andere Methoden übergebene JavaScript-Funktionen möglich gewesen ist. 

Ein JavaScript-Promise ist also ein Objekt, das nach Definition das Ergebnis der Ausführung eines Sub-Programmes (eine Funktion) repräsentiert und ein Ergebnis eines Programmes hat - das wissen wir seit C und EXIT_SUCCESS und EXIST_FAILURE - im besten Fall höchstens zwei mögliche Zustände - nämlich erfolgreich oder fehlerhaft.   

 

#include<stdio.h>

int main(int argc, char* argv[]) {      

    return 0;
}

 

Kurzum: Ein Promise ermöglicht es uns, auf zwei verschiedene Zustände einer Programmausführung zu reagieren: Einer erfolgreichen, oder einer fehlerhaften/nicht erfolgreichen Ausführung.

Ein recht einfaches Beispiel für ein Programm, das wir manuell in einen erfolgreichen und einen nicht erfolgreichen Zustand führen können, ist folgendes:

 

function equal (x, y) {
    return x === y;
}

equal(1, 1);


equal(2, 1);

 

Über den ersten Aufruf bekommen wir ein true zurück, das wir an dieser Stelle stellvertretend für eine erfolgreiche Programmausführung festgelegt haben. Der zweite Aufruf liefert uns false zurück, die Überprüfung von 2 und 1 auf Gleichheit schlägt fehl, wir würden das im weiteren Verlauf als nicht erolgreiche Programmausführung ansehen, beispielsweise bei der Überprüfung von User-Input (Story: Der User muß seinen Input so lange wiederholen, bis x und y gleich sind).

Ziemlich simpler, synchroner code, den wir allerdings noch etwas verkomplizieren können - und zu Anschauungszwecken verkomplizieren werden. Denn: Können wir das ganze über ein Promise kapseln? Wir haben vorher gesehen, daß ein Promise den Zustand einer Programmausführung repräsentiert. Und da equal() ein Programm ist, machen wir das ganze jetzt einfach mal, wir steuern das ganze über ein Promise. 
Was wir erreichen wollen, ist: Das der Aufruf von equal nicht synchron, sondern asynchron stattfinden kann. Weil? Weil wir vielleicht im späteren Verlauf die Logik der Überprüfung auf Gleichheit als (Mikro-)Service  auf einen externen API-call auslagern wollen. Natürlich macht das in den wenigsten Fällen Sinn, aber wenn wir einen komplexen Algorithmus haben, der vielleicht sogar noch externe Datenquellen anzapfen muss, dann hätte ein externer API-Call natürlich viel eher seine Daseinsberechtigung.
 

 

function equal (x, y) {
    const request = new XMLHttpRequest();

    request.addEventListener("load", () => {/*callback*/}));
    request.open("GET", `http://someurl/api/equal?x=${x}&y=${y}`);
    request.send();
}

 

Bevor wir also wieder die Schuhe schnüren, um die Jenseitswanderung in Richtung Callback-Hölle anzugehen, schauen wir uns mal an, wie wir dieses Programm (also diese Methode) etwas verständlicher implementieren und aufbereiten können, um zu verhindern, das wir uns zu einem späteren Zeitpunkt im Dickicht von anyonymen Methoden verlieren.

 

function equal = (x, y) => {

    const promise = new Promise((resolve, reject) => {
        const request = new XMLHttpRequest();
    
        request.addEventListener("load", (evt) => {
            const responseText = evt.target.responseText;    
        });
        request.open("GET", `http://someurl/api/equal?x=${x}&y=${y}`);
        request.send();    
    };

    return promise;
};

 

Hier passieren jetzt mehrere Dinge auf einmal. Die Schnittstelle von equal() ändert sich - die Methode gibt jetzt keinen booleschen Wert mehr zurück, sondern ein Promise. Das Promise-Objekt selber kapselt die asynchrone Funktionsausführung von dem XMLHttpRequest und bietet uns selber eine Schnittstelle, über die wir den Rückgabewert bzw. das Ergebnis der Operation (die nun auf dem Server liegt) abfragen können und zwar über die Methode then(), die Teil der API des Promise-Objektes ist.

Bevor wir uns then() anschauen, müssen wir aber noch auf eine Besonderheit des Promise-Objektes eingehen. Wie wir jetzt wissen, repräsentiert ein Promise-Objekt den Zustand einer asynchronen Operation - also ob eine Operation erfolgreich oder nicht erfolgreich beendet wurde. Um dieses Signal an den Entwickler bzw. die umliegenden Programme zu senden, bedient sich die Promise-API zweierlei Methoden, die uns als Programmierer zur Verfügung stehen: resolve und reject. Schauen wir uns die Schnittstellendefinition einmal im Detail an:

 

new Promise(executor)

 

Der executor ist die Methode, die den asynchronen Code vorhält und von dem Promise gekapselt wird. Die Schnittstelle schreibt vor, daß diese executor-Methode von außen zwei Methoden übergeben bekommt, nämlich einmal den "resolver", und einmal den "rejector":

 

new Promise(function (resolve, reject) {});

 

Beide Methoden können nach der Durchführung der asynchronen Operation manuell aufgerufen werden und sind nötig, um die erfolgreiche ("resolve") oder eben nicht erolgreiche ("reject") Abarbeitung einer Operation zu signalisieren - das sind quasi unsere Trigger für den success- und failure-callback, die wir im späteren Verlauf als onFullfilled und onRejected`-Methoden kennenlernen werden. Aber der Reihe nach. In unserem Beispiel mit der Methode equal() - die wird natürlich auch als synchrone Implementierung in einem Promise kapseln können - sieht das Ganze wie folgt aus:

 

function equal(x, y) {

    const eq = new Promise(function (resolve, reject) {

        if (x === y) {
            resolve(x); // ruft später bei then() die erste Methode - success/onFullfilled - auf
        } else {
            reject(false); // ruft später bei then() die zweite Methode  - failure/onRejected - auf
        }
    };

    return eq;
}

 

Jetzt liefert uns die equal()-Methode also ein Promise zurück, das intern bei Gleichheit von x und y die resolve()-Methode aufruft - mit x als Argument - und sollten die beiden Werte nicht übereinstimmen, rufen wir reject() auf - mit false als Argument.
Das hilft uns jetzt zunächst einmal nicht weiter, weil wir keine Idee haben, wie wir denn jetzt das Ergebnis des Promise auswerten können. Und da kommt jetzt die Methode then() ins Spiel.

 

p.then(onFulfilled, onRejected);

 

onFullfilled und onRejected sind unsere success- und failure-callbacks, die aufgerufen werden, wenn in unserem Promise der Aufruf zu resolve() bzw. reject() erfolgt. Auf diese Weise können wir die Abarbeitung des Promise also elegant steuern.

 

let res = equal(x, y);
res.then(
	() => console.log("x und y haben denselben Wert"),
    () => console.log("x und y waren nicht gleich")
);

 

Zwei Dinge gilt es zu beachten. Sobald wir ein Promise benutzen, befinden wir uns - auch wenn wir synchronen Code kapseln - in einer asynchronen Umgebung - sprich: Wir geben das Handling einer Funktion an eine Programmausführung ab, die asynchron funktioniert, und das ist an der Stelle wörtlich zu nehmen. Der Zustand des Promise ist solange weder rejected noch fullfilled, bis wir nicht explizit resolve() bzw. reject() aufgerufen haben. Leider bietet uns JavaScript keine Möglichkeit, auf eine State-Property zuzugreifen, die den Zustand des Promises selber repräsentiert. Aber zumindest auf der Konsole können wir ein Promise-Objekt einmal inspizieren und uns die Eigenschaften anzeigen lassen:

 

> new Promise(function(){})

	Promise {}
	__proto__: Promise
	   [[PromiseState]]: "pending"
	   [[PromiseResult]]: undefined

 

Des weiteren ist es für uns an dieser Stelle nicht mehr möglich, die Ausführung des Promises selber zu steuern. Sobald ein Promise Objekt erzeugt wurde, wird auch der gekapselte Code ausgeführt. Eine Unterbrechung des ganzen ist nicht mehr möglich, und ein Promise-Objekt bietet uns auch weder eine Schnittstelle zur Abfrage des Status des Promises an, noch zur dessen Unterbrechung.

 

Kompositionen

Wo bleibt das Versprechen, daß uns Promises vor der Callback-Hölle bewahrt - zumal wir ja gesehen haben, daß wir doch wieder Callbacks benutzen? Nun, zum einen werden wir in der nächsten Folge zwei Schlüsselwörter kennenlernen, die eine noch elegantere Implementierung von Promises erlauben. Zum anderen haben wir allerdings zumindest noch mit Hilfe des Einsatzes von then() die Möglichkeit, Promises zu verketten - denn then() gibt selber ein Promise zurück.

 

let res = equal(x, y);
res.then(
	() => console.log("x und y haben denselben Wert"),
    () => console.log("x und y waren nicht gleich")
    )
    .then(() => console.log("Fertig."));

 

In dem Beispiel wird der zu resolve() bzw. reject() korrespondierende Callback aufgerufen, und danach auf der Konsole ein "Fertig." angezeigt - unabhängig davon, ob das ursprüngliche Promise resolved oder rejected wurde.

 

Parameterpiping

Uns interessiert jetzt an der Stelle noch, wie denn die Parameter weitergegeben werden können, d.h.: Wie komme ich in meinen Callbacks zu resolve() bzw. reject() an Informationen, die von dem ursprünglichen Promise zur Verfügung gestellt werden? Im folgenden Beispiel geht es konkret um den Parameter x:

 

res.then(
	(x) => console.log("x und y haben denselben Wert", x),
    () => console.log("x und y waren nicht gleich")
);

 

Die Argumente, die die onFullfilled`- bzw. onRejected`-callbacks übergeben bekommen, sind die Argumente, die ich dem resolve() bzw. dem reject() übergebe.

 

const p = new Promise( (resolve, reject)=> {
    resolve(1);
});

p.then (
    (result) => console.log(result)
);

 

Wir erzeugen ein Promise, das immer erfüllt wird (weil wir direkt resolve() aufrufen, das wir mit dem Argument 1 füttern). Durch den Aufruf von resolve() wird durch die then()-Methode der onFullfilled-Callback aufgerufen, und dieser enthält in dem Argument result den Wert 1, der an das resolve() weitergegeben wurde. So funktioniert an der Stelle das Durchreichen von Informationen an die Callbacks, die wir in der then()-Methode benutzen, und wir können das Argument natürlich beliebig anpassen, um beispielsweise ein Objekt mit fein granulierteren Informationen an das onFullfilled weiterzureichen oder wir können beliebig viele Argumente an resolve() übergeben, sie werden dann an die Callbacks weitergeleitet.

 

const p = new Promise( (resolve, reject)=> {

resolve({when: new Date(), where: window.location.href});

});

p. then (
(result) => console.log(result)
);

 

Einen kleinen aber feinen Unterschied gibt es an der Stelle noch: Wenn wir eine Komposition benutzen, um mehrere Promises mittels then() zu verketten, dann sind die Rückgabewerte der onFullfilled- bzw. onRejected-Callbacks die Werte, die als Argumente in den Callbacks der folgenden then()-Aufrufe landen:

 

	// Pseudocode:
	resolve(1); 

	then(x => x) // liefert x zurück - 1
    then(value => value + 1) // bekommt 1 in value übergeben und liefert es um 1 inkrementiert zurück
    then(result => console.log(result)) // bekommt 2 übergeben und gibt das Ergebnis der Komposition auf der Konsole aus

 

Ein vollständiges Code-Beispiel sieht wie folgt aus. Hier wird nichts anderes gemacht, als den ursprünglichen Wert 1 in jedem Aufruf von then() um 1 hochzuzählen, so daß am Ende der Operation 5 herauskommt.

 

    const p = new Promise( (resolve, reject)=> {

        resolve(1);

    });

    p.then (
           // 1
        (value) => value + 1
    ).then (
           // 2
        (value) => value + 1
    ).then (
          // 3
        (value) => value + 1
    ).then (
          // 4
        (value) => value + 1
    ).then(
          // 5
        (value) => console.log(value)
    );

 

Thorsten Suckow-Homberg

Full Stack Senior bei eyeworkers
kontakt@eyeworkers.de
+ 49 721 183960