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+.
Pour réaliser les exercices, on suppose que vous savez écrire des programmes Javascript et les exécuter dans NodeJS.
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 */ });
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
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); } }); } ); }
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"));
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!') });
setTimeout(callback, ms)
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 }); }
function timeout(ms, promise) { return new Promise(function (resolve, reject) { promise.then(resolve); setTimeout(function () { reject(new Error(`Timeout after ${ms} ms`)); }, ms); }); }
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 :
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)
"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); }); } ); });
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)
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 :
unhandledRejection
sera émis lorsqu'aucune erreur
n'est rattrapée.
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.
Quand favoriser l'usage d'une technique ou d'une autre pour réaliser des opérations asynchrones ?