Historia del Callback Hell en Node.js

Hace unos días intentaba convencer a uno de mis compañeros de trabajo para que probase Node como lenguaje de programación. Me sorprendió su respuesta: Tío, no me aclaro con la asincronía, los callbacks hacen que mi código sea una chapuza.

En ese momento me di cuenta de todo lo que había avanzado Node.js a lo largo de las versiones. Una persona que haya tenido contacto con Node hace tiempo, puede tener una opinión diferente a la que tendría si esa misma prueba la hiciera ahora mismo.

Hacemos un resumen histórico de cómo se usan los callback para manejar la asincronía, veremos algunas buenas prácticas y formas de organizar el código para hacer que este sea de buena calidad y que aproveche las capacidades que nos ofrecen las últimas versiones de Node.

Breve historia de Node.js

Node.js es un framework que se ejecuta en el lado servidor. Los creadores de Node utilizaron Javascript, ya que Node se basa en la máquina V8 que Google creó para interpretar este lenguaje en Chrome y se caracteriza por su velocidad y rendimiento.

Node.js surge como respuesta a la problemática con la programación secuencial tradicional. En lenguajes como Java, que se basan en hilos de ejecución, hay un máximo teórico que viene dado por la memoria que consume cada hilo de ejecución en la máquina en la que se despliega.

De forma que si por ejemplo tenemos una máquina con 8GB de memoria RAM y cada hilo consume 2 megas, tenemos un máximo teórico de 4000 hilos concurrentes (teórico, ya que habría que descontar el consumo de memoria del propio sistema operativo y otros elementos que estuvieran en ejecución).

En este tipo de arquitecturas el cuello de botella reside en la memoria de la máquina y para escalar hay que hacerlo añadiendo más máquinas.

La particularidad de Node.js reside en una pieza denominada EventLoop. Con el EventLoop, Node.js es capaz de solventar el problema de la limitación de memoria, que comentamos anteriormente, consumiendo muy pocos recursos de la máquina y maximizando el rendimiento.

Node.js es single thread, este hilo de ejecución única se denomina EventLoop y su función es ejecutar código Javascript, ante la llegada de operaciones de entrada/salida delega la ejecución de las mismas en subsistemas específicamente preparados para que se procesen en segundo plano.

De esta forma no se bloquea el hilo de ejecución (non-blocking), mientras el EventLoop apunta la función que se ejecutará una vez que ese procesamiento offline termine (callback).

Desde un punto de vista más técnico, un componente de la máquina V8 denominado libuv genera un ThreadPool con 4 hilos para los procesos asíncronos, además el propio sistema operativo provee de interfaces asíncronas para procesamiento de entrada/salida.

Por otro lado, el EventLoop tiene una cola donde va apuntando los callbacks que tiene que ejecutar una vez que las operaciones asíncronas finalicen.

Una forma sencilla de entender qué es el EventLoop es imaginar que el sistema es como un restaurante, en el que tenemos un único trabajador: un camarero. Nuestro camarero se encarga de sentar a la gente en la mesa y tomarles la orden de comida (tarea asíncrona), si el camarero tuviese que preparar la comida estaríamos bloqueándole para que pudiese atender a la siguiente mesa.

En lugar de eso, el camarero manda la orden a la cocina (threadPool), en la que uno de los cocineros la prepara. Cuando la comida esté lista, llamará al camarero para que la lleve a la mesa, nuestro camarero tendrá apuntada la correspondencia entra la comida y el número de mesa para saber el sitio al que tiene entregar la comida (callback).

Callback Hell

El callback Hell se produce cuando encadenamos muchas operaciones asíncronas seguidas.

function getNames(callback) {
	setTimeout(function() {
	    console.log('han pasado 3 segundos');
	    callback(null, 'Lucas', 'Carlota');
	}, 3000);
}


function compoundSentence(name1, name2, callback) {
	console.log('componemos la frase: ');
	setTimeout(function() {
	    console.log('han pasado 2 segundos');
	    var sentence = name1 +' y '+ name2;
    	callback(null, sentence);
	}, 2000); 
}

function finalizeSentence(sentence, callback) {
	console.log('añadimos otra pieza a la frase: ');
	setTimeout(function() {
	    console.log('han pasado 1 segundos');
	    sentence += ' son hermanos!';
    	callback(null, sentence);
	}, 1000); 
}

getNames(function (err, name1, name2){
     compoundSentence(name1, name2, function(err, sentence) {
        finalizeSentence(sentence, function(err, finalResult) {
            console.log(finalResult)
        })
     })
});

Recorrido por las distintas versiones de Node

Vamos a ver cómo Node.js gestiona esta problemática a lo largo de todas sus versiones.

Node v0.11

Node.js pasó una etapa bastante conflictiva en sus inicios, llegando incluso a recibir un fork (io.js) debido a conflictos en su gobierno, provocado principalmente ante la negativa a incorporar nativamente las mejoras que ECMASCRIPT iba lanzando.

Por suerte esa etapa no duró mucho y nació Node v4, como resultado de la fusion de node.js e io.js.

Por aquel entonces la forma de no caer en el temido callback-hell y que el código fuese ilegible, era utilizar librerías para controlar el flujo, una de las más populares es Async.

Async proporciona métodos tales como waterfall, series, parallel que permiten ejecutar funciones en cascada, en serie o en paralelo respectivamente pasando los resultados obtenidos a la siguiente función.

Solución con Async:

async.waterfall(
    [
        function(callback) {
        	// operación síncrona que recurre un par de nombres
        	setTimeout(function() {
			    console.log('han pasado 3 segundos');
			    callback(null, 'Lucas', 'Carlota');
			}, 3000);
        },
        function(name1, name2, callback) {
        	console.log('componemos la frase: ');
        	setTimeout(function() {
			    console.log('han pasado 2 segundos');
			    var sentence = name1 +' y '+ name2;
            	callback(null, sentence);
			}, 2000); 
        },
        function(sentence, callback) {
        	console.log('añadimos otra pieza la frase: ');
        	setTimeout(function() {
			    console.log('han pasado 1 segundos');
			    sentence += ' son hermanos!';
            	callback(null, sentence);
			}, 1000); 
        }
    ],
    function (err, sentence) {
        console.log(sentence);
    }
);

Node V4

A partir de esta versión de Node llegaron las tan ansiadas Promesas. Una promesa es un objeto que representa la evaluación en forma de éxito o fracaso de una operación asíncrona.

Si bien es cierto que el uso de promesas es anterior a esta versión mediante el uso de librerías como promisify, desde la versión 4 se introducen de forma nativa.

Solución con Promesas:

function getNames() {
	return new Promise((resolve, reject) => {
		setTimeout(function() {
		    console.log('han pasado 3 segundos');
		    resolve('Lucas', 'Carlota');
		}, 3000);
	});	
}

function compoundSentence(name1, name2) {
	return new Promise ((resolve, reject) => {
		console.log('componemos la frase: ');
		setTimeout(function() {
		    console.log('han pasado 2 segundos');
		    var sentence = name1 +' y '+ name2;
	    	resolve(sentence);
		}, 2000); 
	});
}

function finalizeSentence(sentence, callback) {
	return new Promise ((resolve, reject) => {
		console.log('añadimos otra pieza la frase: ');
		setTimeout(function() {
		    console.log('han pasado 1 segundos');
		    sentence += ' son hermanos!';
	    	resolve(sentence);
		}, 1000); 
	});
}

getNames()
	.then((name1, name2) => {
		return compoundSentence(name1, name2)
	})
    .then((sentence) => {
    	return finalizeSentence(sentence)
    })
    .then((finalResult) => {
    	console.log(finalResult)
    })
    .catch((err) => console.log(err));

Ahora es bastante más legible seguir el flujo de la ejecución de getNames.

Node v6

Con la versión 6 de Node puedes hacer uso de las funciones generadoras y de la palabra reservada yield. Una de las cosas que más se echaban de menos era poder parar la ejecución de una promesa, de forma que se pudiese programar de forma secuencial.

Lo que te permite hacer yield es parar la ejecución de una promesa, esperando para continuar cuando la promesa sea resuelta, de esta forma puedes programar de un modo más secuencial y que el código sea más fácilmente entendible.

La complejidad de esta solución es el uso de las funciones generadoras, y que este tipo de código se suele usar junto con un módulo llamado Co, que es un controlador del flujo para funciones generadoras.

Solución con funciones generadoras y yield:

const co = require('co');

function getNames() {
	return new Promise((resolve, reject) => {
		setTimeout(function() {
		    console.log('han pasado 3 segundos');
		    resolve(['Lucas', 'Carlota']);
		}, 3000);
	});	
}

function compoundSentence(names) {
	return new Promise ((resolve, reject) => {
		console.log('componemos la frase: ');
		setTimeout(function() {
		    console.log('han pasado 2 segundos');
		    var sentence = names[0] +' y '+ names[1];
	    	resolve(sentence);
		}, 2000); 
	});
}

function finalizeSentence(sentence) {
	return new Promise ((resolve, reject) => {
		console.log('añadimos otra pieza la frase: ');
		setTimeout(function() {
		    console.log('han pasado 1 segundos');
		    sentence += ' son hermanos!';
	    	resolve(sentence);
		}, 1000); 
	});
}

co(function* () {
	let names = yield getNames();
	let sentence = yield compoundSentence(names);
	let finalResult = yield finalizeSentence(sentence);
	console.log(finalResult);
})
.catch(error => console.log(error));

Node v8

Con esta versión llega ASYNC/AWAIT para dar una vuelta a la funcionalidad anterior que, pese a ser efectiva, resultaba un tanto complicada porque usaba funciones generadoras y el uso de módulos externos como Co.

Solución con ASYNC/AWAIT:

async function getNames() {
	return new Promise((resolve, reject) => {
		setTimeout(function() {
		    console.log('han pasado 3 segundos');
		    resolve(['Lucas', 'Carlota']);
		}, 3000);
	});	
}

async function compoundSentence(names) {
	return new Promise ((resolve, reject) => {
		console.log('componemos la frase: ');
		setTimeout(function() {
		    console.log('han pasado 2 segundos');
		    var sentence = names[0] +' y '+ names[1];
	    	resolve(sentence);
		}, 2000); 
	});
}

async function finalizeSentence(sentence) {
	return new Promise ((resolve, reject) => {
		console.log('añadimos otra pieza la frase: ');
		setTimeout(function() {
		    console.log('han pasado 1 segundos');
		    sentence += ' son hermanos!';
	    	resolve(sentence);
		}, 1000); 
	});
}

try{
let names = await getNames();
let sentence = await compoundSentence(names);
let finalResult = await finalizeSentence(sentence);
console.log(finalResult);
}
catch(error) {
	console.log(error);
}

Como ves, es un wrapper sobre la anterior solución, pero que hace que sea más entendible y que tengamos más limpio nuestro código y, sobre todo, eliminamos la necesidad de usar un módulo externo como Co.

A partir de la versión 8, han llegado las versiones 9 y 10 pero no aportan nada nuevo en la problemática que hemos tratado.

Como has podido ver Node.js ha ido evolucionando mucho en muy poco tiempo. Gracias a la increíble comunidad que hay detrás, su evolución está garantizada y eso hará que nos siga facilitándonos la vida.

Esto no quiere decir que las versiones anteriores sean incorrectas y que te tengas que volver loco a refactorizar código, pero tienes que ser consciente de que hay nuevas formas de hacer las cosas que harán que nuestro código luzca mejor.

Es importante estar al tanto de estas mejoras e ir actualizando las versiones más antiguas para que nuestro código sea más fácilmente mantenible.

Destacaría, por otro lado, que debido a todas las evoluciones y cambios de versiones que hemos vivido en los últimos años, Node.js ha alcanzado un grado de madurez óptimo para su uso, por lo que te animo tanto si has probado Node.js en el pasado como si estás pensando hacerlo, a que le des una oportunidad y veas la facilidad de uso y la potencia que tiene.

Enlace de interés

Javier Alvarez, con más de 10 años de experiencia en el mundo del desarrollo de Software, es un apasionado de su profesión y de las nuevas tecnologías, tras unos años en el exilio, Javier ha vuelto a España para unirse a Paradigma, tratando de aportar esa experiencia internacional y un punto de vista diferente.

Ver toda la actividad de Javier Álvarez

Escribe un comentario