El infierno de retrollamadas (callback hell)

Objetivos del capítulo

En este capítulo veremos cómo las herramientas que hemos visto hasta ahora (a saber, transformadores de mónada y funtores aplicativos) se pueden usar para resolver algunos problemas del mundo real. En particular, veremos cómo podemos resolver el problema del infierno de retrollamadas.

Preparación del proyecto

El código fuente de este capítulo se puede compilar y ejecutar usando pulp run. También es necesario instalar el módulo request usando NPM:

1 npm install

El problema

El código asíncrono en JavaScript usa normalmente retrollamadas (callbacks) para estructurar el flujo del programa. Por ejemplo, para leer texto de un fichero, el método preferido es usar la función readFile y pasar una retrollamada (una función que se llamará cuando el texto esté disponible):

 1 function readText(onSuccess, onFailure) {
 2   var fs = require('fs');
 3   fs.readFile('file1.txt', { encoding: 'utf-8' }, function (error, data) {
 4     if (error) {
 5       onFailure(error.code);
 6     } else {
 7       onSuccess(data);
 8     }   
 9   });
10 }

Sin embargo, si hay involucradas múltiples operaciones, esto puede llevar rápidamente a retrollamadas anidadas, lo que puede acabar en código difícil de leer:

 1 function copyFile(onSuccess, onFailure) {
 2   var fs = require('fs');
 3   fs.readFile('file1.txt', { encoding: 'utf-8' }, function (error, data1) {
 4     if (error) {
 5       onFailure(error.code);
 6     } else {
 7       fs.writeFile('file2.txt', data, { encoding: 'utf-8' }, function (error) {
 8         if (error) {
 9           onFailure(error.code);
10         } else {
11           onSuccess();
12         }
13       });
14     }   
15   });
16 }

Una solución a este problema es descomponer las llamadas asíncronas individuales en sus propias funciones:

 1 function writeCopy(data, onSuccess, onFailure) {
 2   var fs = require('fs');
 3   fs.writeFile('file2.txt', data, { encoding: 'utf-8' }, function (error) {
 4     if (error) {
 5       onFailure(error.code);
 6     } else {
 7       onSuccess();
 8     }
 9   });
10 }
11 
12 function copyFile(onSuccess, onFailure) {
13   var fs = require('fs');
14   fs.readFile('file1.txt', { encoding: 'utf-8' }, function (error, data) {
15     if (error) {
16       onFailure(error.code);
17     } else {
18       writeCopy(data, onSuccess, onFailure);
19     }   
20   });
21 }

Esta solución funciona, pero tiene algunos problemas:

  • Es necesario pasar resultados intermedios a funciones asíncronas como argumentos de función, de la misma manera que hemos pasado data a writeCopy arriba. Esto está bien para funciones pequeñas, pero si hay muchas retrollamadas involucradas, las dependencias de datos pueden volverse complejas, resultando en muchos argumentos de función adicionales.
  • Hay un patrón común. Las retrollamadas onSuccess y onFailure se especifican normalmente como argumentos a cada función asíncrona. Pero este patrón se tiene que documentar en la documentación del módulo que acompaña el código fuente. Es mejor capturar este patrón en el sistema de tipos y usarlo para forzar su uso.

A continuación veremos cómo usar las técnicas que hemos aprendido hasta ahora para resolver estos problemas.

La mónada de continuación

Traduzcamos el ejemplo copyFile de arriba a PureScript usando la FFI. Al hacerlo, la estructura del cálculo resultará evidente, y seremos conducidos de manera natural a un transformador de mónada definido en el paquete purescript-transformers; el transformador de mónada de continuación ContT.

Nota: en la práctica no es necesario escribir estas funciones a mano cada vez. Puedes encontrar funciones de entrada/salida asíncrona en las bibliotecas purescript-node-fs y purescript-node-fs-aff.

Primero tenemos que dar tipos a readFile y writeFile usando la FFI. Comencemos definiendo algunos sinónimos de tipo y un nuevo efecto para entrada/salida de fichero:

1 foreign import data FS :: Effect
2 
3 type ErrorCode = String
4 type FilePath = String

readFile toma un nombre de fichero y una retrollamada que toma dos argumentos. Si el fichero ha sido leído con éxito, el segundo argumento tendrá el contenido del fichero, y si no, el primer argumento se usará para indicar el error.

En nuestro caso, envolveremos readFile con una función que toma dos retrollamadas: una retrollamada de error (onFailure) y una retrollamada de resultado (onSuccess), igual que hicimos con copyFile y writeCopy arriba. Usando el soporte de funciones de múltiples argumentos de Data.Function por simplicidad, nuestra función envuelta readFileImpl puede tener esta pinta:

1 foreign import readFileImpl
2   :: forall eff
3    . Fn3 FilePath
4          (String -> Eff (fs :: FS | eff) Unit)
5          (ErrorCode -> Eff (fs :: FS | eff) Unit)
6          (Eff (fs :: FS | eff) Unit)

En el módulo externo JavaScript, readFileImpl estaría definida como:

 1 exports.readFileImpl = function(path, onSuccess, onFailure) {
 2   return function() {
 3     require('fs').readFile(path, {
 4       encoding: 'utf-8'
 5     }, function(error, data) {
 6       if (error) {
 7         onFailure(error.code)();
 8       } else {
 9         onSuccess(data)();
10       }
11     });
12   };
13 };

Esta firma de tipo indica que readFileImpl toma tres argumentos: una ruta de fichero, una retrollamada de éxito y una retrollamada de error, y devuelve un cálculo con efectos secundarios que devuelve un resultado vacío (Unit). Fíjate en que a las retrollamadas les damos tipos que usan la mónada Eff para registrar sus efectos.

Debes intentar entender por qué esta implementación tiene la representación correcta en tiempo de ejecución para su tipo.

writeFileImpl es muy similar; difiere únicamente en que el contenido del fichero se pasa a la función, no a la retrollamada. Su implementación tiene esta pinta:

1 foreign import writeFileImpl
2   :: forall eff
3    . Fn4 FilePath
4          String
5          (Eff (fs :: FS | eff) Unit)
6          (ErrorCode -> Eff (fs :: FS | eff) Unit)
7          (Eff (fs :: FS | eff) Unit)
 1 exports.writeFileImpl = function(path, data, onSuccess, onFailure) {
 2   return function() {
 3     require('fs').writeFile(path, data, {
 4       encoding: 'utf-8'
 5     }, function(error) {
 6       if (error) {
 7         onFailure(error.code)();
 8       } else {
 9         onSuccess();
10       }
11     });
12   };
13 };

Dadas estas declaraciones FFI, podemos escribir las implementaciones de readFile y writeFile. Estas usarán el módulo Data.Function.Uncurried para convertir las ligaduras FFI de múltiples argumentos en funciones currificadas normales de PureScript, y que por lo tanto tienen tipos algo más legibles.

Además, en lugar de requerir dos retrollamadas, una para éxitos y otra para fallos, podemos requerir una única retrollamada que responde a ambas cosas. Esto es, la nueva retrollamda toma un valor en la mónada Either ErrorCode como argumento:

 1 readFile
 2   :: forall eff
 3    . FilePath
 4   -> (Either ErrorCode String -> Eff (fs :: FS | eff) Unit)
 5   -> Eff (fs :: FS | eff) Unit
 6 readFile path k =
 7   runFn3 readFileImpl
 8          path
 9          (k <<< Right)
10          (k <<< Left)
11 
12 writeFile
13   :: forall eff
14    . FilePath
15   -> String
16   -> (Either ErrorCode Unit -> Eff (fs :: FS | eff) Unit)
17   -> Eff (fs :: FS | eff) Unit
18 writeFile path text k =
19   runFn4 writeFileImpl
20          path
21          text
22          (k $ Right unit)
23          (k <<< Left)

Ahora podemos identificar un patrón importante. Cada una de estas funciones toma una retrollamada que devuelve un valor en alguna mónada (en este caso, Eff (fs :: FS | eff)) y devuelve un valor en la misma mónada. Esto significa que cuando la primera retrollamada devuelve un resultado, esa mónada se puede usar para ligar el resultado a la entrada de la siguiente función asíncrona. De hecho, eso es exactamente lo que hicimos a mano en el ejemplo copyFile.

Esto es la base del transformador de mónada de continuación, que está definido en el módulo Control.Monad.Cont.Trans de purescript-transformers.

ContT se define como un newtype como sigue:

1 newtype ContT r m a = ContT ((a -> m r) -> m r)

Una continuación es simplemente otro nombre para una retrollamada. Una continuación captura el resto de un cálculo; en nuestro caso, qué sucede después de que el resultado sea proporcionado tras una llamada asíncrona.

El argumento al constructor de datos ContT se parece notablemente a los tipos de readFile y writeFile. De hecho, si hacemos que el tipo a sea el tipo Either ErrorCode String, r sea Unit y m la mónada Eff (fs :: FS | eff), podemos recuperar la parte derecha del tipo de readFile.

Esto motiva el siguiente sinónimo de tipo, definiendo una mónada Async, que usaremos para componer acciones asíncronas como readFile y writeFile:

1 type Async eff = ContT Unit (Eff eff)

Para nuestros propósitos, siempre usaremos ContT para transformar la mónada Eff, y el tipo r será siempre Unit, pero esto no es obligatorio.

Podemos tratar readFile y writeFile como cálculos en la mónada Async aplicando simplemente el constructor de datos ContT:

 1 readFileCont
 2   :: forall eff
 3    . FilePath
 4   -> Async (fs :: FS | eff) (Either ErrorCode String)
 5 readFileCont path = ContT $ readFile path
 6 
 7 writeFileCont
 8   :: forall eff
 9    . FilePath
10   -> String
11   -> Async (fs :: FS | eff) (Either ErrorCode Unit)
12 writeFileCont path text = ContT $ writeFile path text

Con eso, podemos escribir nuestra rutina de copia de ficheros usando notación do para el transformador de mónada ContT:

 1 copyFileCont
 2   :: forall eff
 3    . FilePath
 4   -> FilePath
 5   -> Async (fs :: FS | eff) (Either ErrorCode Unit)
 6 copyFileCont src dest = do
 7   e <- readFileCont src
 8   case e of
 9     Left err -> pure $ Left err
10     Right content -> writeFileCont dest content

Fíjate en cómo la naturaleza asíncrona de readFileCont queda oculta por la ligadura monádica expresada usando notación do; parece código síncrono, pero la mónada ContT es la que se ocupa de hilar nuestras funciones asíncronas.

Podemos ejecutar este cálculo usando el gestor runContT suministrando una continuación. La continuación representa qué hacer a continuación, es decir, qué hacer cuando la rutina asíncrona de copia de ficheros finaliza. Para nuestro ejemplo simple, podemos elegir logShow como función de continuación, que imprimirá el resultado de tipo Either ErrorCode Unit a la consola:

1 import Prelude
2 
3 import Control.Monad.Eff.Console (logShow)
4 import Control.Monad.Cont.Trans (runContT)
5 
6 main =
7   runContT
8     (copyFileCont "/tmp/1.txt" "/tmp/2.txt")
9     logShow

Poniendo ExceptT a trabajar

Esta solución funciona, pero puede mejorarse.

En la implementación de copyFileCont tuvimos que usar ajuste de patrones para analizar el resultado del cálculo readFileCont (de tipo Either ErrorCode String) para determinar qué hacer a continuación. Sin embargo, sabemos que la mónada Either tiene un transformador de mónada correspondiente, ExceptT, de forma que es razonable esperar que podamos usar ExceptT con ContT para combinar los dos efectos de cálculo asíncrono y gestión de errores.

De hecho es posible, y podemos ver por qué si miramos la definición de ExceptT:

1 newtype ExceptT e m a = ExceptT (m (Either e a))

ExceptT simplemente cambia el resultado de la mónada subyacente de a a Either e a. Esto significa que podemos reescribir copyFileCont transformando nuestra pila de mónadas actual con el transformador ExceptT ErrorCode. Es tan simple como aplicar el constructor ExceptT a nuestra solución existente:

 1 readFileContEx
 2   :: forall eff
 3    . FilePath
 4   -> ExceptT ErrorCode (Async (fs :: FS | eff)) String
 5 readFileContEx path = ExceptT $ readFileCont path
 6 
 7 writeFileContEx
 8   :: forall eff
 9    . FilePath
10   -> String
11   -> ExceptT ErrorCode (Async (fs :: FS | eff)) Unit
12 writeFileContEx path text = ExceptT $ writeFileCont path text

Ahora, nuestra rutina de copia de fichero es mucho más simple, ya que la gestión de errores asíncrona queda oculta dentro del transformador de mónada ExceptT:

1 copyFileContEx
2   :: forall eff
3    . FilePath
4   -> FilePath
5   -> ExceptT ErrorCode (Async (fs :: FS | eff)) Unit
6 copyFileContEx src dest = do
7   content <- readFileContEx src
8   writeFileContEx dest content

Un cliente HTTP

Como otro ejemplo de uso de ContT para gestionar funciones asíncronas, vamos a ver el módulo Network.HTTP.Client del código fuente de este capítulo. Este módulo usa la mónada Async para soportar peticiones HTTP asíncronas usando el módulo request, disponible via NPM.

El módulo request suministra una función que toma un URL y una retrollamada, hace una petición HTTP(S) e invoca la retrollamada cuando la respuesta está disponible, o cuando ocurre un error. Aquí tenemos un ejemplo de petición:

1 require('request')('http://purescript.org'), function(err, _, body) {
2   if (err) {
3     console.error(err);
4   } else {
5     console.log(body);
6   }
7 });

Vamos a recrear este ejemplo simple en PureScript usando la mónada Async.

En el módulo Network.HTTP.Client, el método request está envuelto con una función getImpl:

 1 foreign import data HTTP :: Effect
 2 
 3 type URI = String
 4 
 5 foreign import getImpl
 6   :: forall eff
 7    . Fn3 URI
 8          (String -> Eff (http :: HTTP | eff) Unit)
 9          (String -> Eff (http :: HTTP | eff) Unit)
10          (Eff (http :: HTTP | eff) Unit)
 1 exports.getImpl = function(uri, done, fail) {
 2   return function() {
 3     require('request')(uri, function(err, _, body) {
 4       if (err) {
 5         fail(err)();
 6       } else {
 7         done(body)();
 8       }
 9     });
10   };
11 };

De nuevo, podemos usar el módulo Data.Function.Uncurried para convertir esto en una función currificada PureScript normal. Como antes, combinamos las dos retrollamadas en una, esta vez aceptando un valor de tipo Either String String, y aplicamos el constructor ContT para construir una acción en nuestra mónada Async:

1 get :: forall eff.
2   URI ->
3   Async (http :: HTTP | eff) (Either String String)
4 get req = ContT \k ->
5   runFn3 getImpl req (k <<< Right) (k <<< Left)

Cálculos paralelos

Hemos visto cómo usar la mónada ContT y la notación do para componer cálculos asíncronos en secuencia. Sería también útil poder componer cálculos asíncronos en paralelo.

Si usamos ContT para transformar la mónada Eff, podemos realizar cálculos en paralelo simplemente iniciando nuestros dos cálculos uno tras otro.

El paquete purescript-parallel define una clase de tipos MonadPar para mónadas como Async que soportan ejecución paralela. Cuando nos encontramos los funtores aplicativos en un capítulo previo, observamos cómo los funtores aplicativos pueden ser útiles para combinar cálculos paralelos. De hecho, una instancia de Parallel define una correspondencia entre una mónada m (como Async) y un funtor aplicativo f que se puede usar para combinar cálculos en paralelo:

1 class (Monad m, Applicative f) <= Parallel f m | m -> f, f -> m where
2   sequential :: forall a. f a -> m a
3   parallel :: forall a. m a -> f a

La clase define dos funciones:

  • parallel, que toma un cálculo en la mónada m y lo convierte en cálculos en el funtor aplicativo f, y
  • sequential, que realiza una conversión en sentido inverso.

La biblioteca purescript-parallel proporciona una instancia Parallel para la mónada Async. Usa referencias mutables para combinar acciones Async en paralelo, llevando registro de cual de las dos continuaciones se ha llamado. Cuando ambos resultados están disponibles, podemos calcular el resultado final y pasarlo a la continuación principal.

Podemos usar la función parallel para crear una versión de nuestra acción readFileCont que se puede combinar en paralelo. Aquí hay un simple ejemplo que lee dos ficheros de texto en paralelo, los concatena e imprime el resultado:

 1 import Prelude
 2 import Control.Apply (lift2)
 3 import Control.Monad.Cont.Trans (runContT)
 4 import Control.Monad.Eff.Console (logShow)
 5 import Control.Monad.Parallel (parallel, sequential)
 6 
 7 main = flip runContT logShow do
 8   sequential $
 9    lift2 append
10      <$> parallel (readFileCont "/tmp/1.txt")
11      <*> parallel (readFileCont "/tmp/2.txt")

Fíjate en que, ya que readFileCont devuelve un valor de tipo Either ErrorCode String, necesitamos elevar la función append sobre el constructor de tipo Either usando lift2 para formar nuestra función combinadora.

Dado que los funtores aplicativos soportan elevar funciones de aridad arbitraria, podemos realizar más cálculos en paralelo usando los combinadores aplicativos. ¡Podemos también beneficiarnos de todas las funciones de la biblioteca estándar que trabajan con funtores aplicativos, como traverse y sequence!

También podemos combinar cálculos paralelos con porciones de código secuencial, usando combinadores aplicativos en un bloque en notación do, o viceversa, usando parallel y sequential para cambiar los constructores de tipo como corresponda.

Conclusión

En este capítulo, hemos visto una demostración práctica de los transformadores de mónada:

  • Vimos cómo el idioma común en JavaScript de pasar retrollamadas se puede capturar con el transformador de mónada ContT.
  • Vimos cómo el problema del infierno de las retrollamadas se puede resolver usando notación do para expresar cálculos asíncronos secuenciales, y un funtor aplicativo para expresar paralelismo.
  • Hemos usado ExceptT pare expresar errores asíncronos.