Node.js sündmuspõhise arhitektuuri mõistmine

Värskendus: see artikkel on nüüd osa minu raamatust “Node.js Beyond The Basics”. Lugege selle sisu värskendatud versiooni ja lisateavet Node'i kohta aadressil jscomplete.com/node-beyond-basics .

Enamik Node'i objekte - nagu HTTP-päringud, vastused ja voogud - rakendavad EventEmittermoodulit, nii et nad saavad pakkuda viisi sündmuste emiteerimiseks ja kuulamiseks.

Sündmuspõhise olemuse lihtsaim vorm on mõnede populaarsete Node.js funktsioonide tagasihelistamisstiil - näiteks fs.readFile. Selles analoogias käivitatakse sündmus üks kord (kui sõlm on helistamiseks valmis) ja tagasihelistamine toimib sündmuste käitlejana.

Uurime kõigepealt seda põhivormi.

Helistage mulle, kui olete valmis, Node!

Algne viis, kuidas Node asünkroonseid sündmusi käsitles, oli tagasihelistamisega. See oli kaua aega tagasi, enne kui JavaScripti lubas tugi ja funktsioon async / await.

Tagasihelistamised on põhimõtteliselt lihtsalt funktsioonid, mille edastate teistele funktsioonidele. See on JavaScriptis võimalik, kuna funktsioonid on esmaklassilised objektid.

Oluline on mõista, et tagasihelistamised ei tähenda koodis asünkroonset kõnet. Funktsioon võib helistada tagasihelistamiseks nii sünkroonselt kui ka asünkroonselt.

Näiteks on siin hostfunktsioon, fileSizemis aktsepteerib tagasihelistamisfunktsiooni cbja saab tingimusest lähtuvalt seda tagasihelistamisfunktsiooni käivitada nii sünkroonselt kui ka asünkroonselt:

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

Pange tähele, et see on halb tava, mis toob kaasa ootamatuid vigu. Kujundage hostfunktsioonid tagasihelistamise tarbimiseks kas alati sünkroonselt või alati asünkroonselt.

Uurime lihtsa näite tüüpilise asünkroonse sõlme funktsiooni kohta, mis on kirjutatud tagasihelistamisstiiliga:

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

readFileAsArrayvõtab faili tee ja tagasihelistamisfunktsiooni. See loeb faili sisu, jagab selle reade reaks ja kutsub selle massiivi abil tagasi helistamise funktsiooni.

Siin on näide selle kasutamiseks. Eeldades, et meil on fail numbers.txtsama sisuga kataloogis:

10 11 12 13 14 15

Kui meil on ülesanne loendada selles failis olevaid paarituid numbreid, saame readFileAsArrayselle abil koodi lihtsustada:

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

Kood loeb numbrite sisu stringide massiiviks, sõelub need numbriteks ja loeb paarituid.

Node tagasihelistamisstiili kasutatakse puhtalt siin. Tagasihelistamisel on vea esimene argument, errmis on tühine ja me edastame tagasihelistuse hostifunktsiooni viimase argumendina. Peaksite seda alati tegema oma funktsioonides, sest kasutajad eeldavad seda tõenäoliselt. Laske hostfunktsioonil tagasihelistamine vastu võtta viimase argumendina ja laske tagasihelistamisel esimese argumendina oodata tõrkeobjekti.

Kaasaegne JavaScripti alternatiiv tagasihelistamistele

Kaasaegses JavaScriptis on meil lubaduse objektid. Lubadused võivad olla asünkroonsete API-de tagasihelistamise alternatiiviks. Selle asemel, et tagasihelistamine argumendina edastada ja viga samas kohas käsitseda, lubab lubaduse objekt edukus- ja veajuhtumeid käsitleda eraldi ning see võimaldab meil ka mitu asünkroonseid kõnesid aheldada pesitsemise asemel.

Kui readFileAsArrayfunktsioon toetab lubadusi, saame seda kasutada järgmiselt:

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

Tagasihelistamisfunktsiooni edastamise asemel kutsusime .thenfunktsiooni hostfunktsiooni tagastusväärtusele. See .thenfunktsioon annab meile tavaliselt juurdepääsu samale liinimassiivile, mille saame tagasihelistamisversioonis, ja saame sellega töödelda nagu varem. Vigade käsitlemiseks lisame tulemusele .catchkõne ja see annab meile juurdepääsu veale, kui see juhtub.

Tänu uuele objektile Promise on tänapäevases JavaScripti abil lihtsam muuta hostifunktsioon lubaduste liideseks. Siin on readFileAsArrayfunktsioon, mida on muudetud lubatava liidese toetamiseks lisaks tagasihelistamisliidesele, mida see juba toetab:

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

Seega paneme funktsiooni tagastama objekti Promise, mis ümbritseb fs.readFileasünkroonse kõne. Lubaduse objekt paljastab kaks argumenti, resolvefunktsiooni ja rejectfunktsiooni.

Alati, kui tahame helistada tagasihelistamisel veaga, kasutame ka lubaduse rejectfunktsiooni ja alati, kui tahame tagasihelistamist andmetega kutsuda, kasutame ka lubadusfunktsiooni resolve.

Ainus asi, mida me sel juhul pidime tegema, on selle tagasihelistusargumendi vaikeväärtus, juhul kui koodi kasutatakse lubaduse liidesega. Selle juhtumi argumendis võime kasutada lihtsat vaikimisi tühja funktsiooni: () =>{}.

Lubaduste tarbimine asünkroonimise / ootamise korral

Lubaduse liidese lisamine muudab teie koodi töötamise palju lihtsamaks, kui on vaja asünkroonfunktsiooni üle vaadata. Tagasihelistamise korral muutuvad asjad sassi.

Lubadused parandavad seda natuke ja funktsioonigeneraatorid parandavad seda natuke rohkem. See tähendab, et asünkroonkoodiga töötamise uuem alternatiiv on asyncfunktsiooni kasutamine , mis võimaldab meil asünkroonkoodi käsitleda nii, nagu see oleks sünkroonne, muutes selle üldiselt palju loetavamaks.

readFileAsArrayFunktsiooni async / await abil saame tarbida järgmiselt :

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

Kõigepealt loome asünkroonfunktsiooni, mis on lihtsalt tavaline funktsioon, mille ees on sõna async. Async-funktsiooni sees kutsume readFileAsArrayfunktsiooni nii, nagu tagastaks read muutuja, ja selle toimimiseks kasutame märksõna await. Pärast seda jätkame koodi nii, nagu oleks readFileAsArraykõne sünkroonne.

Asjade käivitamiseks käivitame asünkroonimisfunktsiooni. See on väga lihtne ja loetavam. Vigadega töötamiseks peame asünkroonse kõne pakkima avaldisse try/ catch.

Selle funktsiooniga async / await ei pidanud me kasutama spetsiaalset API-d (nt .then ja .catch). Märgistasime funktsioonid lihtsalt erinevalt ja kasutasime koodi jaoks puhast JavaScripti.

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

Ülaltoodu tõttu logitakse kõigepealt rida „Tähemärgid”.

Ja lõpuks, kui peate kuulaja eemaldama, saate removeListenermeetodit kasutada .

See on kõik, mis mul selle teema jaoks on. Täname lugemast! Järgmise korrani!

Reaktsiooni või sõlme õppimine? Vaadake minu raamatuid:

  • React.js saate teada, ehitades mänge
  • Node.js põhitõdedest kaugemale