Les promesses de Javascript Tutoriel

Inria

Apprenez le concept des promesses en Javascript.
Les "promesses" (ou "futures" dans d'autres languages) sont un patron de conception qui fait désormais partie de ES6 (ES2015) ; elles sont basées sur la spécification Promises/A+.

  • Comprendre le fonctionnement des promesses
  • Transfomer une fonction "callback" en object Promise
  • Réaliser des séquences asynchrones avec des promesses

Pour réaliser les exercices, on suppose que vous savez écrire des programmes Javascript et les exécuter dans NodeJS.

L'essentiel sur les promesses

Les promesses représentent un résultat à venir, typiquement lors d'une opération asynchrone telle qu'une requête HTTP, et disposent de méthodes permettant de traiter le résultat une fois l'opération accomplie (then()), ou son échec catch().

new Promise(executor);

L'exécuteur est une fonction à 2 arguments qui sont eux-mêmes des fonctions qui seront appelées une fois la promesse résolue. L'exécuteur est immédiatement appelé, et contient le code susceptible de déclencher l'opération asynchrone.

new Promise(function(resolve,reject) {
    // launch some async task
});

Lorsque l'opération asynchrone est terminée, l'exécuteur doit appeler la fonction resolve(value) avec le résultat, ou la fonction reject(error) avec les informations sur l'erreur rencontrée.

Dans les 2 cas, la promesse est résolue.

function asyncFunc() {
    return new Promise(
        function(resolve, reject) {
            // ...
            resolve(value); // success
            // ···
            reject(error); // failure
        });
}

L'état initial d'une promesse est "en attente" (pending). Une fois acquitée (settled), l'état de la promesse ne change plus. Peu importe que la méthode then() soit appelée sur une promesse avant ou après qu'elle ne soit résolue, l'effet de cette méthode est d'enregistrer la méthode qui sera exécutée après le succès de la promesse, et non pas de l'exécuter au moment de son appel. Idem pour les erreurs, avec la méthode catch().

asyncFunc()
    .then(value => { /* success */ })
    .catch(error => { /* failure */ });

Info Bonne pratique

Attachez toujours la méthode catch() à vos chaînes de promesses.

Chaque appel à p.then() et p.catch() crée une nouvelle promesse qui bloque sur p jusqu'à sa résolution. Les promesses peuvent être chaînées :

console.log('Start');

var calculate = function(value) {
    return new Promise((resolve, reject) => {
        console.log(`Calculate with ${value}`);
        resolve(value * 2);
    });
};

calculate(1)
    .then(calculate)
    .then(result => result + 1) // ❶
    .then(calculate)            // ❷
    .then(verify);
 
function verify(result) {
    console.log(`Verify that ${result} = 10`);
};

console.log('End');

❶ si une valeur est retournée, la méthode then() suivante est appelée avec cette valeur

❷ si une promesse est retournée, la méthode then() suivante ne sera appelée qu'une fois la promesse résolue et en cas de succès.

Constatez dans les traces d'exécution ci-dessous que l'exécuteur est bien appelé immédiatement, mais que toutes les méthodes chaînées ne sont appelées qu'après la fin du programme principal.

$ node promise-calculate.js 
Start
Calculate with 1
End
Calculate with 2
Calculate with 5
Verify that 10 = 10
Exemple

Une fonction asynchrone traditionnelle utilise comme argument une fonction dite "callback" qui sera appelée une fois l'opération asynchrone terminée :

var fs = require('fs');

fs.readFile('config.json',
    function (error, text) {
        if (error) {
            console.error('Error while reading config file');
        } else {
            try {
                const obj = JSON.parse(text);
                console.log(JSON.stringify(obj, null, 4));
            } catch (e) {
                console.error('Invalid JSON in file');
            }
        }
    }
);

Cette approche souffre de nombreux inconvénients, en particulier la gestion des erreurs qui doit être traitée à différents endroits (if (error) et try {} catch() {}), et l'architecture du code : lorsque les fonctions "à callback" sont imbriquées les unes dans les autres, elles forment une "pyramid of doom" (appelée aussi le "callback hell") difficilement lisible.

La version ci-dessous est une utilisation d'une fonction "promessifiée" dont on voit clairement la finalité, et une meilleure prise en charge des erreurs :

readFilePromisified('config.json')
.then(function (text) {
    const obj = JSON.parse(text);
    console.log(JSON.stringify(obj, null, 4));
})
.catch(function (reason) {
    // File read error or JSON SyntaxError
    console.error('An error occurred', reason);
});

Pour transformer notre fonction "à callback" en promesse, il suffit d'écrire le code suivant :

var fs = require('fs');

function readFilePromisified(filename) {
    return new Promise(
        function (resolve, reject) {
            fs.readFile(filename, { encoding: 'utf8' },
                (error, data) => {
                    if (error) {
                        reject(error);
                    } else {
                        resolve(data);
                    }
                });
        }
    );
}

Autres fonctionnalités

Synchroniser des traitements asynchrones lancés en parallèle :

var p1 = fetch('/users.json');
var p2 = fetch('/articles.json');

Promise.all([p1, p2]).then(function(results) {
    // Both promises done!
})
.catch(function(error) {
    // One or more promises was rejected
});

Traiter le premier résultat obtenu entre plusieurs traitements asynchrones lancés en parallèle :

var p1 = fetch('/data1.json');
var p2 = fetch('/data2.json');

Promise.race([p1, p2]).then(function(results) {
    // As soon as a Promise is fulfilled
});

On dispose d'une valeur mais une promesse est exigée :

return Promise.resolve(val);

Obtenir directement une promesse à l'état rejeté :

return Promise.reject(new Error("Failure"));

Exercices

Vérifiez en premier lieu que NodeJS est installé :

$ node -v
v4.2.4

Ecrivez la fonction delay() :

// Using delay():
delay(5000).then(function () {
    console.log('5 seconds have passed!')
});
Utilisez les timers disponibles dans Javascript avec setTimeout(callback, ms)
Observez comment est utilisée la fonction delay() : puisqu'elle est chaînée à une fonction then(), alors elle doit retourner une Promise.
function delay(ms) {
    return new Promise(function (resolve, reject) {
        // your code here
    });
}
function delay(ms) {
    return new Promise(function (resolve, reject) {
        setTimeout(resolve, ms);
    });
}

Attention de ne pas écrire :

             setTimeout(resolve(), ms);

...car dans ce cas, la promesse est immédiatement résolue et le timer sera sans effet ; ne confondez pas resolve et resolve() : le premier correspond à la référence de la fonction, le second exécute cette fonction.

Ecrivez la fonction timeout(ms, promise) :

timeout(3000, delay(5000))
.then(function () {
    console.log('5 seconds have passed!')
})
.catch(function (reason) {
    console.error('Error or timeout', reason);
});

Vérifiez aussi le cas d'échec avec un appel hors délai : timeout( 8000 , delay(5000))

function timeout(ms, promise) {
    return new Promise(function (resolve, reject) {
        // your code here
    });
}
Cette fois-ci le timer doit déclencher un échec et non pas un succès.
Mais si la promesse est résolue avant que le timer n'arrive à échéance, elle doit déclencher un succès.
function timeout(ms, promise) {
    return new Promise(function (resolve, reject) {
        promise.then(resolve);
        setTimeout(function () {
            reject(new Error(`Timeout after ${ms} ms`));
        }, ms);
    });
}

Fusion de plusieurs sources de données

Teams with people

On dispose du document JSON :

[
    {
        "name": "Java developer team", 
        "from": "2000-01-01",
        "to": "2016-01-01",
        "people": [1, 2, 4]
    },
    {
        "name": "Javascript developer team",
        "from": "2016-01-02",
        "to": null,
        "people": [1, 2, 3]
    },
    {
        "name": "JSON masters", 
        "from": "2010-01-01",
        "to": null,
        "people": [3, 4]
    }
]

Et d'autres sources de données JSON :

{
    "id": 1,
    "name": "Alice",
    "email": "alice@example.com"
}
{
    "id": 2,
    "name": "Bob",
    "email": "bob@example.com"
}
{
    "id": 3,
    "name": "Courtney",
    "email": "courtney@example.com"
}
{
    "id": 4,
    "name": "Daniel",
    "email": "daniel@example.com"
}

Dans un serveur Web, associez le chemin "/teamsWithPeople" à une ressource JSON qui retourne les équipes mais où les ID des membres de l'équipe ont été remplacés par les données appropriées.

Contraintes imposées :

  • parser les données JSON
  • lire les fichiers en mode asynchrone
  • utiliser les promesses
  • ne pas concaténer de chaînes de caractères

Commencez par créer votre projet :

$ npm init -y
$ npm install express --save --save-exact
$ tsd query -r -o -a install express

(si tsd est installé, pour que votre IDE dispose de l'autocomplétion)

Résultat escompté : dans votre navigateur, au lieu de voir les structures "people": [1, 2, 4], vous devez obtenir :
"people": [{
        "id": 1,
        "name": "Alice",
        "email": "alice@example.com"
      }, {
        "id": 2,
        "name": "Bob",
        "email": "bob@example.com"
      },
      {
        "id": 4,
        "name": "Daniel",
        "email": "daniel@example.com"
      }]

Utilisez le squelette suivant :

"use strict";

var fs = require('fs');
var express = require("express");
var app = express();

app.get("/info",function(req,res) {
    res.writeHead(200, {'Content-Type': 'application/json'});
    res.end(fs.readFileSync('package.json'));
});

app.get("/teamsWithPeople",function(req,res) {
    // your code here
});

app.listen(3000);

Vous pouvez créer une fonction utilitaire qui permet de lire un fichier JSON et parse le résultat dans une promesse :

function jsonRead(name, encoding) {
    encoding = encoding || 'UTF-8';
    return new Promise((resolve, reject) => {
        // your code here
    });
}

Voici le code de cette fonction :

function jsonRead(name, encoding) {
    encoding = encoding || 'UTF-8';
    return new Promise((resolve, reject) => {
        fs.readFile(name, encoding,
            (err, data) => {
                if (err) {
                    reject(err);
                } else {
                    try {
                        let json = JSON.parse(data);
                        resolve(json);
                    } catch(err) {
                        reject(err);
                    }
                }
            }
        );
    });
}

Vous pouvez réaliser les opérations suivantes :

app.get("/teamsWithPeople",function(req,res) {
    // your code here :
    // 1-read teams.json
    // 2-for each people in each team
    // 3-read the member and replace the data in the team
    // 4-ensure to write the HTTP response when all data has been read
});

Solution de l'étape 1 :

app.get("/teamsWithPeople",function(req,res) {
    // 1-read teams.json
    jsonRead('data/teams.json')
    .then(teams => {
	    // your code here :
	    // 2-for each people in each team
	    // 3-read the member and replace the data in the team
	    // 4-ensure to write the HTTP response when all data has been read
    }
    );
});

Solution de toutes les étapes :

function jsonRead(name, encoding) {
app.get("/teamsWithPeople",function(req,res) {
    jsonRead('data/teams.json')
    .then(teams => {
        let promises = [];
        console.log(teams.length);
        for (let team of teams) {
            for (let userId of team.people) {
                promises.push(new Promise((resolve, reject) => {
                    let currentTeam = team;
                    jsonRead(`data/people/${userId}.json`)
                    .then(user => {
                        currentTeam.people.push(user);
                        resolve();
                    });
                    team.people = [];
                }));
            }
        }
        Promise.all(promises)
        .then(() => { 
            res.writeHead(200, {'Content-Type': 'application/json'});
            res.end(JSON.stringify(teams));
        }).catch((err) => {
            res.writeHead(500, {'Content-Type': 'text/plain'});
            res.end('Error\n' + err);
        });
        }
    );    
});

Info Solution alternative

Notez que dans ce code, les membres sont directement injectés à la place de leur ID, ce qui ne respecte pas forcément l'ordre d'origine.

Il est possible également de résoudre la promesse en passant les infos de chaque membre en tant que donnée de la promesse.

Adaptez ce programme pour exploiter le résultat donné par resolve(user)

Autres informations

Les promesses sont intégrée au langage Javascript depuis ES2015, et sont utilisables côté serveur ou côté client.

Attention à la compatibilité des navigateurs, un polyfill sera peut-être nécessaire pour les vieux modèles.

L'implémentation des promesses dans Edge est connue pour être désastreuse, il est recommandé d'utiliser une autre implémentation, telle que Bluebird

La librairie Bluebird offre également la possibilité de "promissifier" des fonctions à callback :

var Promise = require('bluebird');
var mongoClient = Promise.promisifyAll(require('mongodb')).MongoClient;

var getAllContent = function() {
    return new Promise(function(resolve) {
        mongoClient.connectAsync('mongodb://localhost:27017/mydb')
            .then(function(db) {
                return db.collection('content').findAsync({})
            })
            .then(function(cursor) {
                return cursor.toArrayAsync();
            })
            .then(function(content) {
                // This is how we return the data
                // to the next .then() call
                resolve(content);
            })
            .catch(function(err) {
                throw err;
            });
    });
};
            

NodeJS dispose d'une mécanique de rattrapage des erreurs supplémentaire, qui peut avoir son utilité si aucune fonction catch() n'est attachée en fin de chaîne :

  • L'événement unhandledRejection sera émis lorsqu'aucune erreur n'est rattrapée.
  • L'événement rejectionHandled sera émis si une erreur est rattrapée.
process.on("unhandledRejection", function(reason, promise) {
    console.log(reason.message); // "Failure !"
});

let rejected = Promise.reject(new Error("Failure !"));

La documentation de référence de MDN.

Events vs Callback vs Promises

Quand favoriser l'usage d'une technique ou d'une autre pour réaliser des opérations asynchrones ?

  • Les callbacks sont à privilégier pour indiquer directement la complétion d'une opération asynchrone.
  • Les promesses sont un remplacement des callbacks lorsque la composition de multiples opérations asynchrone est requise, ou pour faciliter la gestion des erreurs.
  • Les événements sont plus appropriés pour le traitement répété de résultats, contrairement aux promesses qui sont des objets à usage unique.