Comprendre l'architecture événementielle Node.js

Mise à jour: Cet article fait maintenant partie de mon livre «Node.js Beyond The Basics». Lisez la version mise à jour de ce contenu et plus sur Node sur jscomplete.com/node-beyond-basics .

La plupart des objets de Node - comme les requêtes HTTP, les réponses et les flux - implémentent le EventEmittermodule afin qu'ils puissent fournir un moyen d'émettre et d'écouter des événements.

La forme la plus simple de la nature événementielle est le style de rappel de quelques - unes des fonctions Node.js populaires - par exemple fs.readFile. Dans cette analogie, l'événement sera déclenché une fois (lorsque Node est prêt à appeler le rappel) et le rappel agit comme le gestionnaire d'événements.

Explorons d'abord cette forme de base.

Appelez-moi quand vous êtes prêt, Node!

La manière originale dont Node a géré les événements asynchrones était avec le rappel. C'était il y a longtemps, avant que JavaScript ne prenne en charge les promesses natives et la fonction async / await.

Les rappels sont essentiellement des fonctions que vous passez à d'autres fonctions. Cela est possible en JavaScript car les fonctions sont des objets de première classe.

Il est important de comprendre que les rappels n'indiquent pas un appel asynchrone dans le code. Une fonction peut appeler le rappel à la fois de manière synchrone et asynchrone.

Par exemple, voici une fonction hôte fileSizequi accepte une fonction de rappel cbet peut appeler cette fonction de rappel à la fois de manière synchrone et asynchrone en fonction d'une condition:

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 }); }

Notez qu'il s'agit d'une mauvaise pratique qui entraîne des erreurs inattendues. Concevez des fonctions hôtes pour utiliser le rappel de manière toujours synchrone ou toujours asynchrone.

Explorons un exemple simple de fonction de nœud asynchrone typique écrite avec un style de rappel:

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); }); };

readFileAsArrayprend un chemin de fichier et une fonction de rappel. Il lit le contenu du fichier, le divise en un tableau de lignes et appelle la fonction de rappel avec ce tableau.

Voici un exemple d'utilisation pour cela. En supposant que nous avons le fichier numbers.txtdans le même répertoire avec un contenu comme celui-ci:

10 11 12 13 14 15

Si nous avons une tâche pour compter les nombres impairs dans ce fichier, nous pouvons utiliser readFileAsArraypour simplifier le code:

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); });

Le code lit le contenu des nombres dans un tableau de chaînes, les analyse comme des nombres et compte les impairs.

Le style de rappel de Node est utilisé uniquement ici. Le rappel a un argument de première erreur errqui peut être nul et nous passons le rappel comme dernier argument de la fonction hôte. Vous devriez toujours le faire dans vos fonctions car les utilisateurs le supposeront probablement. Faire en sorte que la fonction hôte reçoive le rappel comme son dernier argument et que le rappel s'attende à un objet d'erreur comme premier argument.

L'alternative JavaScript moderne aux rappels

En JavaScript moderne, nous avons des objets prometteurs. Les promesses peuvent être une alternative aux rappels pour les API asynchrones. Au lieu de passer un rappel en tant qu'argument et de gérer l'erreur au même endroit, un objet de promesse nous permet de gérer séparément les cas de succès et d'erreur et nous permet également d'enchaîner plusieurs appels asynchrones au lieu de les imbriquer.

Si la readFileAsArrayfonction prend en charge les promesses, nous pouvons l'utiliser comme suit:

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);

Au lieu de passer une fonction de rappel, nous avons appelé une .thenfonction sur la valeur de retour de la fonction hôte. Cette .thenfonction nous donne généralement accès au même tableau de lignes que nous obtenons dans la version de rappel, et nous pouvons faire notre traitement dessus comme avant. Pour gérer les erreurs, nous ajoutons un .catchappel sur le résultat et cela nous donne accès à une erreur lorsqu'elle se produit.

Faire en sorte que la fonction hôte supporte une interface de promesse est plus facile dans JavaScript moderne grâce au nouvel objet Promise. Voici la readFileAsArrayfonction modifiée pour prendre en charge une interface de promesse en plus de l'interface de rappel qu'elle prend déjà en charge:

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); }); }); };

Nous faisons donc en sorte que la fonction renvoie un objet Promise, qui encapsule l' fs.readFileappel asynchrone. L'objet de promesse expose deux arguments, une resolvefonction et une rejectfonction.

Chaque fois que nous voulons appeler le rappel avec une erreur, nous utilisons également la rejectfonction de promesse , et chaque fois que nous voulons invoquer le rappel avec des données, nous utilisons également la resolvefonction de promesse .

La seule autre chose que nous devions faire dans ce cas est d'avoir une valeur par défaut pour cet argument de rappel au cas où le code serait utilisé avec l'interface promise. Nous pouvons utiliser une simple fonction vide par défaut dans l'argument de ce cas: () =>{}.

Consommer des promesses avec async / await

L'ajout d'une interface de promesse rend votre code beaucoup plus facile à utiliser lorsqu'il est nécessaire de boucler sur une fonction asynchrone. Avec les rappels, les choses deviennent désordonnées.

Les promesses améliorent un peu cela, et les générateurs de fonctions améliorent un peu plus cela. Cela dit, une alternative plus récente au travail avec du code asynchrone consiste à utiliser la asyncfonction, qui nous permet de traiter le code asynchrone comme s'il était synchrone, ce qui le rend globalement beaucoup plus lisible.

Voici comment nous pouvons utiliser la readFileAsArrayfonction avec 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();

Nous créons d'abord une fonction asynchrone, qui est juste une fonction normale avec le mot asyncdevant elle. À l'intérieur de la fonction async, nous appelons la readFileAsArrayfonction comme si elle retournait la variable lines, et pour que cela fonctionne, nous utilisons le mot-clé await. Après cela, nous continuons le code comme si l' readFileAsArrayappel était synchrone.

Pour faire fonctionner les choses, nous exécutons la fonction async. C'est très simple et plus lisible. Pour travailler avec des erreurs, nous devons encapsuler l'appel async dans une instruction try/ catch.

Avec cette fonctionnalité async / await, nous n'avons pas eu besoin d'utiliser d'API spéciale (comme .then et .catch). Nous avons simplement étiqueté les fonctions différemment et utilisé du JavaScript pur pour le code.

We can use the async/await feature with any function that supports a promise interface. However, we can’t use it with callback-style async functions (like setTimeout for example).

The EventEmitter Module

The EventEmitter is a module that facilitates communication between objects in Node. EventEmitter is at the core of Node asynchronous event-driven architecture. Many of Node’s built-in modules inherit from EventEmitter.

The concept is simple: emitter objects emit named events that cause previously registered listeners to be called. So, an emitter object basically has two main features:

  • Emitting name events.
  • Registering and unregistering listener functions.

To work with the EventEmitter, we just create a class that extends EventEmitter.

class MyEmitter extends EventEmitter {}

Emitter objects are what we instantiate from the EventEmitter-based classes:

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