Javascript Promises: Asynchrones Programmieren
In einer modernen Javascript Anwendung läuft der Großteil des Codes asynchron. Wie man asynchrone Programmierung meistert, zeige in diesem Artikel.
Asynchrone Programmierung
Wenn wir mit Javascript asynchronen Code schreiben, verwenden wir zwangsläufig callbacks. Wenn wir mehrere callback Funktionen nacheinander aufrufen entsteht eine Pyramiden artige Struktur in unserem Code, auch bekannt als Pyramid of Doom
Was ist eine "Pyramid of Doom" ?
Dabei handelt es sich um verschachtelte callback Funktionen. Besonders in der asynchronen Programmierung ist das Problem unausweichlich. Solche verschachtelten Funktionen führen zu unleserlichen Code sowie erschwerter Testbarkeit. Ein Promise Objekt ist dazu gedacht, verschachtelten Funktionen entgegenzuwirken.
Promise
Promise ist ein ES6 Feature und daher nicht mit allen Browsern / Laufzeitumgebungen kompatibel. Wer Promises trotzdem nutzen will muss auf externe Bibliotheken zurück greifen. Ich persönliche bevorzuge die Q Library. Wer mag kann auch das Promise Polyfill nutzen. Grundsätzlich, die Idee dahinter ist die selbe.
Anwendungsfall
Wir programmieren einfaches Registrierungsverfahren eines Benutzers mit node.js. Der Ablauf ist wie folgt:
- Wir führen eine Validierung durch,
- verbinden uns mit unserer Datenbank,
- erstellen einen Salt,
- hashen das Passwort,
- speichern neuen Benutzer.
Ich verwende node.js 0.12.2 und folgende packages:
- mongodb
- q
- crypto
Registrationsverfahren mit Callbacks
sign_user_with_pyramid_of_doom veranschaulicht einen typischen Javascript Code. Zugeben die Validierung muss nicht unbedingt einen Callback liefern, aber angenommen wir validieren asynchron.
Nichtdestotrotz müssen wir warten bis wir uns mit der Datenbank verbinden. Danach erstellen wir einen Salt, danach hashen wir unser Passwort, und danch und danach…
Um so tiefer wir verschachteln umso mehr leidet die Code Qualität. Jetzt ist es nur schwer möglich die Funktion zu testen oder zu erweitern.
// User Objekt
var user = {
username: "mmustermann",
password: "123Password",
email: "mmmustermann@web.de"
}
/**
* Eine typische Funktion mit verschachtelten callback Funktionen:
*/
function sign_user_with_pyramid_of_doom(callback){
// wir validieren unsere eingehende daten
validate(user, function(state){
if(!state){
// wir verbinden uns mit unserer Datenbank
MongoClient.connect("mongodb://localhost:27017/myMongoDB", function( err, mongo ){
if(err){
throw err;
}
// datenbank verbindung zwischen speichern.
db = mongo;
// wir erstellen ein salt
crypto.randomBytes(64, function(err, salt){
if(err){
throw err;
}
// salt in ein string umwandeln
salt = salt.toString('base64');
// password salting & hashing
crypto.pbkdf2( user.password, salt, 128, 64, function( err, hash ){
if(err){
throw err;
}
// salt & hash dem User Objekt übergeben
user.salt = salt;
user.password = hash.toString('base64');
// user objekt in der Datenbank speichern
db.collection('users').insertOne(user, function(err, state){
if(err){
throw err;
}
callback(state);
})
});
});
});
}else{
callback(state);
}
});
}
// asynchrone validierung durchführen
function validate(user, callback)
{
//code
state = false;
callback(state)
}
// sign_user_with_pyramid_of_doom aufrufen
sign_user_with_pyramid_of_doom(function(state){
// ende
});
Registrationsverfahren mit Promise(Q Library)
Zuerst erstellen wir für jeden Ablauf eine neue Funktion. Jede Funktion liefert ein Promise Objekt zurück. Da wir jede Funktion separat erstellen, können wir diese auch besser testen (siehe: TDD).
Q.defer()
Wenn wir es mit einer callback Funktion zu tun haben, eignet sich Q.defer(); am besten dafür. Wir verwenden deferred.resolve() bei einer erfolgreichen Operation und bei einem Fehler verwenden wir deferred.reject(). Mt deferred.promise liefern wir ein Promise Objekt zurück.
* die Promise Funktionen
*
* Wir "wrappen" unsere Funktionen in ein Promise Objekt.
*/
function validateUser(user){
// für callback Funktion, bei den wir nicht wissen
// wie lange der Funktionsaufruf dauert, verwenden wir
// Q.defer().
// es stellt uns ein Promise/resolve/reject Object zur verfügung.
var deferred = Q.defer();
// aufruf der eigentlichen Funktion
validate(user, function(state){
if( !state ){
// wenn die Operation erfolgreich verlaufen ist.
// übergeben wir ein resolve.
deferred.resolve(user);
}
/**
* bei einem Fehler erstellen wir unser errorReport.
* und rufen ein reject auf.
*/
errorReport.type = "Validate Error…";
errorReport.message = "ungültige Email Adresse…";
errorReport.detail = "validateUser() Email Adresse…";
deferred.reject(errorReport);
});
// die validateUser liefert ein Promise Objekt zurück
return deferred.promise;
}
function connect2mongodb(){
var deferred = Q.defer();
MongoClient.connect("mongodb://localhost:27017/myMongoDB", function( err, mongo ){
if(err){
errorReport.type = "Database Error…";
errorReport.message = "no connection to mongodb";
errorReport.detail = "connect2mongodb()";
deferred.reject(errorReport);
}
deferred.resolve(db = mongo); // zwischen speicherung des mongo Objekts
});
return deferred.promise;
}
function generateSalt(){
var deferred = Q.defer();
crypto.randomBytes(64, function(err, salt){
if(err){
errorReport.type = "Salt Error…";
errorReport.detail = "generateSalt()";
deferred.reject(errorReport);
}
deferred.resolve(salt.toString('base64'));
});
return deferred.promise;
}
function generateHash(password, salt){
var deferred = Q.defer();
crypto.pbkdf2(password, salt, 128, 64, function( err, hash ){
if(err){
errorReport.type = "Hash Error";
errorReport.detail = "generateHash()";
deferred.reject(errorReport);
}
deferred.resolve(hash.toString('base64'));
});
return deferred.promise;
}
Promise ohne Callback
Wenn wir keine Callback Funktion haben können wir trotzdem eine Funktion in ein Promise Objekt umwandeln und es nahtlos in unseren Ablauf integrieren.
// Promise ohne eine callback Funktion verwenden
function getMongodb(){
return Q( db );
}
deferred.makeNodeResolver()
Bei typischen node.js Callback Funktionen, wie zum Beispiel insertOne(data, function(err, data){ }). Können wir uns resolve und reject sparen. Mit deferred.makeNodeResolver() können wir das ganze vereinfachen. Selbstverständlich funktioniert es auch bei randomBytes() und pbkdf2().
// deferred.makeNodeResolver() bei standard node.js callback Funktionen verwenden
// z.B insertOne(data, function( err, data ){…});
function insertUser(user){
var promise = getMongodb().then(
function(db)
{
var deferred = Q.defer();
db.collection( "users" ).insertOne(user, deferred.makeNodeResolver() );
return deferred.promise;
}
);
return deferred.promise;
}
die then() Funktion
Nachdem wir alle unsere Funktionen erstellt haben. Können wir zum wesentlichen übergehen. Wenn wir jetzt connect2mongodb() aufrufen liefert es uns die then() Funktion. Die then() Funktion erwartet zwei Parameter. Der erste Parameter liefert das resolve() un der der zweite das reject(). Je nachdem ob es einen Fehler gibt oder nicht, wird einer der beiden Funktionen aufgerufen. Als Rückgabewert liefert then() ein Promise, daher können mit return den Promise Objekt überschreiben. Mit .done() beenden wir unsere Kette. Mit den Promise Objekt erzeugen wir eine Art Pipe Struktur.
/**
* promise objekt
*/
function sign_user_with_promise()
{
// start Punkt: wir verbinden uns mit der Datenbank.
connect2mongodb().then(
// Eine verbindung wurde erfolgreich aufgebaut.
// resolve()
function(){
// then() liefert immer
// ein Promise Object zurück
// wir überschreiben diesen mit unseren eigenen.
return validateUser(user);
},
// Datenbank verbindung wurde fehlgeschlagen.
// reject()
function(state)
{
// Hier wird die Kette unterbrochen.
// Wer throw exception verwenden will sollte die
// catch() methode verwenden:
// connect2mongodb().then(function(){…} "<kein reject>" ).catch( function(err) { throw err } );
// => try, catch and throw
}
).then(
// Wenn validateUser abgeschlossen und erfolgreich war
// übergeben wir generateSalt()
function()
{
return generateSalt();
},
// Validierung wurde Fehlgeschlagen
function(state)
{
// Kette wird unterbrochen.
// Hier können wir mit dem Fehler weiter arbeiten.
// z.B res.render('/sign', { error: "Bitte geben Sie…" });
}
).then(
// Nachdem generateSalt() können wir unser Password Hashen
// wir übergeben generateHash() als neues Promise Objekt.
function(salt)
{
user.salt = salt;
return generateHash(user.password, salt);
},
// Fehler bei Saltgenerierung…
function(state)
{
// code
}
).then(
// Nach erfolgreichen Ablauf, erzeugen wir einen neune User.
function(hash)
{
user.password = hash;
return insertUser(user);
},
// Fehler bei Hashen.
function(state)
{
// code
}
).then(
// User wurde erfolgreich gespeichert
function(state)
{
return state
},
// User konnte nicht gespeichert werden
function(state)
{
// Bei einem Datenbank Fehler können wir wieder die catch Methode verwenden.
// then(function(){…} "<kein reject>" ).catch( function(err) { throw err } );
}
).done(); // wir schließen unsere Kette.
}
Die sign_user_with_promise Funktion ist übersichtlicher und strukturierter als sign_user_with_pyramid_of_doom. Jetzt kann man problemlos die Funktion erweitern und testen.
Resolve & Reject nutzen
Wenn wir sign_user_with_promise Funktion ebenfalls in ein Promise Objekt umwandeln können wir resolve und reject auf eine bestimmte Stelle im Code auslagern. Zudem können wir ein Globales Error Objekt definieren um das Error Handling zu erleichtern.
/**
* Nach "return insertUser()" machen wir nicht weiter.
* Da die letzte then() Funktion einen Promise übergiebt,
* können wir diesen ausgeben und in userController() verwenden.
* reject() funktionen können wir alle entfernen. Diese werden auf eine einzige
* Funktion massiert.
*/
// sign_user_with_promise können wir bei bedarf ein User Objekt übergeben
// z.B sign_user_with_promise( user )
function sign_user_with_promise()
{
var promise = connect2mongodb().then(
function(){
return validateUser(user);
}
).then(
function()
{
return generateSalt();
}
).then(
function(salt)
{
user.salt = salt;
return generateHash(user.password, salt);
}
).then(
function(hash)
{
user.password = hash;
return insertUser(user);
}
);
return promise;
}
/**
* Diese Funktion fungiert als Controller.
* wie zum Beispiel bei Express.js:
* app.get('/sign', userController)
* Ich spare mir die res, req parameter!
*/
function userController(){
// wir rufen unsere sign_user_with_promise
// Funktion auf. Da es sich um ein Promise handelt
// können wir die then Funktion aufrufen.
sign_user_with_promise().then(
// Der User wurde erfolgreich angemeldet.
function(){
// set Session…
// z.B res.redirect('/user/mmustermann')
},
// Da wir jetzt alle fehler auf eine
// einzige Funktion auslagern können wir nicht gleich bestimmen
// um welchen fehler es sich handelt.
// Daher habe ich ein "appReport Objekt" erstellt.
function(state){
// state === appReport
// mit state.type kann ich abfragen was genau schiefgelaufen ist.
// oder state direkt im Template ausgeben
// res.render('/sign', state);
}
).done(); // wir schliesen unsere Promise Kette.
}
Error handling mit Q
/**
* mit appReport Objekt können wir unsere
* Fehlerausgabe vereintlichen und auf eine einzige Funktion
* auslagern.
*/
var errorReport = {
type: "",
message: "",
detail: "",
code: 0
}
Alexander Naumov
Contao Freelancer, PHP Entwickler und Web Allrounder