En este tutorial veremos cómo utilizar promesas en JavaScript y algunas funcionalidades de la librería de promesas Bluebird.js para manejar operaciones asíncronas.
Índice de contenidos
- 1. Introducción
- 2. Entorno
- 3. Promesas en JavaScript
- 4. ¿Qué es una promesa?
- 5. Promisificación de funciones asíncronas
- 5. Promise.all() , some() y any()
- 7. Creación de nuevas promesas
- 8. Conclusiones
- 9. Referencias
1. Introducción
JavaScript utiliza un estándar para ejecutar operaciones asíncronas que se basa en el llamado continuation-passing style,
accediendo al resultado de nuestras llamadas asíncronas por medio de callbacks. Este estilo de programación es difícil de manejar y modificar cuando se apilan muchas llamadas asíncronas, y también dificulta el testing de las aplicaciones.
Una promesa es un objeto que queda a la escucha del resultado de una operación asíncrona y permite acceder a él del mismo modo que lo haríamos ejecutando código síncrono, sin depender de callbacks.
2. Entorno
Cualquier editor de texto y un navegador te valdrá para ejecutar los snippets de código que encontrarás en este tutorial.
3. Promesas en JavaScript
Tal vez ya hayáis leído este tutorial de Samuel Martín sobre promesas en Angular. Bien, Angular no es el único framework del lenguaje que utiliza promesas, lo cierto es que es un patrón de diseño prácticamente ubiquo en JavaScript, tanto de lado cliente como servidor, y hay multitud de frameworks que lo utilizan como respuesta estándar para operaciones asíncronas, como jQuery. De hecho, ES6 añade un objeto Promise nativo del lenguaje.
Así que es muy probable que si estamos usando jQuery o Angular para lanzar peticiones asíncronas, simplemente podamos usar sus promesas sin tener que preocuparnos por nada más. No obstante, hay muchas librerías en JavaScript -especialmente en nodeJs- que hacen llamadas a API’s de third parties que no trabajan directamente con promesas, sino con callbacks.
Todas las implementaciones de promesas se adhieren –o deberían– a una interfaz definida como Promises/A+.
4. ¿Qué es una promesa?
Una promesa es un objeto que envuelve una operación asíncrona y queda pendiente de su estado. Cuando realizamos operaciones asíncronas con promesas, la función que usemos devolverá una promesa. En cambio, cuando ejecutamos funciones que realizan llamadas asíncronas y ejecutan un callback cuando llega la respuesta, estas funciones no devuelven ningún valor: la única forma que tendremos de utilizar los resultados de estas llamadas es ejecutar código dentro de esos callbacks.
Por ejemplo, usando el método readFile del módulo fs de nodeJs, para leer de un fichero tendríamos que llamar a fs.readFile y trabajar con ese fichero dentro del callback que le pasamos a la función:
var fs = require(‘fs’); fs.readFile(‘file.txt’, 'utf8', function manageFile(err, data) { if (err) throw err; console.log(data); });
Aquí, simplemente, cuando fs.readFile
acceda al fichero, se ejecutará el callback pasando como primer parámetro algún error en caso de que haya habido alguno, y el fichero mismo como string como segundo parámetro -data en nuestro ejemplo-.
Una promesa, en cambio, encapsula un estado que corresponde con el resultado de un callback, y tiene una api para comprobar y acceder a esos resultados cuando sea posible, fundamentalmente mediante su método then. Una promesa recibe como parámetro una función, que a su vez recibe dos funciones como parámetro: resolve
y reject
. Debemos ejecutar código asíncrono dentro del primer callback, e invocar resolve()
sobre el resultado del callback -o reject()
en caso de que haya habido un error-, para que la promesa recién creada quede a la escuche de ese valor cuando se ejecute el callback:
var readFilePromise = function(filename) { return new Promise(function(resolve, reject) { fs.readFile(filename, function(err, success) { if (err) reject(err); resolve(success); }); }); } readFilePromise.then(function(file) {... });
El método then
debe recibir como parámetro una función que será el resultado del callback de readFile
. La promesa se asegura de que sólo sea llamado cuando el valor está disponible, y no antes.
Una de las cosas buenas que tiene Bluebird es su mecanismo para promisificar funciones que realizan operaciones asíncronas. En vez de crear manualmente una promesa y llamar a resolve sobre el resultado de un callback, llamamos a Promise.promisify
sobre un método asíncrono y el resultado será una función equivalente que devolverá una promesa:
var fs = require(‘fs’); var Promise = require(‘bluebird’); var readFileAsync = Promise.promisify(fs.readFile); readFileAsync(‘file.txt’, ‘utf8’) .then(console.log) .catch(console.log);
La función readFileAsync
devuelve una promesa. Una promesa es un objeto que está enlazado con el resultado de una operación asíncrona, y que tiene tres estados posibles: pendiente, satisfecha o rechazada (pending, fullfilled, rejected). Una promesa está pendiente mientras se espera el resultado de nuestra llamada, satisfecha cuando el resultado es exitoso, y rechazada cuando la respuesta nos indica que ha habido algún error.
Dentro de la API de las promesas, las dos funciones básicas son then
y catch
. Ambas toman funciones como parámetros. Cuando llamamos a then
sobre una promesa, la función que éste toma como parámetro sólo se llegará a ejecutar cuando el estado de la promesa pase a ser satisfecha, y recibirá por parámetro el resultado de la llamada asíncrona.
Exactamente lo mismo sucede con catch
, pero solamente en caso de error, y lo que recibirá su función como argumento es el error.
Cuando utilizamos una promesa, el resultado de la operación, si ha sido exitosa, se pasa como parámetro automáticamente a cualquier función que pasemos a su vez como parámetro de la llamada a then
, y lo mismo sucede con los errores que puedan resultar de la llamada asíncrona cuando usamos catch
. En el ejemplo anterior -que no es muy sofisticado- usamos console.log en ambos casos, simplemente para ver los datos en el terminal.
Una de las grandes ventajas de todo esto, es que dentro de un then
, podemos utilizar una función que a su vez haga una petición asíncrona y devuelva una promesa. Bien, esta función no se ejecutará hasta que la promesa sobre la que se ha invocado su then no esté satisfecha.
En un caso tan simple como el anterior, no hay mucha diferencia entre usar callbacks o usar promesas, pero las promesas se vuelven muy útiles cuando llamamos a callbacks dentro de callbacks o queremos crear piezas de código asíncrono reusables. Y cuantas más llamadas anidadas a callbacks haya dentro de callbacks, más nos beneficiaremos de usar promesas. Usando promesas, en vez de anidamiento de callbacks, tendremos trenes de métodos then, que se irán llamando subsiguientemente según se vayan resolviendo sus promesas.
Las Promesas son un patrón general de desarrollo. Bluebird es solamente una implementación. Como ya dije, hay una especificación de EcmaScript6 para soportar promesas como una función nativa del lenguaje. Muchos frameworks envuelven sus llamadas asíncronas con su propia implementación de las promesas, y hay otras implementaciones como q. Este tutorial está basado en Bluebird porque es la implementación de Promesas que yo he manejado más a menudo y se ha erigido casi como un estándar en node, pero en principio, cualquier implementación que se adhiera a la interfaz A+ debería poder usarse exactamente igual. Algunas utilidades como Promise.all()
no están en el spec A+, pero igualmente son implementadas por la mayoría de frameworks para trabajar con promesas.
5. Promisificación de funciones asíncronas
Nuestra función readFileAsync
es el resultado de aplicar promisify sobre la función readFile
del módulo fs de node. Bluebird puede convertir automáticamente funciones asíncronas en Promesas,pero solo si se cumplen ciertas condiciones: los callbacks de las funciones asíncronas que queramos promisificar deben adherirse a una determinada interfaz: la convención de nodeJs para funciones asíncronas, lo que supone que:
- El último argumento de la función que queremos promisificar debe ser una función, que es la que se ejecutará como callback cuando la llamada asíncrona finalice:
- El primer parámetro de este callback debe ser un objeto que, en caso de no ser nulo, indica que ha habido un error en la llamada y contiene información sobre el error.
- El resto de parámetros constituyen la respuesta de la llamada asíncrona en caso de que no haya habido errores.
Entonces, si una función que hace una operación asíncrona se adhiere a esta convención, podemos usar el método promisify
de bluebirdjs para envolver la llamada en una promesa.
Envolver una única llamada asíncrona que ejecuta un solo callback tal vez no sea muy útil. No obstante, cuando tenemos varios callbacks anidados dentro de otros, generando lo que se llama el callback hell, es cuando usar promesas se vuelve especialmente útil.
Por ejemplo, supongamos que nuestro fichero movies.json es un listado de nombres de películas, tal que así:
{ "movies": [{ "name": "The Toxic Avenger" }, { "name": "The matrix" }, { "name": "Willow" }] }
y que queremos listar la información de esas películas usando la api de IMDB y el paquete imdb-api. Imdb-api es un paquete de node para hacer peticiones a la api de imdb, que es un servicio con información sobre películas. Imdb-api nos permite obtener la información de una película en concreto por su nombre, tal que:
Simplemente imprimirá en nuestra consola datos de ‘The toxic Avenger’, como su fecha de estreno, el nombre del director etc. etc.
Entonces, supongamos que queremos hacer lo siguiente:
- Leer el fichero movies.json.
- Elegir la primera película del json.
- Llamar a imdb para obtener los datos de esa película.
- Utilizar esta información en algún callback.
El código para hacer eso, utilizando las funciones nativas de fs
e imdb-api
, sería como el que sigue:
var fs = require('fs'); var imdb = require('imdb-api'); var useMovieData = function(filename, operationCallback) { fs.readFile(filename, function(err, file) { if (err) throw err; var movie = JSON.parse(file).movies[0]; imdb.getReq(movie, function(err, data) { if (err) throw err; operationCallback(data); }) }) } useMovieData('movies.json', console.log);
En cambio, si promisificamos las dos llamadas asíncronas, podríamos hacerlo así:
var fs = require('fs'); var imdb = require('imdb-api'); var Promise = require('bluebird'); var getMovieAsync = Promise.promisify(imdb.getReq); var readFileAsync = Promise.promisify(fs.readFile); var getMovieData = function(filename) { return readFileAsync(filename) .catch(console.log) .then(function(file) { var movie = JSON.parse(file).movies[0]; return getMovieAsync(movie); }); } getMovieData('movies.json') .catch(console.log) .then(console.log);
Aquí, getMovieData
lanza una petición asíncrona con readFileAsync
, que como ya hemos visto devuelve una promesa. No podemos saber cuándo terminará de leer el fichero, pero la promesa que hemos asociado a readFile quedará a la escucha del resultado de la operación.
En el then siguiente a readFileAsync leemos la primera película del fichero y llamamos a getMovieAsync, que a su vez hace una petición asíncrona a imdb y devuelve la promesa resultante.
Las sentencias de return
que hay en getMovieData
son muy importantes. Si os fijáis, no estamos simplemente llamando a readFileAsync
: lo estamos retornando. Y readFileAsync
devuelve una promesa. Como quiera que tiene un .then
asociado, lo que estamos devolviendo en realidad es la promesa que creamos dentro de la lambda que pasamos a ese then
: la promesa que creamos con la llamada a getMovieAsync
. Por eso el return que hay en la última línea de la función getMovieData
también es muy importante, porque de otro modo no devolveríamos una promesa, sino undefined
.
Al devolver una promesa, podemos utilizar el resultado de llamar a getMovieData
inmediatamente después de llamarla. Esto es distinto a lo que sucede si usamos callbacks, por ejemplo:
var fileContents = null; fs.readFile('text.txt', 'utf8', function(err, file) { fileContents = file; }); console.log(fileContents); /*Será null. La llamada asíncrona de readFile todavía se estará ejecutando en este punto del script. En cambio, este log se lanza a la consola En cuanto el flujo del programa llega a este punto*/
Por otro lado:
var filePromise = readFileAsync('text.txt'); filePromise.then(console.log); /* Esto sí hará log del contenido del fichero. No obstante, el log solamente será ejecutado cuando readFileAsync termine de hacer su trabajo, así que aunque esta línea ya haya sido ejecutada en este punto, es imposible controlar a priori cuándo exáctamente se hará el log.*/
Sin embargo, solamente el código que esté promisificado se ejecutará en orden. El código que no vaya dentro de promesas no esperará a las llamadas asíncronas:
var fileContents = null; filePromise.then(function(result) { fileContents = result; }); console.log(fileContents); //null
Volviendo a nuestro ejemplo con imdb, las diferencias entre el código promisificado y el basado en callbacks pueden no parecer muy grandes, pero son notables:
getMovieData
-la versión con promesas- toma un solo parámetro en lugar de dos, lo que simplifica su uso. Además, si quisiéramos realizar varias operaciones sobre el resultado de la llamada, no habría ningún problema, mientras que hacerlo en el caso de los callbacks requeriría modificar ese código.
Los niveles de indentación en useMovieData son mayores. Si hubiera más llamadas asíncronas anidadas, sería aún peor.
6. Promise.all() , some() y any()
Si esto no te ha convencido, vamos a ver un ejemplo un poco más complejo y más realista: dado que movies.txt contiene, al fin y al cabo, varias películas, ahora vamos a definir una función que llame a imdb para cada una de las películas que hay en el fichero. En el caso de querer usar las funciones nativas de fs
e imdb
, el código podría ser como el que sigue:
var fs = require('fs'); var imdb = require('imdb-api'); var useMoviesDataFromFile = function(file, operationCallback) { fs.readFile(file, 'utf8', function getFile(err, data) { if (err) throw err; var movies = JSON.parse(data).movies; var imdbMoviesData = []; movies.forEach(function eachMovieImdbRequest(movie, index) { imdb.getReq({ name: movie.name }, function getMovie(err, data) { if (err) throw (err); else imdbMoviesData.push(data); if (imdbMoviesData.length == movies.length) operationCallback(imdbMoviesData); }) }); }); } useMoviesDataFromFile('movies.json', console.log);
La idea es que una vez que leemos el fichero ‘movies.json’ del filesystem, iteramos sobre el resultado y lanzamos una petición a imdb por cada película, y vamos guardando el resultado de esta llamada en el array imdbMoviesData
. Al final de cada llamada, si ya tenemos los datos de todas las películas, invocamos a operationCallback
sobre este array que tiene todos los datos. En el ejemplo, simplemente usamos console.log
como operationCallback
.
Este código tiene bastantes problemas. Para empezar, al contener un callback dentro de otro, los niveles de indentación son mayores, y puede ser complicado saber exactamente en qué punto estamos cuando leemos el código.
Por otro lado, dado que dentro de movies.forEach
estamos lanzando peticiones asíncronas, su código no se habrá ejecutado cuando termine el foreach
. Es por ese motivo que tenemos que preguntar al final de cada iteración si imdbMoviesData
-el array donde estamos guardando los resultados de las peticiones a imdb- tiene el mismo número de elementos que nuestro array movies donde habíamos almacenado cada título de película. Sólo cuando son iguales podemos operar sobre imdbMoviesData
con la seguridad de que tendrá los datos de todas las películas -si no ha habido errores-.
Esta solución complica el código y dificulta tanto su lectura como su edición. ¿No sería mejor poder ejecutar código después del forEach
con la seguridad de que podemos acceder a lo que se haya fijado dentro del bucle?
La función Promise.all
de bluebird nos permite iterar sobre un array de promesas y operar sobre un array equivalente de sus resultados cuando éstas son resueltas satisfactoriamente. Con esto en mente, podríamos escribir nuestro código como sigue:
var Promise = require('bluebird'); var imdb = require('imdb-api'); var fs = require('fs'); var readFileAsync = Promise.promisify(fs.readFile); var imdbGetAsync = Promise.promisify(imdb.getReq); var getAllMoviesFromFile = function(file) { return readFileAsync('movies.json') .then(function(moviesfile) { var movies = JSON.parse(moviesfile).movies; var promises = []; movies.forEach(function(movie) { promises.push(imdbGetAsync(movie)) }); return Promise.all(promises); }); } getAllMoviesFromFile('movies.json').catch(console.log).then(console.log);
Aquí, promises
es un array de promesas. Cuando termine el forEach, tendremos una promesa -que envuelve una petición asíncrona a imdb- por cada película. No obstante, no tenemos la seguridad de que, cuando termine el foreach, estas peticiones hayan obtenido un resultado -de hecho, es absolutamente seguro que no les habrá dado tiempo a obtener una respuesta-.
Pero, como nuestro array es de promesas, y no de los resultados de las llamadas asíncronas, podemos usar Promise.all
para devolver, a su vez, una promesa que sólo quedará satisfecha cuando todas las promesas del array estén satisfechas. El último console.log
que se pasa como parámetro a getAllMoviesFromFile... ...then()
está actuando sobre el resultado de esa promesa, devuelta a su vez por Promise.all
, que devolverá un array cuando su estado quede satisfecho.
El código del último ejemplo, aunque promisificado, es un poco feo, en el siguiente sentido: dado que then
toma funciones como argumento, es mucho más adecuado definir nuestras funciones al margen de la cadena de promesas y luego pasarlas a los then
por su nombre, sin incluír bloques enteros de código dentro de funciones anónimas. Además,en vez de pasar funciones directamente a los then
, podemos invocar funciones que devuelvan a su vez… funciones, lo que nos permite agilizar algo el código con algunas técnicas de programación funcional. Un ejemplo de código más conciso y legible podría ser el siguiente:
var Promise = require('bluebird'); var imdb = require('imdb-api'); var fs = require('fs'); var readFileAsync = Promise.promisify(fs.readFile); var imdbGetAsync = Promise.promisify(imdb.getReq); var getMovie = function(movie) { return imdbGetAsync(movie); } var parseMoviesFile = function(file) { return JSON.parse(file).movies; } //nota que esto devuelve una función var mapToMovies = function(func) { return function(movies) { return movies.map(func); } } var getAllMoviesFromFile = function(file) { return readFileAsync('movies.json') .catch(console.log) .then(parseMoviesFile) .then(mapToMovies(getMovie)) .then(Promise.all); } getAllMoviesFromFile('movies.json').catch(console.log).then(console.log);
Bluebird expone métodos como some y any que son similares a all, pero que devuelven una promesa que se satisface solamente cuando un número determinado de las promesas del array quedan satisfechas -en some
– o cuando hay al menos una -en any
-. Puedes comprobar la api de bluebird aquí.
7. Creación de nuevas promesas
Un problema que nos puede surgir trabajando con promesas e intentando promisificar nuestro código, es que no todas las funciones asíncronas se adhieren a la interfaz error-success de node, lo que provocará que no podamos promisificarlas con Promise.promisify
. En ese caso, como en nuestro primer ejemplo, tendremos que crear una nueva promesa y llamar a las funciones resolve
y reject
que recibe como parámetros.
Por ejemplo, una función asíncrona muy conocida que no se adhiere a la interfaz de callbacks de node es setTimeout
. La función setTimeout
toma el callback que va a ejecutar como primer parámetro, y el tiempo que debe esperar para ejecutarla como segundo parámetro.
Un ejemplo de cómo promisificar setTimeout
es este pequeño programa (que destruirá el mundo la mitad de las veces, tal vez no queráis ejecutarlo en vuestra máquina 0_0):
var Promise = require('bluebird'); var doomMechanism = function() { var randomNumber = Math.floor(Math.random() * 2); if (randomNumber == 0) throw new Error("Doom!!!"); return "World is safe"; } new Promise(function(resolve, reject) { setTimeout(function() { try { var isArmaggeddonSkipped = doomMechanism(); resolve(isArmaggeddonSkipped); } catch (err) { reject(err); } }, 1000); }) .catch(console.log) .then(console.log);
La idea es ejecutar la llamada asíncrona en la función que se pasa al constructor de la nueva promesa, y llamar a resolve siempre al final de la ejecución del callback que se ejecuta en la respuesta, pasando por parámetro el resultado de la llamada.
8. Conclusiones
Las promesas permiten escribir código asíncrono que se comporta como su contrapartida síncrona: los métodos devuelven valores en vez de ejecutar callbacks, el orden cronológico del programa sigue el orden del código y podemos utilizar el resultado de nuestras operaciones asíncronas sin tener que pasar operaciones a través de cadenas de callbacks.
9. Referencias
Algunos de los mejores enlaces que podéis leer sobre promesas en JavaScript son los siguientes:
Ya habia escuchado de bluebird para nodejs, nunca pense en utilizarlo en el navegador, normalmente yo trabajo con Java, me toco utilizar DWR, la cual tenia callbacks para manejar las llamadas ajax, el problema de DWR es que no maneja promesas y en estas fechas me encontraba actualizando un sistema y queria meter las llamadas de DWR con promesas, asi que utilice las promesas de jQuery creando scripts de servicios por cada clase controller de DWR y ya lo promesifique, no me gusto la forma, quedo mucho código que se tiene que mantener, pero posiblemente si hago un refactor de eso utilice bulebird para promesificar las cosas e igual la pondre como sugerencia para DWR, aunque ya es una libreria vieja.
Buen articulo
Gracias por el comentario Jesús. Nunca he trabajado con DWR, suerte con eso. Estoy pensando en escribir un próximo tutorial sobre async/await en javascript, si sueles trabajar a menudo con promesas seguramente te interese el tema si no lo conocías.
Pues yo creo que el futuro esta en Genradores y Canales.
Pegale un vistazo a esto si estas interesado:
http://jlongster.com/Taming-the-Asynchronous-Beast-with-CSP-in-JavaScript