Comprensión de la arquitectura basada en eventos de Node.js

Actualización: este artículo ahora es parte de mi libro "Node.js más allá de lo básico". Lee la versión actualizada de este contenido y más sobre Node en jscomplete.com/node-beyond-basics .

La mayoría de los objetos de Node, como las solicitudes, respuestas y transmisiones HTTP, implementan el EventEmittermódulo para que puedan proporcionar una forma de emitir y escuchar eventos.

La forma más simple de la naturaleza orientada a eventos de devolución de llamada es el estilo de algunas de las funciones populares Node.js - por ejemplo, fs.readFile. En esta analogía, el evento se disparará una vez (cuando Node esté listo para llamar a la devolución de llamada) y la devolución de llamada actúa como el controlador de eventos.

Exploremos primero esta forma básica.

¡Llámame cuando estés listo, Node!

La forma original en que Node manejó los eventos asincrónicos fue con la devolución de llamada. Esto fue hace mucho tiempo, antes de que JavaScript tuviera soporte nativo para promesas y la función async / await.

Las devoluciones de llamada son básicamente funciones que pasa a otras funciones. Esto es posible en JavaScript porque las funciones son objetos de primera clase.

Es importante comprender que las devoluciones de llamada no indican una llamada asincrónica en el código. Una función puede llamar a la devolución de llamada de forma sincrónica y asincrónica.

Por ejemplo, aquí hay una función de host fileSizeque acepta una función de devolución de llamada cby puede invocar esa función de devolución de llamada tanto de forma sincrónica como asincrónica según una condición:

function fileSize (fileName, cb) { if (typeof fileName !== 'string') { return cb(new TypeError('argument should be string')); // Sync } fs.stat(fileName, (err, stats) => { if (err) { return cb(err); } // Async cb(null, stats.size); // Async }); }

Tenga en cuenta que esta es una mala práctica que conduce a errores inesperados. Diseñe funciones de host para consumir la devolución de llamada siempre de forma sincrónica o siempre de forma asincrónica.

Exploremos un ejemplo simple de una función de nodo asincrónica típica que está escrita con un estilo de devolución de llamada:

const readFileAsArray = function(file, cb) { fs.readFile(file, function(err, data) { if (err) { return cb(err); } const lines = data.toString().trim().split('\n'); cb(null, lines); }); };

readFileAsArraytoma una ruta de archivo y una función de devolución de llamada. Lee el contenido del archivo, lo divide en una matriz de líneas y llama a la función de devolución de llamada con esa matriz.

Aquí hay un ejemplo de uso para ello. Suponiendo que tenemos el archivo numbers.txten el mismo directorio con contenido como este:

10 11 12 13 14 15

Si tenemos una tarea para contar los números impares en ese archivo, podemos usar readFileAsArraypara simplificar el código:

readFileAsArray('./numbers.txt', (err, lines) => { if (err) throw err; const numbers = lines.map(Number); const oddNumbers = numbers.filter(n => n%2 === 1); console.log('Odd numbers count:', oddNumbers.length); });

El código lee el contenido de los números en una matriz de cadenas, los analiza como números y cuenta los impares.

El estilo de devolución de llamada de Node se usa exclusivamente aquí. La devolución de llamada tiene un primer argumento de error errque admite nulos y pasamos la devolución de llamada como último argumento para la función del host. Siempre debe hacer eso en sus funciones porque los usuarios probablemente lo asumirán. Haga que la función del host reciba la devolución de llamada como su último argumento y haga que la devolución de llamada espere un objeto de error como su primer argumento.

La moderna alternativa de JavaScript a las devoluciones de llamada

En JavaScript moderno, tenemos objetos de promesa. Las promesas pueden ser una alternativa a las devoluciones de llamada para API asincrónicas. En lugar de pasar una devolución de llamada como argumento y manejar el error en el mismo lugar, un objeto de promesa nos permite manejar casos de éxito y error por separado y también nos permite encadenar múltiples llamadas asincrónicas en lugar de anidarlas.

Si la readFileAsArrayfunción admite promesas, podemos usarla de la siguiente manera:

readFileAsArray('./numbers.txt') .then(lines => { const numbers = lines.map(Number); const oddNumbers = numbers.filter(n => n%2 === 1); console.log('Odd numbers count:', oddNumbers.length); }) .catch(console.error);

En lugar de pasar una función de devolución de llamada, llamamos a una .thenfunción en el valor de retorno de la función del host. Esta .thenfunción generalmente nos da acceso a la misma matriz de líneas que obtenemos en la versión de devolución de llamada, y podemos hacer nuestro procesamiento como antes. Para manejar los errores, agregamos una .catchllamada al resultado y eso nos da acceso a un error cuando ocurre.

Hacer que la función de host admita una interfaz de promesa es más fácil en JavaScript moderno gracias al nuevo objeto Promise. Aquí está la readFileAsArrayfunción modificada para admitir una interfaz de promesa además de la interfaz de devolución de llamada que ya admite:

const readFileAsArray = function(file, cb = () => {}) { return new Promise((resolve, reject) => { fs.readFile(file, function(err, data) { if (err) { reject(err); return cb(err); } const lines = data.toString().trim().split('\n'); resolve(lines); cb(null, lines); }); }); };

Entonces hacemos que la función devuelva un objeto Promise, que envuelve la fs.readFilellamada asíncrona. El objeto de promesa expone dos argumentos, una resolvefunción y una rejectfunción.

Siempre que queremos invocar la devolución de llamada con un error, usamos también la rejectfunción de promesa , y siempre que queremos invocar la devolución de llamada con datos, también usamos la resolvefunción de promesa .

La única otra cosa que necesitamos hacer en este caso es tener un valor predeterminado para este argumento de devolución de llamada en caso de que el código se esté utilizando con la interfaz de promesa. Podemos usar una función vacía por defecto simple en el argumento para ese caso: () =>{}.

Consumir promesas con async / await

Agregar una interfaz de promesa hace que sea mucho más fácil trabajar con su código cuando es necesario recorrer una función asíncrona. Con las devoluciones de llamada, las cosas se complican.

Las promesas mejoran eso un poco, y los generadores de funciones lo mejoran un poco más. Dicho esto, una alternativa más reciente para trabajar con código asíncrono es usar la asyncfunción, que nos permite tratar el código asíncrono como si fuera síncrono, haciéndolo mucho más legible en general.

Así es como podemos consumir la readFileAsArrayfunción con async / await:

async function countOdd () { try { const lines = await readFileAsArray('./numbers'); const numbers = lines.map(Number); const oddCount = numbers.filter(n => n%2 === 1).length; console.log('Odd numbers count:', oddCount); } catch(err) { console.error(err); } } countOdd();

Primero creamos una función asíncrona, que es solo una función normal con la palabra asyncantes. Dentro de la función asíncrona, llamamos a la readFileAsArrayfunción como si devolviera la variable de líneas, y para que eso funcione, usamos la palabra clave await. Después de eso, continuamos el código como si la readFileAsArrayllamada fuera síncrona.

Para que las cosas funcionen, ejecutamos la función async. Esto es muy simple y más legible. Para trabajar con errores, necesitamos envolver la llamada asíncrona en una declaración try/ catch.

Con esta función asincrónica / en espera, no tuvimos que usar ninguna API especial (como .then y .catch). Simplemente etiquetamos funciones de manera diferente y usamos JavaScript puro para el código.

Podemos usar la función async / await con cualquier función que admita una interfaz de promesa. Sin embargo, no podemos usarlo con funciones asíncronas de estilo callback (como setTimeout, por ejemplo).

El módulo EventEmitter

EventEmitter es un módulo que facilita la comunicación entre objetos en Node. EventEmitter es el núcleo de la arquitectura controlada por eventos asincrónica de Node. Muchos de los módulos integrados de Node heredan de EventEmitter.

El concepto es simple: los objetos emisores emiten eventos con nombre que hacen que se llame a oyentes previamente registrados. Entonces, un objeto emisor básicamente tiene dos características principales:

  • Emitir eventos de nombre.
  • Registrar y anular el registro de funciones de escucha.

Para trabajar con EventEmitter, simplemente creamos una clase que extiende EventEmitter.

class MyEmitter extends EventEmitter {}

Los objetos emisores son lo que instanciamos de las clases basadas en EventEmitter:

const myEmitter = new MyEmitter();

At any point in the lifecycle of those emitter objects, we can use the emit function to emit any named event we want.

myEmitter.emit('something-happened');

Emitting an event is the signal that some condition has occurred. This condition is usually about a state change in the emitting object.

We can add listener functions using the on method, and those listener functions will be executed every time the emitter object emits their associated name event.

Events !== Asynchrony

Let’s take a look at an example:

const EventEmitter = require('events'); class WithLog extends EventEmitter { execute(taskFunc) { console.log('Before executing'); this.emit('begin'); taskFunc(); this.emit('end'); console.log('After executing'); } } const withLog = new WithLog(); withLog.on('begin', () => console.log('About to execute')); withLog.on('end', () => console.log('Done with execute')); withLog.execute(() => console.log('*** Executing task ***'));

Class WithLog is an event emitter. It defines one instance function execute. This execute function receives one argument, a task function, and wraps its execution with log statements. It fires events before and after the execution.

To see the sequence of what will happen here, we register listeners on both named events and finally execute a sample task to trigger things.

Here’s the output of that:

Before executing About to execute *** Executing task *** Done with execute After executing

What I want you to notice about the output above is that it all happens synchronously. There is nothing asynchronous about this code.

  • We get the “Before executing” line first.
  • The begin named event then causes the “About to execute” line.
  • The actual execution line then outputs the “*** Executing task ***” line.
  • The end named event then causes the “Done with execute” line
  • We get the “After executing” line last.

Just like plain-old callbacks, do not assume that events mean synchronous or asynchronous code.

This is important, because if we pass an asynchronous taskFunc to execute, the events emitted will no longer be accurate.

We can simulate the case with a setImmediate call:

// ... withLog.execute(() => { setImmediate(() => { console.log('*** Executing task ***') }); });

Now the output would be:

Before executing About to execute Done with execute After executing *** Executing task ***

This is wrong. The lines after the async call, which were caused the “Done with execute” and “After executing” calls, are not accurate any more.

To emit an event after an asynchronous function is done, we’ll need to combine callbacks (or promises) with this event-based communication. The example below demonstrates that.

One benefit of using events instead of regular callbacks is that we can react to the same signal multiple times by defining multiple listeners. To accomplish the same with callbacks, we have to write more logic inside the single available callback. Events are a great way for applications to allow multiple external plugins to build functionality on top of the application’s core. You can think of them as hook points to allow for customizing the story around a state change.

Asynchronous Events

Let’s convert the synchronous sample example into something asynchronous and a little bit more useful.

const fs = require('fs'); const EventEmitter = require('events'); class WithTime extends EventEmitter { execute(asyncFunc, ...args) { this.emit('begin'); console.time('execute'); asyncFunc(...args, (err, data) => { if (err) { return this.emit('error', err); } this.emit('data', data); console.timeEnd('execute'); this.emit('end'); }); } } const withTime = new WithTime(); withTime.on('begin', () => console.log('About to execute')); withTime.on('end', () => console.log('Done with execute')); withTime.execute(fs.readFile, __filename);

The WithTime class executes an asyncFunc and reports the time that’s taken by that asyncFunc using console.time and console.timeEnd calls. It emits the right sequence of events before and after the execution. And also emits error/data events to work with the usual signals of asynchronous calls.

We test a withTime emitter by passing it an fs.readFile call, which is an asynchronous function. Instead of handling file data with a callback, we can now listen to the data event.

When we execute this code , we get the right sequence of events, as expected, and we get a reported time for the execution, which is helpful:

About to execute execute: 4.507ms Done with execute

Note how we needed to combine a callback with an event emitter to accomplish that. If the asynFunc supported promises as well, we could use the async/await feature to do the same:

class WithTime extends EventEmitter { async execute(asyncFunc, ...args) { this.emit('begin'); try { console.time('execute'); const data = await asyncFunc(...args); this.emit('data', data); console.timeEnd('execute'); this.emit('end'); } catch(err) { this.emit('error', err); } } }

I don’t know about you, but this is much more readable to me than the callback-based code or any .then/.catch lines. The async/await feature brings us as close as possible to the JavaScript language itself, which I think is a big win.

Events Arguments and Errors

In the previous example, there were two events that were emitted with extra arguments.

The error event is emitted with an error object.

this.emit('error', err);

The data event is emitted with a data object.

this.emit('data', data);

We can use as many arguments as we need after the named event, and all these arguments will be available inside the listener functions we register for these named events.

For example, to work with the data event, the listener function that we register will get access to the data argument that was passed to the emitted event and that data object is exactly what the asyncFunc exposes.

withTime.on('data', (data) => { // do something with data });

The error event is usually a special one. In our callback-based example, if we don’t handle the error event with a listener, the node process will actually exit.

To demonstrate that, make another call to the execute method with a bad argument:

class WithTime extends EventEmitter { execute(asyncFunc, ...args) { console.time('execute'); asyncFunc(...args, (err, data) => { if (err) { return this.emit('error', err); // Not Handled } console.timeEnd('execute'); }); } } const withTime = new WithTime(); withTime.execute(fs.readFile, ''); // BAD CALL withTime.execute(fs.readFile, __filename);

The first execute call above will trigger an error. The node process is going to crash and exit:

events.js:163 throw er; // Unhandled 'error' event ^ Error: ENOENT: no such file or directory, open ''

The second execute call will be affected by this crash and will potentially not get executed at all.

If we register a listener for the special error event, the behavior of the node process will change. For example:

withTime.on('error', (err) => { // do something with err, for example log it somewhere console.log(err) });

If we do the above, the error from the first execute call will be reported but the node process will not crash and exit. The other execute call will finish normally:

{ Error: ENOENT: no such file or directory, open '' errno: -2, code: 'ENOENT', syscall: 'open', path: '' } execute: 4.276ms

Note that Node currently behaves differently with promise-based functions and just outputs a warning, but that will eventually change:

UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: ENOENT: no such file or directory, open '' DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

The other way to handle exceptions from emitted errors is to register a listener for the global uncaughtException process event. However, catching errors globally with that event is a bad idea.

The standard advice about uncaughtException is to avoid using it, but if you must do (say to report what happened or do cleanups), you should just let the process exit anyway:

process.on('uncaughtException', (err) => { // something went unhandled. // Do any cleanup and exit anyway! console.error(err); // don't do just that. // FORCE exit the process too. process.exit(1); });

However, imagine that multiple error events happen at the exact same time. This means the uncaughtException listener above will be triggered multiple times, which might be a problem for some cleanup code. An example of this is when multiple calls are made to a database shutdown action.

The EventEmitter module exposes a once method. This method signals to invoke the listener just once, not every time it happens. So, this is a practical use case to use with the uncaughtException because with the first uncaught exception we’ll start doing the cleanup and we know that we’re going to exit the process anyway.

Order of Listeners

If we register multiple listeners for the same event, the invocation of those listeners will be in order. The first listener that we register is the first listener that gets invoked.

// प्रथम withTime.on('data', (data) => { console.log(`Length: ${data.length}`); }); // दूसरा withTime.on('data', (data) => { console.log(`Characters: ${data.toString().length}`); }); withTime.execute(fs.readFile, __filename);

The above code will cause the “Length” line to be logged before the “Characters” line, because that’s the order in which we defined those listeners.

If you need to define a new listener, but have that listener invoked first, you can use the prependListener method:

// प्रथम withTime.on('data', (data) => { console.log(`Length: ${data.length}`); }); // दूसरा withTime.prependListener('data', (data) => { console.log(`Characters: ${data.toString().length}`); }); withTime.execute(fs.readFile, __filename);

The above will cause the “Characters” line to be logged first.

And finally, if you need to remove a listener, you can use the removeListener method.

That’s all I have for this topic. Thanks for reading! Until next time!

Learning React or Node? Checkout my books:

  • Learn React.js by Building Games
  • Node.js Beyond the Basics