martes, 20 de diciembre de 2011

Evitando el infierno de los callbacks en node.js

El año pasado Isaac Schlueter (@izs) publicó en la Oakland Javascript Meetup una interesante presentación (http://joyeur.files.wordpress.com/2010/10/40366684-nodejs-controlling-flow.pdf) sobre el control de flujos en javascript (tanto valdría para servidor como para cliente). Este post está basada en dicha presentación


Hoy vamos a centrarnos en una característica tan potente como, en muchas ocasiones, problemática característica de node.js: la asincronía y como gestionarla  correctamente.

En javascript estamos muy acostumbrados a utilizar EventEmitters para controlar flujos de manera asíncrona. Responder a eventos es un problema ya resuelto (al menos para los "javascripters"). El algo muy similar al manejo de eventos de DOM que llevamos haciendo tantos años, por lo que realmente trasladar esto a javascript de servidor no tiene ningún misterio. Este patrón se puede resumir en  algo.on("evento", hazAlgo).

Un ejemplo, con Socket.IO se utiliza sería:
var io = require('socket.io').listen(80);

io.sockets.on('connection', function (socket) {
  socket.emit('news', { hello: 'world' });
  socket.on('my other event', function (data) {
    console.log(data);
  });
});

Otro mecanismo muy habitual en node.js y también en muchas librerías que se pueden utilizar en clientes es el de los callbacks. De hecho, las quejas más habituales sobre node.js son sobre este aspecto:
Programar en node.js es acabar con un feísimo código anidado e intentado hasta el infinito y más allá solo para conseguir abrir un fichero....
La cosa parece incluso peor: aunque muchos de los objetos de node.js son EvventEmitters (como http server/client), la mayoría de las funciones de bajo nivel necesitan callbacks (DNS, posix API, etc.) . Parece que ir más allá de un "hola mundo" la cosa se vuelve muy complicada.

Pero analicemos un momento por qué resulta en realidad difícil. Llegaremos este pequeño shortlist:
  • Realizar un puñado de tareas en un orden específico
  • Saber cuando se han terminado las tareas
  • Gestionar los fallos
  • Dividir la funcionalidad en partes (para evitar los callbacks anidados indefinidamente). 
Estas tareas se suelen ver salpicadas por un conjunto de errores habituales. A saber:
  • Abandonar las convenciones y la consistencia
  • Poner todos los callbacks anidados en línea
  • Usar librerías de terceros sin comprender adecuadamente lo que pasa por debajo (y con los microframeworks esto no suele ser difícil)
  • Intentar que el código asíncrono parezca asíncrono (aunque aquí podríamos argüir que las promesas son un método legítimo para conseguirlo).

Por lo tanto, para conseguir un control del flujo de javascript con tareas asíncronas, lo mejor es utilizar una librería que resuelva de manera sencilla todas estas dificultades y que a la vez que imponga una convención que prevenga los errores.

Existen tropecientos mil librerías distintas de control de flujo y cada autor cree que la suya es la mejor. Por lo elección de la librería es obvia: vamos a escribir una :)

La vamos a escribir con el objetivo de aprender, manteniendo un nivel de complejidad bajo y evitando los elementos "automágicos". No se trata, por tanto, de escribir la librería de control de flujos ideal.

Convenciones

Vamos a empezar estableciendo unas convenciones (ya sabes, el primer error de la lista fue abandonarlas).

Definamos dos tipos de funciones:

  • Actores: realizan acciones o tareas.
  • Callbacks: reciben los resultados de las acciones.
En esencia, lo que vamos a utilizar aquí es el patrón de continuación. Resulta muy parecido a las fibras, pero siendo más sencillo de implementar.  Además, ya que nodejs funciona de esta manera con las APIs de bajo nivel, resulta muy flexible en este contexto.

Callbacks

  • Hemos dicho que los callbacks reciben los resultados de las acciones. Son simples respondedores.
  • Deben estar siempre preparados para gestionar los errores. Por siempre, quiero decir SIEMPRE, sin excepción alguna. Para asegurarnos de que esto ocurre, por convención siempre se pasará el error como primer argumento.
  • En muchas ocasiones serán funciones anónimas en línea, pero no siempre lo serán.
  • Puede o bien gestionar el error y llamar a otros callbacks modificando los datos, o pasar directamente el error hacia arriba.
Actores

  • Son funciones que hacen las tareas. Su último argumento será el callback (cb) que invocar al terminar.
  • Si ocurre algún error que el actor no sepa gestionar, se lo pasa al callback y termina su ejecución.
  • No debe utilizar "throw" y el valor de retorno se ignora (para el ejemplo esto será cierto, sin embargo, existen librerías que utilizan el valor de retorno para discriminar entre ejecuciones síncronas y asíncronas)
  • Es decir, un "return x" se convertiría en "return cb(null, x);"
  • Y un "throw er" pasaría a ser "return cb(er)"

Es decir, un actor seguiría más o menos estas líneas maestras:

function actor (some, args, cb) {
  // el último argumento es el callback
  // con este mecanismo podemos
  // tener argumentos opcionales
  if (!cb &&
      typeof(args) === "function")
    cb = args, args = []
 
  // hace algo y a continuación:
  
  if (failed)  cb(new Error("failed!"))
  else cb(null, optionalData)
}

Pongamos un caso de uso concreto: un actor que nos diga si una ruta dada se corresponde con un directorio o un symlink (windows users: no confundir con accesos directos ;), http://en.wikipedia.org/wiki/NTFS_symbolic_link):

// devuelve true si el path es un symlink 
// o un directorio.

function isLinkOrDir (path, cb) {
  fs.lstat(path, function (er, s) {
    if (er) 
      return cb(er);
    var linkOrDir = s.isDirectory() || s.isSymbolicLink();
    return cb(null, linkOrDir);
  })
}

Detengámonos un ratito en las líneas anteriores:

Vemos que lo primero es el tratamiento de errores:
    if (er) 
      return cb(er);

Y si el resultado es satisfactorio, entonces se invoca el callback con null en el error y pasando el resultado en el siguiente parámetro:
    return cb(null, linkOrDir);

Una vez disponemos de un actor disponible, veamos como podemos realizar una composición de actores:
// devuelve true si el path es un symlink,
// un directorio y tambien termina en .bak

function isLinkDirBak (path, cb) {
   return isLinkOrDir(path,
     function (er, ld) {
       return cb(er, ld &&
         path.substr(-4) === ".bak")
}) }

En primer lugar se llama al primer actor con
   return isLinkOrDir(path,

Como callback, definimos una función en línea que incluye la lógica propia y exclusiva y que callback cb original
       return cb(er, ld &&
         path.substr(-4) === ".bak")

Y de momento aquí nos quedamos con los actores. Ahora un poco de chícha asíncrona.

asyncMap

Vamos a poner un ejemplo un poco complicado de tareas a realizar de forma asíncrona. Por ejemplo, queremos poder hacer cualquiera de estas tareas:

  • Tenemos una lista de 10 ficheros y necesitamos leer todos ellos y continuar una vez que se haya terminado la lectura.
  • Tenemos una docena de URLs y tenemos que leerlas todas y continuar cuando todas se hayan leido.
  • Tenemos 4 usuarios conectados y tenemos que enviar un mensaje a todos y cada uno ellos y continuar cuando se hayan completado las comunicación de todos los mensajes.


Es decir, todos los ejemplos anteriores encajan en el siguiente patrón:
Tengo una lista de "n" cosas y necesito "hacerAlgo" con cada una de dichas cosas, en paralelo,  y recuperar los resultados cuando se haya completado.
El código que nos permite resolver este requisito podría ser lo siguiente:
function asyncMap (list, fn, cb_) {
  var n = list.length
    , results = []
    , errState = null;
  function cb (er, data) {
    if (errState) 
      return;
    if (er) 
      return cb_(errState = er);
    results.push(data);
    if (-- n === 0)
      return cb_(null, results);
  }
  list.forEach(function (l) {
    fn(l, cb) 
  })
}


Como diría Jack el Destripador: vayamos por partes.

En primer lugar, se almacena el tamaño de la lista y se definen las variables que almacenarán el resultado y el error en caso de que ocurra.
function asyncMap (list, fn, cb_) {
  var n = list.length
    , results = []
    , errState = null;

A continuación, se define el callback interno que se utiliza tras invocar la función recibida fn para cada elemento de la lista. Realiza las siguientes tareas para cada elemento:

  1. Si hay algún error anterior no hace nada, sale de la función.
  2. Si se ha producido un error en la ejecución de la función, lo almacena y sale.
  3. Guarda el resultado.
  4. Si ha terminado invoca el callback original.
  function cb (er, data) {
    if (errState) 
      return;
    if (er) 
      return cb(errState = er);
    results.push(data);
    if (-- n === 0)
      return cb_(null, results);
  }

Y por último se invoca la función para cada elemento:
  list.forEach(function (l) {
    fn(l, cb) 
  })

Un ejemplo de uso de asyncMap creando una función que escriba en varios ficheros un contenido dado podría ser el siguiente:

function escribirFicheros (rutasFicheros, contenido, cb) {
  asyncMap( 
    rutasFicheros
    , function (rutaFichero, cb2) {
         fs.writeFile(rutaFichero, contenido, cb2);
      }
    , cb
  );
}
escribirFicheros([mi,lista,de,rutas,de,ficheros], "¡Hola, mundo!", cb);

En este ejemplo asyncMap es una función Actor, recibiendo el callback como último parámetro. Se puede incluso invocar asyncMap desde otro asyncMap.

Esta implementación está bien si no importa el orden de ejecución, pues se están lanzando todas las funciones del tirón. Pero, ¿cómo hacer si el órden de ejecución es importante?

Es decir, los resultados se están agregando al array de resultados según llegan, pero si ¿cómo hacer para que el array de respuesta tenga los resultados con el mismo índice que cada uno de los valores de entrada, manteniendo la correspondencia uno a uno?


function asyncMap (list, fn, cb_) {
  var n = list.length
    , results = []
    , errState = null;
  function cbGen (i) {
    return function cb (er, data) {
      if (errState) return
      if (er) 
        return cb(errState = er)
      results[i] = data
      if (-- n === 0)
        return cb_(null, results)
    }
  }
  list.forEach(function (l, i) {
    fn(l, cbGen(i))
  })
}

Al iterar sobre los elementos, hemos agregado el indice de iteración i, e invocamos a una nueva función cbGen
 list.forEach(function (l, i) {
    fn(l, cbGen(i))
  })

La nueva función cbGen prepara una función callback cb con el índice i fijado, de manera que cuando cb sea invocada almacenará el resultado en la posición correcta 
  function cbGen (i) {
    return function cb (er, data) {
      if (errState) return
      if (er) 
        return cb(errState = er)
      results[i] = data
      if (-- n === 0)
        return cb_(null, results)
    }
  }

Cadenas
Otro caso de uso es realizar tareas en cadena, en un cierto orden preestablecido.

Como ejemplo podría ser esta secuencia: leer las credenciales de un fichero, leer datos de una base de datos, escribir los datos en un fichero. Si algo  falla, no se continúa.

El código que resolvería esto podría ser: 
function chain(things, cb) {
  var len = things.length;
  (function LOOP (i) {
    if (i >= len) 
       return cb();
    things[i](function (er) {
      if (er) 
        return cb(er);
      LOOP(i + 1);
    })
  })(0)
}

Y ahora paso a paso:

En primer lugar, definimos la función que invocará iterativamente cada una de las cosas a realizar. La invocamos empezando con el índice 0 y nos guardamos la longitud de cosas a hacer:
function chain(things, cb) {
  var len = things.length;
  (function LOOP (i) {
     //...
  })(0)
}

A continuación, dentro de LOOP, lo primero será controlar que no se ha producido un error.
    if (i >= len) 
       return cb();

Después, invocamos la cosa que toque hacer (things[]), pasando un callback para poder continuar con lo siguiente:
    things[i](function (er) {
      if (er) 
        return cb(er);
      LOOP(i + 1);
    })

Sin embargo, el código anterior tiene unos pocos "peros". A saber:
  • Hay que proporcionar un array de funciones, que implica mucho trabajo y resulta tedioso si las funciones deben recibir parámetros: "function (cb){blah(a,b,c,cb)}"
  • Los resultados se desechan lo cual es una pena. 
  • No se pueden hacer ramas en la ejecución
Para realizar las tareas tediosas vamos a preparar unos métodos que nos simplifiquen la vida:

  • convertir un array de  [fn, args] en un actor que no reciba argumentos excepto cb.
  • parecido a  Function#bind pero ajustado a nuestras necesidades concretas
  • presentará las siguientes casos de uso:
    • bindActor(obj, "method", a, b, c) 
    • bindActor(fn, a, b, c) 
    • bindActor(obj, fn, a, b, c)
Como hemos hecho anteriormente, primero el código completo:

function bindActor () {
  var args = Array.prototype.slice.call(arguments) 
    , obj = null
    , fn;
  if (typeof args[0] === "object") {
    obj = args.shift();
    fn = args.shift();
    if (typeof fn === "string")
      fn = obj[ fn ]
  } 
  else 
    fn = args.shift();
  return function (cb) {
    fn.apply(obj, args.concat(cb)); 
  }
}

Analicemos las distintas partes: si el primer argumento resulta ser un objeto, almacenamos la referencia. Si el argumento de función es una cadena, se busca el método en el objeto recibido:
  if (typeof args[0] === "object") {
    obj = args.shift();
    fn = args.shift();
    if (typeof fn === "string")
      fn = obj[ fn ]
  } 


En caso contrario, recuperamos la función
  else 
    fn = args.shift();

A continuación, se devuelve una función anónima que ejecutará la función fn con apply, estableciendo el contexto this de fn al objeto pasado (si existe) y concatenando el callback al final de los argumentos.
  return function (cb) {
    fn.apply(obj, args.concat(cb)); 
  }

Una vez que hemos definido este bindActor volvamos de nuevo sobre el método chain que ejecutaba "cosas" en cadena:
function chain (things, cb) {
  var len = things.length;
  (function LOOP (i) {
    if(i >= len) 
      return cb();
    if(Array.isArray(things[i]))
      things[i] = bindActor.apply(null, things[i]);
    things[i](function (er) {
      if (er) 
        return cb(er);
      LOOP(i + 1)
    })
  })(0)
}

Ahora evaluamos cada "cosa" recibida. Si esa cosa es un array, utilizaremos el recien creado bindActor para sustituir en el array ese array por una función con el contexto y argumentos definido en el array. El array son los argumentos que se pasaran a bindActor:
    if(Array.isArray(things[i]))
      things[i] = bindActor.apply(null, things[i]);

Creando ramas. Lo primero que necesitamos es la posibilidad de anular ramas, para lo que se utilizan argumentos que si son false no ejecutan el elemento:
chain([ hazAlgo && [algo,a,b,c]
       , isFoo && [doFoo, "foo"]
       , subCadena &&
         [chain, [one, two]]
       ], cb)

Para eso introducimos:
  if(!things[i])
    return LOOP(i + 1, len)

dentro de chain
function chain (things, cb) {
  var len = things.length;
  (function LOOP (i) {
    if (i >= len) 
      return cb();
    if (Array.isArray(things[i]))
      things[i] = bindActor.apply(null, things[i]);
    if (!things[i])
      return LOOP(i + 1);
    things[i](function (er) {
      if (er) 
        return cb(er);
      LOOP(i + 1)
    })
  })(0)
}

Ahora vamos a conservar los resultados de la cadena:

  • Vamos a proporcionar un array para almacenar los resultados  
  • El último resultado estará siempre disponible en results[results.length - 1] 
  • Vamos a utilizar chain.first y chain.last como contenedores para referenciar el primer y último resultado hasta un punto dado.
function chain (things, res, cb) {
  if (!cb) cb = res , res = [];
  (function LOOP (i,len) {
    if (i >= len) 
      return cb(null,res)
    if (Array.isArray(things[i]))
      things[i] = bindActor.apply(null,
        things[i].map(function(i){
          return (i===chain.first) ? res[0]
           : (i===chain.last)
             ? res[res.length - 1] : i 
          }
        )
      )
    if (!things[i]) 
      return LOOP(i + 1)
    things[i](function (er, data) {
      res.push(er || data);
      if (er) 
        return cb(er, res);
      LOOP(i + 1)
    });
  })(0, things.length) 
}
chain.first = {};
chain.last = {};


Lo primero es controlar si pasan o no por argumentos el array que almacenará los resultados:
function chain (things, res, cb) {
  if (!cb) cb = res , res = [];

Creamos los contenedores en:
chain.first = {};
chain.last = {};

Usando map y la función anónima del código, cuando un parámetro de la función referencie a chain.first o chain.last se sustituirán en el momento por el valor correcto para su posterior evaluación en bindActor
        things[i].map(function(i){
          return (i===chain.first) ? res[0]
           : (i===chain.last)
             ? res[res.length - 1] : i 
          }
        )


Por tanto, al ejecutar la función, el callback almacenará el error o el resultado en la primera posición libre del array de resultados. Si se produce un error corta la ejecución y si no continúa:
    things[i](function (er, data) {
      res.push(er || data);
      if (er) 
        return cb(er, res);
      LOOP(i + 1)
    });


Un ejemplo de uso no trivial de esta cadena sería el siguiente guión:
  • Leer un conjunto ficheros de un directorio
  • Agregar los resultados
  • Hacer ping a un servicio web con el resultado en un servicio web
  • Guardar la respuesta en un fichero
  • Borrar el conjunto de ficheros
Suponiendo que tuviésemos correctamente definidos los métodos a usar el programa quedaría de la siguiente manera:

function myProgram (cb) {
  var res = [], last = chain.last
    , first = chain.first
  chain
    ( [ [fs, "readdir", "the-directory"]
      , [readFiles, "the-directory", last]
      , [sum, last]
      , [ping, "POST", "example.com", 80
        , "/foo", last]
      , [fs, "writeFile", "result.txt", last]
      , [rmFiles, "./the-directory", first]
      ]
, res , cb )
)

En la cadena que hemos definido, last y first se reemplazarán por el valor del primer resultado y el resultado de la ejecución anterior respectivamente.


6 comentarios:

  1. bufff, aquí hay chicha, voy a tener que leermelo unas cuantas veces para pillar la idea.

    Gracias por compartir!

    ResponderEliminar
    Respuestas
    1. De nada. Si algo no queda claro, avísame e intentaré ampliarlo en la medida de lo posible.

      Eliminar
  2. Muy buen articulo, como los demas, porfabor sigue escribiendo, te llevo la pista

    ResponderEliminar
  3. Muchas gracias :)
    Si hay algún tema sobre el que interese ampliar, decídmelo e intentaré escribir algo.

    ResponderEliminar
  4. Tu intención es buena pero sinceramente lo explicas muy mal. El código de los ejemplos lo pones de entrada difícil de leer. A la hora de mostrar o hacer comprender deberías evitar el "codigo reducido al máximo":

    "return (i===chain.first) ? res[0] : (i===chain.last) ? res[res.length - 1] : i"

    ResponderEliminar
    Respuestas
    1. Jaja, no te falta razón. El código posiblemente no es de lo más legible. Es el código original de la presentación de @izs en la que se basa todo el artículo, y tal vez debí haberlo reescrito para hacerlo más "clean", pero no lo pensé, la verdad.

      No es que la línea sea difícil de entender, pero está claro que al dedicar unos momentos a entenderla desvía la atención de los conceptos que se tratan en el post.

      Eliminar