A Node.js eseményvezérelt architektúrájának megértése

Frissítés: Ez a cikk most a „Node.js Beyond The Basics” című könyvem része. Olvassa el a tartalom frissített verzióját és többet a Node-ról a jscomplete.com/node-beyond-basics címen .

A Node legtöbb objektuma - mint például a HTTP kérések, válaszok és adatfolyamok - úgy valósítja meg a EventEmittermodult, hogy módot adjon az események kibocsátására és meghallgatására.

Az eseményvezérelt természet legegyszerűbb formája néhány népszerű Node.js függvény visszahívási stílusa - például fs.readFile. Ebben az analógiában az esemény egyszer elindul (amikor a Node készen áll a visszahívás hívására), és a visszahívás eseménykezelőként működik.

Először fedezzük fel ezt az alapformát.

Hívjon, ha készen áll, Node!

A Node aszinkron események eredeti kezelési módja a visszahívás volt. Ez már nagyon régen volt, még mielőtt a JavaScript natív ígéretet nyújtott volna a támogatásra és az async / wait funkcióra.

A visszahívások alapvetően csak olyan funkciók, amelyeket más funkcióknak ad át. Ez azért lehetséges a JavaScript-ben, mert a függvények első osztályú objektumok.

Fontos megérteni, hogy a visszahívások nem jelentenek aszinkron hívást a kódban. Egy funkció hívhatja a visszahívást mind szinkron, mind aszinkron módon.

Például itt van egy gazdagépfunkció, fileSizeamely elfogad egy visszahívási funkciót, cbés egy feltétel alapján szinkron és aszinkron módon is meghívhatja ezt a visszahívási funkciót:

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

Vegye figyelembe, hogy ez egy rossz gyakorlat, amely váratlan hibákhoz vezet. Hosztfunkciók tervezése a visszahívás elfogyasztására, akár mindig szinkron vagy mindig aszinkron módon.

Fedezzünk fel egy tipikus aszinkron Node függvény egyszerű példáját, amelyet visszahívási stílusban írunk:

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

readFileAsArrayfájl útvonalat és visszahívási funkciót vesz igénybe. Elolvassa a fájl tartalmát, felosztja egy sor tömbre, és meghívja a visszahívási funkciót ezzel a tömbvel.

Itt van egy példa rá. Feltéve, hogy a fájl numbers.txtugyanabban a könyvtárban van, ilyen tartalommal:

10 11 12 13 14 15

Ha van feladatunk megszámolni a fájl páratlan számait, használhatjuk readFileAsArraya kód egyszerűsítésére:

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

A kód beolvassa a számok tartalmát egy tömb karakterláncba, elemzi számokká és megszámolja a páratlanokat.

A Node visszahívási stílusát kizárólag itt használjuk. A visszahívás hibajavító argumentummal rendelkezik, erramely semmissé válik, és a visszahívást a gazdagép utolsó argumentumaként adjuk át. Mindig ezt kell tennie a funkcióiban, mert a felhasználók valószínűleg ezt feltételezik. Tegye a gazda függvényt a visszahívás utolsó argumentumává, és a visszahívást tegye hibajavításra első argumentumként.

A visszahívások modern JavaScript-alternatívája

A modern JavaScript-ben ígéretes objektumaink vannak. Az ígéretek alternatívája lehet az aszinkron API-k visszahívásának. Ahelyett, hogy a visszahívást argumentumként továbbítanánk, és a hibát ugyanabban a helyen kezelnénk, az ígéret objektum lehetővé teszi számunkra, hogy külön kezeljük a sikert és a hibákat, és több aszinkron hívást is láncolhatunk fészkelés helyett.

Ha a readFileAsArrayfüggvény támogatja az ígéreteket, akkor az alábbiak szerint használhatjuk:

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

Ahelyett, hogy visszahívási függvényt adnánk át, meghívtunk egy .thenfüggvényt a gazdagép függvény visszatérési értékére. Ez a .thenfüggvény általában ugyanahhoz a vonalsorozathoz nyújt hozzáférést, amelyet a visszahívási verzióban kapunk, és ezen tudjuk elvégezni a feldolgozásunkat, mint korábban. A hibák kezeléséhez hozzáadunk egy .catchhívást az eredményhez, és ez hozzáférést biztosít számunkra egy hibához, ha ez megtörténik.

Az új Promise objektumnak köszönhetően a gazda funkció ígéretes felület támogatása könnyebb a modern JavaScript-ben. Az readFileAsArrayígéretfelület támogatására módosított funkció a már támogatott visszahívási felület mellett

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

Tehát a függvénynek egy Promise objektumot adunk vissza, amely az fs.readFileaszinkron hívást lezárja . Az ígéret objektum két argumentumot tár fel, egy resolvefüggvényt és egy rejectfüggvényt.

Amikor a visszahívást hibával akarjuk meghívni, használjuk az ígéret rejectfunkciót is, és amikor csak a visszahívást szeretnénk adatokkal hívni, akkor az ígéret resolvefunkciót is használjuk.

Az egyetlen dolog, amit meg kellett tennünk ebben az esetben, hogy legyen alapértelmezett értéke ennek a visszahívási argumentumnak abban az esetben, ha a kódot az ígéretfelülethez használják. Egy egyszerű, alapértelmezett üres függvényt használhatunk az adott eset argumentumában: () =>{}.

Az ígéretek fogyasztása aszinkron / várakozással

Ígéretfelület hozzáadásával a kódod sokkal könnyebben kezelhető, ha szükség van egy aszinkron függvény átkötésére. Visszahívásokkal a dolgok rendetlenné válnak.

Az ígéretek ezt egy kicsit javítják, a funkciógenerátorok pedig még egy kicsit. Ez azt jelenti, hogy az aszinkron kóddal való munkavégzés egy újabb alternatívája a asyncfunkció használata , amely lehetővé teszi számunkra, hogy az aszinkron kódot úgy kezeljük, mintha szinkron lenne, így összességében sokkal olvashatóbbá válna.

Így tudjuk felhasználni a readFileAsArrayfüggvényt az async / await használatával:

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

Először létrehozunk egy aszinkron függvényt, amely csak egy normális függvény az asyncelőtte lévő szóval . Az async függvényen belül úgy hívjuk a readFileAsArrayfüggvényt, mintha a vonalak változót adná vissza, és hogy ez működjön, a kulcsszót használjuk await. Ezt követően folytatjuk a kódot, mintha a readFileAsArrayhívás szinkron lenne.

A dolgok futtatásához végrehajtjuk az async függvényt. Ez nagyon egyszerű és olvashatóbb. A hibák kezeléséhez be kell csomagolnunk az async hívást egy try/ catchutasításba.

Ezzel az async / await funkcióval nem kellett külön API-t használnunk (például .then és .catch). Csak másképp címkéztük a függvényeket, és tiszta JavaScriptet használtunk a kódhoz.

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