Les promesses en JavaScript

Yet another article about promises… Oui, exactement :)

Pourquoi cet article ?

Parce que je n’ai jamais trouvé que les promesses pouvaient m’apporter quoi que ce soit, qu’async faisait très bien le job et tous les comparatifs qu’on a pu me montrer ne m’ont jamais convaincu.

Néanmons je sentais bien, vu le nombre de mecs beaucoup plus intelligents que moi qui en étaient convaincus, que c’était « mieux ». Mais de là à m’en convaincre… Supposant ne pas être le seul mécréant pour les mêmes raisons, et récemment convaincu, je me devais donc de partager les raisons qui ont fini de me convaincre.

TL;DR : Les promesses ne sont pas (que) du flow control, les réduire à ça les dessert immanquablement face à async & consors. L’isolation des traitements et la gestion d’erreur sont bien plus décisifs.

L’illumination

J’ai eu l’illumination en lisant les exemples (certains sont – comme toujours – de mauvaise foi, mais globalement ils sont très justes et illustrent de vrais avantages) des slides de Domenic Denicola « Promises, Promises ». Oui elles ont un an mais je ne les ai découvertes qu’il y a deux mois… C’est con 😀

Les promesses c’est quoi ?

Il y a un million de tutos sur le sujet, je vous laisse taper sur Google. Je ne vous parlerai pas de la spécification Promise/A+, vous êtes grands :)

Le principe c’est de manipuler la promesse d’une valeur future au lieu de la valeur elle-même. Par exemple quand on va lire un fichier dans Node, au lieu d’attendre que le contenu (de type Buffer) soit disponible, on va directement travailler avec la promesse de ce contenu (de type Promise<Buffer>).

En terme d’implémentation je ne parlerai que de la librairie Q de Kris Kowal.

EDIT : il est important de noter que Q n’est qu’une implémentation parmi d’autres, et on me signale l’existence de l’excellente implémentation Bluebird (beaucoup plus performante, a priori aussi complet, et avec une séduisante option de gestion globale des erreurs non catchées). No panic, ça ne change pas grand-chose car les règles de base relèvent d’une spécification commune, et même les raccourcis utilisés ici (all(), get()…) sont à peu près (…Q.ninvoke et équivalents seront remplacés par Promise.promisify) les mêmes dans les deux librairies.

Le chaînage

Ça c’est la théorie, en pratique l’opération qu’on va faire se limite à appeler la méthode then(onSuccess, onError). Du coup, présenté comme ça on se dit que ça ne change rien : au lieu d’un callback(error, content) ou passe deux callbacks callback(content) et callback(error). Super.

C’est là qu’intervient le chaînage. Le chaînage c’est vraiment cool, l’idée étant que si promise est la promesse de value, alors promise.then(onSuccess) est grosso modo la promesse de onSuccess(value). Premier truc cool, mais ça n’est pas différent de ce qu’on obtient avec async.waterfall().

Imaginons une succession d’appels asynchrones consistant à récupérer un username, sa session, une propriété de cette session, puis une valeur en BDD à partir de propriété :

1
2
3
4
5
6
7
8
9
10
// Async
async.waterfall([
  getUsername, // getUsername = function (cb(err, username))          → void
  getSession,  // getSession  = function (username, cb(err, session)) → void
  getProp,     // getProp     = function (session, cb(err, property)) → void
  getValue     // getValue    = function (property, cb(err, value))   → void
], function (err, value) {
  if (err) return onError(err)
  onSuccess(value)
})
1
2
3
4
5
6
// Q
getUsername()      // getUsername = function ()         → Promise[username]
.then(getSession)  // getSession  = function (username) → Promise[session]
.then(getProp)     // getProp     = function (session)  → Promise[property]
.then(getValue)    // getValue    = function (property) → Promise[value]
.then(onSuccess, onError)

On peut argumenter sur la lisibilité, la quantité de code économisé n’est pas flagrante en tous cas, et le résultat est effectivement le même. Du coup ça n’a pas suffi à me convaincre de me trimballer des objets avec leur état persistent…

Là où ça peut commencer à devenir intéressant, c’est que les callbacks peuvent indifféremment renvoyer la promesse d’une valeur ou cette valeur directement : si onSuccess retourne directement une valeur X on obtiendra une promesse de X, et s’il retourne la promesse d’une valeur X on obtiendra… une promesse de X également (résolution déléguée). Du coup ça marche assez intuitivement :

// Résolution de promesse
promiseOfX
.then(function (X) { return promiseOfY })
.then(function (Y) {})
 
// Ou valeur directe
promiseOfX
.then(function (X) { return Y })
.then(function (Y) {})

Typiquement dans notre exemple, on sent bien que getProp ne va servir qu’à retourner un attribut d’un objet, donc aucun intérêt à faire du faux asynchrone. Comparons les implémentations :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Async operations
function getUsername (cb) {
  …
  cb(null, username)
}
function getSession (username, cb) {
  sessions.get(username, cb)
}
function getProp (session, cb) {
  cb(null, session.property)
}
function getValue (property, cb) {
  db.query('…', cb)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Q operations
function getUsername () {return promiseOfUsername
}
function getSession (username) {
  return Q.ninvoke(sessions, 'get', username)
}
function getProp (session) {
  return session.property
}
function getValue (property) {
  return Q.ninvoke(db, 'query', '…')
}

Ce qu’on observe c’est que getProp est bien plus simple et généralisable (d’ailleurs c’est une opération déjà généralisée dans Q, on peut utiliser promise.get('property') au lieu de promise.then(getProp)).

On observe aussi qu’on « peut » bosser avec les API en continuous passing style, en passant par les fonctions de conversion de Q.

Toujours pas l’impression d’avoir un gain vraiment décisif, mais quand-même ça commence à être sympa.

L’isolation des traitements

Ce que j’appelle « isolation des traitements » c’est le fait de pouvoir attacher des transformations à une promesse, me retournant la promesse de la valeur transformée (jusqu’ici tout va bien). On y trouvera vite un intérêt dans le cas de multiples traitements, retournant des données hétérogènes, qu’on souhaite uniformiser pour la suite des traitements. Et c’est une conséquence directe du chaînage.

Pour prendre un exemple à peu près réaliste, supposons qu’on ait 3 sources de données :

  • getFromFile met à disposition un Buffer
  • getFromUrl met à disposition une Response dont l’attribut text est une String
  • getFromDB met à disposition un Array dont on veut le premier élément, un objet ayant un attribut content de type Buffer

Nous on voudrait trois Buffer. Première version avec async :

1
2
3
4
5
6
7
8
9
10
11
async.parallel([
  getFromFile, // function(cb(err, content:Buffer))
  getFromUrl,  // function(cb(err, response:Object[{text:String}]))
  getFromDB    // function(cb(err, rows:Array[Object[{content:Buffer}]]))
], function (err, results) {
  if (err) return onError(err)
  var v1 = results[0]
  var v2 = Buffer(results[1].text)
  var v3 = results[2][0].content
  onSuccess([v1, v2, v3])
})

Comme vous le voyez, c’est au callback final de faire les transformations sur chaque valeur obtenue. C’est ça que j’entends par « isolation des traitements ». Si j’avais voulu « isoler les traitements » donc, voici une deuxième version possible :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
async.parallel([
  getFromFile,
  getFromUrl_Buffer,
  getFromDB_Buffer
], function (err, results) {
  if (err) return onError(err)
  onSuccess(results)
})
 
function getFromUrl_Buffer (cb) {
  getFromUrl(function (err, response) {
    if (err) return cb(err)
    cb(null, Buffer(response.text))
  })
}
 
function getFromDB_Buffer (cb) {
  getFromDB(function (err, rows) {
    if (err) return cb(err)
    cb(null, Buffer(rows[0] && rows[0].content))
  })
}

Le code (enfin, ses 8 premières lignes) est plus clair, mais les implémentations des opérations nécessitent deux choses un peu pénibles : des callbacks, et la transmission de l’erreur à ne pas oublier.

Avec les promesses c’est déjà un peu différent, comme Buffer(string) retourne un Buffer je peux directement l’utiliser : on a déjà bien compris que si promise est la promesse d’une String alors promise.then(Buffer) est la promesse de cette chaîne convertie en Buffer. Du coup je peux chaîner les transformations unitaires, pour obtenir 3 Promise<Buffer> autonomes :

1
2
3
4
5
6
7
8
9
10
11
12
var v1 = getFromFile() // Promise[Buffer]
 
var v2 = getFromUrl() // Promise[Object]
.get('text')          // Promise[String]
.then(Buffer)         // Promise[Buffer]
 
var v3 = getFromDB()  // Promise[Array[Object]]
.get(0)               // Promise[Object]
.get('content')       // Promise[Buffer]
 
Q.all([v1, v2, v3])   // Promise[Array[Buffer]]
.then(onSuccess, onError)

Chacune des trois promesses embarque son propre lot de transformations, jusqu’à ce que j’ai la donnée finale qui m’intéresse. Le code finale est claire, mais en plus l’implémentation de ces transformations est clair. Pourquoi ? Grâce au chaînage et à la possibilité de retourner des valeurs directes, grâce aux outils fournis par Q, mais aussi…

La propagation d’erreur

C’est une des parties « magiques » (mais spécifié, hein :)) des promesses : lorsqu’une promesse est cassée (cas d’erreur) mais qu’aucun callback ne l’intercepte alors elle se propage de then en then jusqu’à interception (ou jusqu’à être ignorée, on a toujours le droit de faire de la merde).

Ça c’est une feature intéressante qui allège vraiment le code : la délégation de la gestion d’erreur est maintenant implicite. Comme tout comportement implicite, il a son côté négatif : si on est inattentif on a plus vite fait d’oublier la possibilité d’une erreur vu qu’elle n’apparaît même pas (au moins dans le CPS on a ce petit premier argument du callback, qui fait que le cas d’erreur vous nargue un peu quand-même). La solution : ne pas être trop con, et penser à gérer les erreurs.

Un petit exemple :

getFromUrl(url)   // Promise[Response], peut échouer (erreur 500, pb de réseau…)
.get('text')      // Promise[String]
.then(JSON.parse) // Promise[Object], peut échouer (JSON invalide)
.then(onSuccess)  // peut échouer aussi après tout :)
.catch(onError)   // attrape la première erreur qui s'est produite dans la chaîne
 
// Note : catch est transparent on peut l'intercaler n'importe où dans la chaîne sans la briser
getFromUrl(url).catch(onHTTPError)
.get('text').then(JSON.parse).catch(onJSONError)
.then(onSuccess).catch(onCallbackError)
 
// Au passage, "catch(fn)" est équivalent à "fail(fn)", qui est équivalent à "then(null, fn)"…
// …qui est équivalent à "then(_.identity, fn)" tout simplement :)

En parlant d’erreur, vous pourriez lire cette page à propose de l’isolation des erreurs dans les promesses.

Convaincu ?

Moi ça y est, j’ai enfin passé le cap intellectuellement, à force de tester j’ai compris l’intérêt. Les inconvénients (cas d’erreur caché, objets à état) sont devenus finalement dans mon esprit moins importants que les avantages.

Et vous ? Pas convaincu ? OSEF, continuez avec async ou équivalent sans honte. C’est une très bonne lib, async.auto est juste magique, et se forcer à utiliser une API parce que d’autres ont dit que c’était mieux, sans en avoir compris soi-même l’intérêt, je ne suis pas sûr que ce soit bien rentable. Jetez un œil à contrλ quand-même, il est vraiment classe même s’il manque auto 😉

Et si vous êtes convaincu, relisez un coup le README de Q, il y a sans doute de petits raccourcis que vous n’aviez pas encore vu 😉 Et consultez une liste d’implémentations de la spécification Promise/A+.

N’hésitez pas à partager vos ressources sur le sujet en commentaire !

11 réflexions au sujet de « Les promesses en JavaScript »

  1. David Larlet

    > il y a sans doute de petits raccourcis que vous n’aviez pas encore vu 😉

    Et qui vont rendre incompatible votre code en cas de changement de lib, après avoir passé quelques jours à m’arracher les cheveux je ne le recommande à personne !

    Répondre
  2. Greg

    Yeah ! PROMISES !!!!
    De mon coté j’utilise bluebird: https://github.com/petkaantonov/bluebird
    C’est **rapide**: http://spion.github.io/posts/why-i-am-switching-to-promises.html
    Bluebird a aussi une feature super intéressante qui gère tout seul les promesse rejetée sans handling. Il y a par défaut un warning et le comportement peut être configuré. Du coup c’est bien plus débutant friendly, si le gars oublie le done() à la fin, il voit quand même l’erreur.

    En bonus, ça marche super bien avec les générateurs (yield).

    Répondre
    1. naholyr Auteur de l’article

      J’aime beaucoup cette option de signalisation d’erreur, ça manque à Q je trouve… Si on pouvait ne serait-ce que lui attacher un logger de manière globale, ou quelque-chose du genre pour détecter ce genre de problème.

      En attendant il doit y avoir du monkey-patching possible pour régler la question (le genre de truc à faire dans les tests unitaires).

      Répondre
    1. naholyr Auteur de l’article

      Merci pour l’info, je ne connaissais pas et il n’est même pas référencé sur promisejs.org ! À tester 😉

      Après lecture de la doc d’API il semble en tous cas très complet. Je vais mettre un mot dans l’article pour les prochains !

      Répondre
      1. mrsup

        C’est cette lib qui m’a décidé à passer aux promises, puisque c’est à ma connaissance la seule à avoir des performances proches des callbacks (à base d’astuces sioux mais ça marche). A la limite côté browser, on s’en fout, sauf si les appels aux promises sont très fréquents, mais dans node ça pardonne pas d’utiliser Q.

        Répondre
        1. naholyr Auteur de l’article

          Cette question des performances m’a toujours un peu gêné… mais en même temps comme à côté j’utilise MongoDB je suis pas à ça près 😀

          (blague gratuite, il est évident que du temps bloquant est infiniment plus gênant qu’une BDD lente)

          Répondre

Laisser un commentaire