Bonnes pratiques pour gérer son code asynchrone en Javascript

Comme on l’a déjà vu, en Javascript, et principalement avec Node.JS, les appels à des méthodes non bloquantes exécutant leur traitement de manière asynchrone permet de faire du multi-tâche de manière très simple et performante. Néanmoins, un piège dans lequel on tombe rapidement est la cascade d’appels asynchrones.

On va prendre deux exemples, et voir comment les rendres plus lisibles dans la suite de cet article :)

Problème classique #1: Gérer une cascade d’appels

Prenons un programme qui lit deux fichiers A et B, concatène leur contenu, et écrit le résultat dans un fichier C. En cas d’erreur dans le process, une exception est levée.

On peut, avec Node.JS, l’écrire en mode synchrone:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {
  // Lecture fichierA
  var contenuA = fs.readFileSync(fichierA); // peut lever une exception
  // Lecture fichierB
  var contenuB = fs.readFileSync(fichierB); // peut lever une exception
  // Concaténation
  var contenuC = contenuA + '\n' + contenuB;
  // Écriture fichierC
  fs.writeFileSync(fichierC, contenuC); // peut lever une exception
  // Succès :)
  console.log('OK');
} catch (err) {
  // Erreur
  console.log('FAIL: ' + err.message);
}

Classique, simple. Néanmoins tous ces appels sont bloquants. Ça signifie concrètement que si ce type de code se trouve dans une application web, comme on n’est ni multi-thread (à moins qu’on n’ait créé un worker pour ça) ni en mode asynchrone, tout le processus attend les réponses de chaque méthode, au lieu de se concentrer sur les nouvelles requêtes entrantes. Ça aurait un effet négatif sur les performances de l’application.

On va donc écrire le même programme en mode asynchrone:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Lecture fichierA
fs.readFile(fichierA, function(err, contenuA) {
  if (err) {
    // Erreur
    console.log('FAIL: ' + err.message);
  } else {
    // Lecture fichierB
    fs.readFile(fichierB, function(err, contenuB) {
      if (err) {
        // Erreur
        console.log('FAIL: ' + err.message);
      } else {
        // Concaténation
        var contenuC = contenuA + '\n' + contenuB;
        // Écriture fichierC
        fs.writeFile(fichierC, contenuC, function(err) {
          if (err) {
            // Erreur
            console.log('FAIL: ' + err.message)
          } else {
            // Succès :)
            console.log('OK');
          }
        });
      }
    });
  }
});

Au secours! C’est devenu quasiment illisible, on est bien d’accord… Pour exactement la même liste d’opérations, on a 2 fois plus de lignes, et surtout on arrive à 6 niveaux d’imbrication contre… 1. Si on doit sacrifier la lisibilité sur l’autel des performances, il est clair qu’on ne va pas être d’accord. La maintenabilité est pour moi un critère aussi important sinon plus que les performances brutes. Il faut donc réécrire ce code.

Best practice #1: declare your callbacks.

Déclarez vos fonctions de retour d’appel asynchrones, vos « callbacks ». Au lieu de les déclarer inline, et d’augmenter ainsi artificiellement l’imbrication. En fait, on pourrait même appeler ça de la factorisation ;)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Lecture fichierA
fs.readFile(fichierA, onReadFileA);
// Fin lecture fichierA
function onReadFileA(err, contenuA) {
  if (err) return onError(err);
  // Lecture fichierB
  fs.readFile(fichierB, function(err, contenuB) { onReadFileB(err, contenuA, contenuB); });
}
// Fin lecture fichierB
function onReadFileB(err, contenuA, contenuB) {
  if (err) return onError(err);
  var contenuC = contenuA + '\n' + contenuB;
  fs.writeFile(fichierC, contenuC, onWriteFileC);
}
// Fin écriture fichierC
function onWriteFileC(err) {
  if (err) return onError(err);
  // Succès
  console.log('OK');
}
// Erreur
function onError(err) {
  console.log('FAIL: ' + err.message);
}

On a un peu démêlé le tout, ce qui permettra par exemple de plus facilement changer l’ordre des opérations à l’avenir. Il y a encore des pistes d’amélioration: notamment on traite à chaque fois le cas d’erreur, 3 fois la même ligne de code. Il serait intéressant de pouvoir écrire quelque chose comme ça:

execute([
  function() { /* Lecture fichier A */ fs.readFile(fichierA, callbackSuivant()); },
  function(contenuA) { /* Lecture fichier B */ fs.readFile(fichierB, callbackSuivant(avec contenuA)); },
  function(contenuA, contenuB) { /* Écriture fichier C */ fs.writeFile(fichierC, contenuA + '\n' + contenuB, callbackSuivant(avec contenuA et contenuB)); },
  function(contenuA, contenuB) { /* Succès */ console.log('OK'); }
], /* cas d'erreur générique */ function(err) { console.log('FAIL: ' + err.message); });

Et bien en fait, c’est possible :)

Best practice #2: Use async.js!

Le module « async » pour Node.JS est une librairie très complète ayant pour objectif de vous aider à gérer ces traitements asynchrones en cascade, en série, en parallèle, appliqués à des éléments de tableau, etc…

Grâce à cette librairie, et à la méthode « waterfall()« , qui comme son nom l’indique permet de gérer les cascades, on pourra écrire notre programme de cette manière:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async.waterfall(
  [
    // Lecture fichier A
    function(callback) { fs.readFile(fichierA, callback); },
    // Lecture fichier B
    function(contenuA, callback) { fs.readFile(fichierB, function(err, contenuB) { callback(err, contenuA, contenuB); }); },
    // Écriture fichier C
    function(contenuA, contenuB, callback) { fs.writeFile(fichierC, contenuA + '\n' + contenuB, callback); },
    // Succès
    function() { console.log('OK'); }
  ],
  // Erreur
  function(err) { console.log('FAIL: ' + err.message); }
);

C’est beaucoup plus clair, facile à lire, donc facile à maintenir. Et ça marche vraiment :)

Problème classique #2: exécuter une liste dynamique d’appels asynchrones et effectuer un processus après tous ces appels

Supposons que cette fois, on ait une liste dynamique de fichiers, on veut tous les lire, concaténer leur contenu, puis écrire ce texte final dans un autre fichier.

En version synchrone:

1
2
3
4
5
6
7
8
9
10
try {
  var contenu = '';
  fichiers.forEach(function(f) {
    contenu += fs.readFileSync(f);
  });
  fs.writeFileSync(fichierFinal, contenu);
  console.log('OK');
}  catch (err) {
  console.log('FAIL: ' + err.message);
}

En version asynchrone standard:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var contenu = '';
function onError(err) { console.log('FAIL: ' + err.message); }
function ecrireFichier(contenu) {
  fs.writeFile(fichierFinal, contenu, function(err) {
    if (err) onError(err);
    else console.log('OK');
  });
}
function lireFichier(contenu, i) {
  if (i >= fichiers.length) ecrireFichier(contenu); // On a lu tous les fichiers: écrire
  else fs.readFile(fichiers[i], function(err, contenuFichier) { // Lire le fichier courant
    if (err) onError(err);
    else lireFichier(contenu + contenuFichier, i + 1); // Concaténer et lire le fichier suivant
  });
}
lireFichier('', 0);  // Lancer la chaine de lecture

Avec async, on peut pour la lecture utiliser « map() » qui a la faculté appréciable de lancer toutes les opérations en parallèle, mais tout en garantissant l’ordre du résultat final:

async.map(fichiers, fs.readFile, function(err, contenus) { ... })

En combinant avec « waterfall() » vu avant, on obtient ce code:

1
2
3
4
5
6
7
8
9
10
11
12
async.waterfall(
  [
    // Lecture des fichiers
    function(callback) { async.map(fichiers, fs.readFile, callback); },
    // Écriture du fichier final avec la concaténation des contenus
    function(contenus, callback) { fs.writeFile('fichierFinal', contenus.join(''), callback); },
    // Succès
    function() { console.log('OK'); }
  ],
  // Erreur
  function(err) { console.log('FAIL: ' + err.message); }
);

On en arrive à avoir un code même plus lisible que la version synchrone, tout en étant non bloquant et donc plus performant dans un cadre d’applications à nombre potentiellement élevé d’accès concurrents :)

Conclusion

Jongler avec l’imbrication des callbacks pour garder du code non bloquant n’est pas facile et amène vite, même avec toute la bonne volonté du monde, à une soupe de code illisible. Le 2e exemple était un cas assez simple, et on voit qu’en version asynchrone « normale » on a déjà du déployer un algorithme non trivial à coup d’appels récursifs. Pas de quoi fouetter un chat me direz-vous ? Certes, mais complexifiez légèrement le problème et la complexité du code, elle, va exploser :( imaginez devoir ajouter une étape intermédiaire comme un filtrage ou une condition quelconque elle-même basée sur un appel asynchrone… L’horreur!

La librairie async.js, utilisable autant côté client que côté serveur, vous permettra de rendre ce code beaucoup plus lisible et donc plus maintenable, plus facile à faire évoluer. Dès que vous devez manipuler des séquences d’appels asynchrones, pensez-y impérativement!

Mon conseil? Essayez d’implémenter vous-même la fonctionnalité d’async dont vous auriez besoin pour simplifier votre code (tout en sachant que vous le jeterez à la poubelle au profit de la vraie librairie ;)), ou au moins regardez ce qu’il y a sous le capot de cette librairie. Vous apprendrez ainsi au passage à dompter ce type de programmation.

11 réflexions au sujet de « Bonnes pratiques pour gérer son code asynchrone en Javascript »

  1. Ping : Benchmark Node.JS: méthodes synchrones ou asynchrones ? « naholyr

  2. Clément

    Pour le coup, ça peut être pas mal d’utiliser des futures.
    Ça revient au même dans les faits, mais c’est une abstraction claire et puissante parfaite pour cette situation.

    Je ne sais pas si il y a des bibliothèques JS qui proposent ça, mais ça serait cool :)

    (sinon, petite typo dans la ligne 13 du 2e bout de code « conccaténation »)

    Répondre
    1. naholyr Auteur de l’article

      Je n’ai pas du tout regardé cette notion de « futures » (c’est la même chose que les « promise » que j’ai vu passer dans quelques articles ?).
      Cette notion semble abordée par la librairie « streamline.js » qui tend vers le même but, mais comme je ne suis pas sûr du concept, je ne saurais être affirmatif :)

      Merci pour la typo ;)

      Répondre
  3. Clément

    Promises et futures sont souvent utilisées de façon quasi-interchangeables.

    Le but, c’est de lancer le traitement asynchrone, et de récupérer une variable qui contiendra un jour la valeur. Tant qu’on ne demande pas explicitement la valeur finale, il n’y a pas de blocage. L’intérêt, c’est que comme c’est un type monadique, on peut enchainer les traitements, sans forcer l’évaluation finale.

    Répondre
    1. naholyr Auteur de l’article

      OK, je ne sais pas si ça existe pour JS du coup, mais il me semble bien que streamline.js manipule ces contextes (voir le blog de Bruno Jouhier « Bruno’s Ramblings », dans les liens de la colonne de droite de cette page). C’est natif à Erlang je suppose?

      Répondre
  4. Bruno Jouhier

    Bonjour,

    J’ai suivi un lien dans l’espace d’admin de mon blog et j’arrive sur votre article. Tout à fait d’accord avec votre constat: les 6 niveaux de callbacks imbriqués, c’est difficilement viable. Et async.js est probablement la solution la plus aboutie dans la catégorie « bibliothèque ».

    Quelques mots sur streamline.js. Ce n’est pas à proprement parler une bibliothèque mais plutôt un pré-processeur qui vous évite d’écrire les callbacks. Avec streamline, l’exemple ci-dessus devient:

    try {
      var contenuA = fs.readFile(fichierA, _);
      var contenuB = fs.readFile(fichierB, _);
      var contenuC = contenuA + '\n' + contenuB;
      fs.writeFile(fichierC, contenuC, _);
      console.log('OK');
    }
    catch (err) {
      console.log('FAIL: ' + err.message);
    }

    Le try/catch n’est pas obligatoire; vous pouvez laisser l’exception remonter.

    Streamline permet aussi de condenser le bloc try en une seule ligne:

    fs.writeFile(fichierC, fs.readFile(fichierA, _) + '\n' + fs.readFile(fichierB, _), _);

    Avec ces 2 versions, la lecture de B ne commence qu’une fois que A a été lu en totalité. Mais vous pouvez aussi lancer les 2 lectures an parallèle en utilisant des « futures »:

    // pas d'_ => retourne une future
    var futureA = fs.readFile(fichierA); 
    var futureB = fs.readFile(fichierB);
    fs.writeFile(fichierC, futureA(_) + '\n' + futureB(_), _);

    Le seul inconvénient c’est que ça implique une transformation de code. Mais c’est le prix à payer pour pouvoir programmer « normalement » en asynchrone.

    Ca me fait tout drôle de faire un peu de promo en français :-)

    Bruno

    Répondre
    1. naholyr Auteur de l’article

      Merci beaucoup pour ton intervention qui ajoute de précieuses informations! :D

      Je regarderai de plus prêt ce pré-processeur qui semble très intéressant, je suis particulièrement curieux de voir les traductions qu’il fait, ça doit être parfois compliqué.

      NB1: je me suis permis d’éditer ton commentaire pour ajouter la coloration syntaxique.

      NB2: pour le français effectivement je me tate en ce moment à produire du billet en anglais, mais je ne serai jamais aussi à l’aise d’une part, et de l’autre ça me fait un peu plaisir qu’on puisse causer technique dans la langue de Molière, je trouve ça rafraichissant ;)

      Répondre
      1. Bruno Jouhier

        C’est mieux reformatté. Merci.

        Le dernier billet que j’ai mis sur mon blog décrit en détail les différentes étapes de la transformation. Il y a en particulier les patterns pour les différentes instructions du langage. La plupart des patterns sont assez simples mais ça se complique avec try/catch et try/finally (mon préféré).

        Il y a aussi une démo en ligne sur http://sage.github.com/streamlinejs/examples/streamlineMe/streamlineMe.html

        Bruno

        Répondre

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Vous pouvez utiliser ces balises et attributs HTML : <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>