miércoles, 21 de diciembre de 2011

Step. Una librería de control de flujos de node.js

Una vez que he presentado algunos mecanismos para evitar el infierno de callbacks en node.js, vamos a ver una librería de control de flujos. Se trata de Step (https://github.com/creationix/step, npm:step), una librería de Tim Caswell para el control de flujos en node.js. Step permite gestionar ejecuciones en paralelo y en serie y ayuda en la gestión de errores.

Uso de Step

Para una ejecución en serie, hay que pasar this como el argumento de callback de la función asíncrona o bien devolver un valor si se trata de un código sincrono. El siguiente ejemplo de un código que se lee a sí mismo y se saca por consola en mayúsculas ilustra lo que quiero decir:

Step(
  function meLeo(){
    //Lee el codigo fuente de este fichero
    fs.readFile(__filename, this);
  },
  function pasarAMayusculas(err, text){
    //Ejemplo de código síncrono
    if(err) throw err;
    return text.toUpperCase();
  },
  function mostrar(err, texto){
    if(err) throw err;
    console.log(texto);
  }
)

Siguiendo el estándar de node.js (la convención que mencioné en el artículo anterior), los callbacks tienen siempre como primer argumento el error. Además, si no se definen callbacks en línea, sino que se le pasan en serie a Step, tenemos garantizada la captura de todas las excepciones, con la seguridad que ello conlleva.

El ejemplo anterior realiza tareas en serie. Realizar tareas en paralelo es igual de sencillo:
Step(
    //Cargar dos ficheros en paralelo
    function cargarFicheros(){
      fs.readFile(__filename, this.parallel());
      fs.readFile("/etc/passwd", this.parallel());
    },
    function mostrarContenido(err, codigo, usuarios){
      if(err) throw err;
      console.log(codigo);
      console.log(usuarios);
    }
)

Al usar this.parallel(), Step llevará, de manera transparente, la cuenta del número y orden de los callbacks. Al terminar la ejecución en paralelo, pasará al siguiente callback como parámetros los resultados de las tareas paralelizadas, ajustadas al orden en que fueron invocadas estas tareas.

Estructura de Step
Vamos a revisar como está estructurada esta librería para buscar elementos reseñables.

Step viene con un README detallado, un fichero package.json, un único fichero para la librería principal, y los tests separados en diferentes ficheros que abordan cada uno una característica principal de la libreria.

El soporte del módulo CommonJS  es condicional, lo que permite usar esta librería fuera de Node (por ejemplo, en un navegador):
// Hook into commonJS module systems
if (typeof module !== 'undefined' && "exports" in module) {
  module.exports = Step;
}

La estructura base de la principal función de Step es muy fácil de seguir:
function Step() {
  var steps = Array.prototype.slice.call(arguments),
      pending, counter, results, lock;

  // Define the main callback that's given as `this` to the steps.
  function next() {
    // ...
  }

  // Add a special callback generator `this.parallel()` that groups stuff.
  next.parallel = function () {
    // ...
  };

  // Generates a callback generator for grouped results
  next.group = function () {
    // ...
  };

  // Start the engine an pass nothing to the first step.
  next();
}

La función next se invoca al final de Step y es lo que arranca la ejecución. Esta función también tiene los métodos parallel y group a los que se puede acceder desde el this en los callbacks.

Gestión de la ejecución
El núcleo de la librería es la función next. Veamos cada una de sus partes principales.

Los contadores y los valores de retorno se usan para determinar que hay que ejecutar a continuación. El contador se usa en parallel y group. Estos valores se establecen cuando se llama a next:
  // Define the main callback that's given as `this` to the steps.
  function next() {
    counter = pending = 0;

El array de funciones pasados a Step se ejecutan en orden invocando shift en el array. Si no hay más pasos, si hay errores no gestionados se lanzan y si no la ejecución se completa:
    // Check if there are no steps left
    if (steps.length === 0) {
      // Throw uncaught errors
      if (arguments[0]) {
        throw arguments[0];
      }
      return;
    }

    // Get the next step to execute
    var fn = steps.shift();
    results = [];


Cada "step" se invoca utilizando apply de manera que el this en la función suministrada sea "next":
  // Run the step in a try..catch block so exceptions don't get out of hand.
    try {
      lock = true;
      var result = fn.apply(next, arguments);
    } catch (e) {
      // Pass any exceptions on through the next callback
      next(e);
    }

Los errores se capturan y se pasan al siguiente paso. La variable lock se usa por la funcionalidad de paralelización de tareas y agrupación de resultados. El valor devuelto de la función del paso se guarda. A continuación, el valor devuelto se utiliza para determinar si se ha utilizado una función síncrona y en caso afirmativo se invoca next de nuevo con el resultado:
    if (result !== undefined) {
    if (counter > 0 && pending == 0) {
      // If parallel() was called, and all parallel branches executed
      // syncronously, go on to the next step immediately.
      next.apply(null, results);
    } else if (result !== undefined) {
      // If a syncronous return is used, pass it to the callback
      next(undefined, result);
    }
    lock = false;

Ejecución paralela
El método parallel devuelve una función que recubre los callback para mantener los contadores y ejecutar el siguiente paso. El valor de la variable index queda fijada en cada llamada a parallel() y se utiliza para almacenar los valores devueltos por la funciones que se ejecutan en paralelo en las posición correcta:
// Add a special callback generator `this.parallel()` that groups stuff.
  next.parallel = function () {
    var index = 1 + counter++;
    pending++;

    return function () {
      pending--;
      // Compress the error from any result to the first argument
      if (arguments[0]) {
        results[0] = arguments[0];
      }
      // Send the other results as arguments
      results[index] = arguments[1];
      if (!lock && pending === 0) {
        // When all parallel branches done, call the callback
        next.apply(null, results);
      }
    };

Step.fn
La función Step.fn (sin documentar) crea factorías de función a partir de las llamadas a step. Se usa de esta forma:
var myfn = Step.fn(
  function (name) {
    fs.readFile(name, 'utf8', this);
  },
  function capitalize(err, text) {
    if (err) throw err;
    return text.toUpperCase();
  }
);

var selfText = fs.readFileSync(__filename, 'utf8');

expect('result');
myfn(__filename, function (err, result) {
  fulfill('result');
  if (err) throw err;
  assert.equal(selfText.toUpperCase(), result, "It should work");
});

Si el último argumento utilizado al llamar al conjunto de pasos agrupados en myfn es una función, esta se ejecutará como el último paso.

Últimas notas
Step lleva circulando ya un tiempo y Tim a estado trabajando activamente en ella. Es una librería pequeña que resuelve un problema habitual de control de flujos asíncronos. Según el registro de NPM, unas 45 librerías dependen de Step, y esto es sin duda un buen indicador de la popularidad de esta librería.

2 comentarios:

  1. Hola
    Parece ser, por lo que se ve en su Git, que Step ha sido abandonada, ¿verdad?
    ¿Qué reemplazo recomendarías? Parece que Async ha tomado bastante fuerza...
    ¿O optarías por usar alguna librería que implementara promesas, como Q?
    Muchas gracias

    ResponderEliminar
  2. La verdad es que ahora apenas usamos este patrón de programación y cuando lo usamos utilizamos async. Efectivamente, ahora estamos usando mucho las promesas, y la librería Q es la que más usamos ($q con AngularJS). Para nosotros, unas funciones fundamentales de Q cuando usas NodeJS son fnapply y fncall, para usar la api de NodeJS como si fueran promesas.

    ResponderEliminar