Designez des API asynchrones VRAIMENT asynchrones

Je suis tombé récemment sur un vieil article d’Isaac Zimmitti Schlueter à lire ABSOLUMENT.

Pour résumer rapidement le propos, IZS explique suite à un avertissement on ne peut plus clair (Do Not Release Zalgo) que lors du design d’une API Asynchrone, il faut éviter à tout prix la situation suivante :

var cachedData;
function getSomeData(callback) {
  if (cachedData === undefined) {
    $.get('some/random/api').then(function(data) {
      callback(cachedData = data);
    });
  } else {
    callback(cachedData);
  }
}

Quel est le problème avec ce bout de code ?

Si vous êtes développeur AngularJs / EmberJs / votre framework javascript préféré, je suis certain que vous avez vous-même été amené à écrire du code similaire ou au moins à en rencontrer au sein de vos applications.

Alors quel est le problème ?

Mon API fait un calcul coûteux (requête HTTP vers une API tiers) au premier appel, stocke la donnée retournée dans une variable servant de cache et appelle le callback. Au deuxième appel, mon API constate que la donnée est déjà en cache et appelle immédiatement le callback.

L’utilisation d’une API de ce type est extrêmement complexe et source de bugs. Pourquoi ? Son comportement est difficile à prédire…

L’auteur l’explique en ces termes sur son article :

If you have an API which takes a callback, and sometimes that callback is called immediately, and other times that callback is called at some point in the future, then you will render any code using this API impossible to reason about, and cause the release of Zalgo.

Prenons l’exemple du code javascript suivant :

// Quelque part dans mon code
getSomeData(function(result) {
  // Faire quelque chose avec mon résultat
});

function clickHandler() {
  var data;
  getSomeData(function(result) {
    data = result;
  });

  if (data.id === 1) {
    // Traitement important
    alert('gouzigouza');
  }
}

Ligne 2, on appelle notre API asynchrone. Un peu plus bas, on définit une fonction appelée lors d’un clic sur un bouton dans une page web. Au sein de ce clickHandler on utilise l’objet data en partant du principe qu’il est disponible immédiatement. L’erreur saute ici évidemment aux yeux. Rien ne garantit que notre objet data ait déjà été retourné arrivé à la condition if.

Malheureusement, le design de notre API et l’utilisation qui en a été faite ici fait que le bug n’est pas identifiable immédiatement. En revanche, si le développeur supprime le premier appel à notre API ligne 2 ou si le clic sur le bouton au sein de la page web intervient avant que le résultat de notre API asynchrone n’ait été mis en cache, alors on se retrouvera avec une belle erreur javascript :

TypeError: Cannot read property 'id' of undefined

Quelle solution ?

Pour une API asynchrone, l’objectif est donc de toujours respecter le caractère asynchrone.
Pour se faire, une approche simpliste est de modifier notre API de la manière suivante :

var cachedData;
function getSomeData(callback) {
  if (cachedData === undefined) {
    $.get('some/random/api').then(function(data) {
      callback(cachedData = data);
    });
  } else {
    setTimeout(function() {
      callback(cachedData = data);
    }, 0);
  }
}

Ici, l’utilisation du setTimeout() est une astuce qui permet de forcer l’appel du callback de manière asynchrone. Si l’API était codée avec nodejs, il serait bien plus judicieux d’utiliser process.nextTick() qui permet de mettre en place un comportement similaire.

Ainsi, à l’usage, il est désormais systématiquement impossible de faire fonctionner le code suivant :

var data;
getSomeData(function(result) {
  data = result;
});

if (data.id === 1) {
  // Bug systématique
  alert('gouzigouza');
}

Le développeur identifie immédiatement son erreur et est obligé alors de modifier son code de la manière suivante :

getSomeData(function(result) {
  var data = result;
  if (data.id === 1) {
    alert('gouzigouza'); // OK
  }
});

Pour aller plus loin

Voici quelques liens pour approfondir le sujet. Je vous conseille évidemment la lecture de l’article de IZS qui ajoute des exemples de design pour des API où les données sont généralement présentes immédiatement et où les performances sont très importantes.
Vous pouvez également aller jeter un oeil à la partie Keeping callbacks truly asynchronous de cet article sur la fonction process.nextTick de nodejs.

Publié par Olivier Balais

Jeune ingénieur logiciel basé à Lyon (@overnetcity) passionné par les NTIC et le développement Web, je suis actuellement salarié chez Reputation VIP et effectue en parallèle des missions ponctuelles en temps que Freelance. Passionné depuis toujours par l'informatique et le développement, suite à une formation solide à l'INSA de Lyon, je me suis spécialisé dans la réalisation de bout en bout de projets web complexes.

Rejoindre la conversation

6 commentaires

  1. Ou alors utiliser les promises (ou leurs ersatz), non ?

    Un truc du genre (avec les fausses promises jquery : Deferred ) :

    var cachedData;
    function getSomeData(callback) {
     
      if (cachedData === undefined) {
            $.get('some/random/api').then(function(data) {
                 var cachedData = data;
         });
    
      } else {
       
         var dfd  = $.Deferred();
         dfd.resolve(cachedData);
        return dfd;
       
      }
    }
    
    getSomeData().then(function(data) {
          
     });
    
  2. Lorsque tu utilises setTimeout, tu perds 1ms pour rien.

    Sinon, dans la pratique, on appelle directement le callback. Le seul intérêt de setTimeout dans ce cas est de vider la callstack, intéressant lorsqu’on fait de la récursion massive pour éviter les « Maximum call stack size exceeded », mais inutile dans ton cas.

  3. @Rodrigue : A priori, la problématique reste sensiblement la même avec l’usage des promesses. Le VRAI problème ici est le caractère changeant du comportement de l’API. Un coup le callback est appelé immédiatement, un coup après un traitement asynchrone.
    Dans ton bout de code, si je résoud immédiatement la promesse lorsque ma donnée est en cache, je crée un comportement variable.

    @Weber : Le setTimeout utilisé ici dans l’exemple est une astuce pour garder la logique asynchrone. Dans une API nodejs, le setTimeout pourrait être remplacé avantageusement par process.nextTick().
    Cf la partie Keeping callbacks truly asynchronous de cet article
    Suite à votre remarque, j’ai changé le setTimeout(..., 1) par un setTimeout(..., 0) permettant de conserver le comportement asynchrone que je souhaitais mais en évitant de perdre 1ms.

  4. J’ai mis un peu de temps à comprendre, mais une fois que j’ai compris => très bon article !
    Merci 🙂

  5. Le vrai problème ici n’est pas l’api, mais comment elle est utiliser par le développeur utilisateur.

    Le développeur utilisateur doit considérer que l’api est asynchrone. Dans ton exemple il utilise sciemment le fait que soit synchrone (alors quel ne l’est pas forcément).

    C’est donc un problème de programmation de l’utilisateur et non de l’api.

  6. @Jouj : merci 🙂

    @Maarek : Je suis d’accord avec vous sur le fait que l’API est mal utilisée par le développeur.
    En revanche, cette API n’est pas asynchrone. Elle est parfois asynchrone et parfois synchrone la rendant plus difficile à comprendre.
    Bien sûr, le cas utilisé est très simple, le mauvais usage qui en est fait paraît évident…
    Le fait est que le développeur ne devrait pas avoir à réfléchir comment l’API va se comporter en fonction des appels qui ont déjà été réalisés.

    Prenons l’exemple suivant :

    var t = true;
    setTimeout(function() {
      t = false;
    },0);
    console.log(t); // true
    

    Si j’exécute ce code 500 fois, je sais de manière certaine que mon console.log() affichera toujours la même valeur car le callback passé à setTimeout s’exécutera TOUJOURS après ce dernier.

    Le but de cet article est d’attirer l’attention sur le fait qu’une API dont le caractère synchrone / asynchrone varie d’un appel à l’autre est mécaniquement plus complexe à utiliser qu’une API dont le comportement est constant.

Laisser un commentaire

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