Forståelse af Node.js hændelsesdrevet arkitektur

Opdatering: Denne artikel er nu en del af min bog "Node.js Beyond The Basics". Læs den opdaterede version af dette indhold og mere om Node på jscomplete.com/node-beyond-basics .

De fleste af Nodes objekter - som HTTP-anmodninger, svar og streams - implementerer EventEmittermodulet, så de kan give en måde at udsende og lytte til begivenheder på.

Den enkleste form for begivenhedsdrevet natur er tilbagekaldelsesstil for nogle af de populære Node.js-funktioner - for eksempel fs.readFile. I denne analogi udløses begivenheden en gang (når Node er klar til at ringe tilbage), og tilbagekaldet fungerer som begivenhedshåndterer.

Lad os udforske denne grundlæggende form først.

Ring til mig, når du er klar, Node!

Den oprindelige måde, hvorpå Node håndterede asynkrone begivenheder, var med tilbagekald. Dette var for længe siden, før JavaScript havde understøttelse af native-løfter og async / wait-funktionen.

Callbacks er stort set bare funktioner, som du overfører til andre funktioner. Dette er muligt i JavaScript, fordi funktioner er førsteklasses objekter.

Det er vigtigt at forstå, at tilbagekald ikke angiver et asynkront opkald i koden. En funktion kan kalde tilbagekaldet både synkront og asynkront.

For eksempel er her en værtsfunktion, fileSizeder accepterer en tilbagekaldsfunktion cbog kan påberåbe sig den tilbagekaldsfunktion både synkront og asynkront baseret på en tilstand:

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

Bemærk, at dette er en dårlig praksis, der fører til uventede fejl. Design værtsfunktioner til at forbruge tilbagekald enten altid synkront eller altid asynkront.

Lad os undersøge et simpelt eksempel på en typisk asynkron node-funktion, der er skrevet med en tilbagekaldningsstil:

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

readFileAsArraytager en filsti og en tilbagekaldsfunktion. Den læser filindholdet, opdeler det i en række linjer og kalder tilbagekaldsfunktionen sammen med den matrix.

Her er et eksempel, der bruges til det. Forudsat at vi har filen numbers.txti samme bibliotek med indhold som dette:

10 11 12 13 14 15

Hvis vi har en opgave at tælle de ulige numre i den fil, kan vi bruge readFileAsArraytil at forenkle koden:

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

Koden læser nummerindholdet i en række strenge, analyserer dem som tal og tæller de ulige.

Nodeens tilbagekaldestil bruges rent her. Tilbagekaldelsen har et fejl-første argument, errder er ugyldigt, og vi sender tilbagekaldet som det sidste argument for værtsfunktionen. Du skal altid gøre det i dine funktioner, fordi brugerne sandsynligvis antager det. Få værtsfunktionen til at modtage tilbagekaldelsen som sit sidste argument, og få tilbagekaldet til at forvente et fejlobjekt som sit første argument.

Det moderne JavaScript-alternativ til tilbagekald

I moderne JavaScript har vi løfteobjekter. Løfter kan være et alternativ til tilbagekald til asynkrone API'er. I stedet for at give et tilbagekald som et argument og håndtere fejlen samme sted, giver et løfteobjekt os mulighed for at håndtere succes- og fejlsager separat, og det giver os også mulighed for at kæde flere asynkrone opkald i stedet for at indlejre dem.

Hvis readFileAsArrayfunktionen understøtter løfter, kan vi bruge den som følger:

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

I stedet for at sende en tilbagekaldsfunktion kaldte vi en .thenfunktion på værtsfunktionens returværdi. Denne .thenfunktion giver os normalt adgang til det samme linjearray, som vi får i tilbagekaldsversionen, og vi kan behandle det som før. For at håndtere fejl tilføjer vi et .catchopkald til resultatet, og det giver os adgang til en fejl, når det sker.

At gøre værtsfunktionen understøtter et løftegrænseflade er lettere i moderne JavaScript takket være det nye Promise-objekt. Her er den readFileAsArrayfunktion, der er ændret til at understøtte en løftegrænseflade ud over den tilbagekaldsgrænseflade, den allerede understøtter:

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

Så vi får funktionen til at returnere et Promise-objekt, der omslutter fs.readFileasync-opkaldet. Løfteobjektet afslører to argumenter, en resolvefunktion og en rejectfunktion.

Når vi ønsker at påkalde tilbagekaldet med en fejl, bruger vi også løftefunktionen reject, og når vi vil påkalde tilbagekaldet med data, bruger vi også løftefunktionen resolve.

Den eneste anden ting, vi skulle gøre i dette tilfælde, er at have en standardværdi for dette tilbagekaldsargument, hvis koden bruges sammen med løftegrænsefladen. Vi kan bruge en simpel, standard tom funktion i argumentet for den sag: () =>{}.

Forbruger løfter med asynkronisering / afvent

Tilføjelse af et løfteinterface gør din kode meget nemmere at arbejde med, når der er behov for at løbe over en async-funktion. Med tilbagekald bliver tingene rodet.

Løfter forbedrer det lidt, og funktionsgeneratorer forbedrer det lidt mere. Dette er sagt, et nyere alternativ til at arbejde med async-kode er at bruge asyncfunktionen, som giver os mulighed for at behandle async-kode som om den var synkron, hvilket gør den meget mere læselig generelt.

Sådan kan vi forbruge readFileAsArrayfunktionen med asynkronisering / afventer:

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

Vi opretter først en async-funktion, som bare er en normal funktion med ordet asyncforan den. Inde i async-funktionen kalder vi readFileAsArrayfunktionen som om den returnerer linjevariablen, og for at få det til at fungere bruger vi nøgleordet await. Derefter fortsætter vi koden som om readFileAsArrayopkaldet var synkron.

For at få ting til at køre, udfører vi async-funktionen. Dette er meget simpelt og mere læsbart. For at arbejde med fejl skal vi indpakke async-opkaldet i en try/ catchsætning.

Med denne async / afvent-funktion behøvede vi ikke bruge nogen speciel API (som .then og .catch). Vi har lige mærket funktioner forskelligt og brugt ren JavaScript til koden.

Vi kan bruge async / wait-funktionen med enhver funktion, der understøtter en interface til løfte. Vi kan dog ikke bruge det med tilbagekald-async-funktioner (som f.eks. SetTimeout).

EventEmitter-modulet

EventEmitter er et modul, der letter kommunikation mellem objekter i Node. EventEmitter er kernen i Node asynkron hændelsesdrevet arkitektur. Mange af Nodes indbyggede moduler arver fra EventEmitter.

Konceptet er simpelt: emitterobjekter udsender navngivne begivenheder, der får kaldt til tidligere registrerede lyttere. Så et emitterobjekt har stort set to hovedfunktioner:

  • Udsendelse af navnehændelser.
  • Registrering og afregistrering af lytterfunktioner.

For at arbejde med EventEmitter opretter vi bare en klasse, der udvider EventEmitter.

class MyEmitter extends EventEmitter {}

Emitterobjekter er det, vi instantierer fra de EventEmitter-baserede klasser:

const myEmitter = new MyEmitter();

På ethvert tidspunkt i livscyklussen for disse emitterobjekter kan vi bruge emitteringsfunktionen til at udsende enhver navngiven begivenhed, vi ønsker.

myEmitter.emit('something-happened');

Udsendelse af en begivenhed er signalet om, at der er opstået en tilstand. Denne betingelse handler normalt om en tilstandsændring i det emitterende objekt.

Vi kan tilføje lytterfunktioner ved hjælp af onmetoden, og disse lytterfunktioner udføres hver gang emitterobjektet udsender deres tilknyttede navnehændelse.

Begivenheder! == Asynkroni

Lad os se på et eksempel:

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 WithLoger en begivenhedsudsender. Det definerer en instansfunktion execute. Denne executefunktion modtager et argument, en opgavefunktion, og indpakker dens udførelse med logsætninger. Det affyrer begivenheder før og efter henrettelsen.

For at se sekvensen af, hvad der vil ske her, registrerer vi lyttere på begge navngivne begivenheder og udfører endelig en prøveopgave for at udløse ting.

Her er resultatet af det:

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

Hvad jeg vil have dig til at bemærke om output ovenfor er, at det hele sker synkront. Der er ikke noget asynkront ved denne kode.

  • Vi får først linjen "Før udførelse".
  • Den beginnavngivne begivenhed får derefter linjen "Om at udføre".
  • Den aktuelle udførelseslinje udsender derefter linjen "*** Udførelse af opgave ***".
  • Den endnavngivne begivenhed forårsager derefter linjen "Udført med udførelse"
  • Vi får linjen "Efter udførelse" sidst.

Ligesom almindelige gamle tilbagekald skal du ikke antage, at begivenheder betyder synkron eller asynkron kode.

Dette er vigtigt, for hvis vi videregiver en asynkron taskFunctil execute, vil de udsendte begivenheder ikke længere være nøjagtige.

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

Ovenstående vil medføre, at linjen "Tegn" først logges.

Og endelig, hvis du har brug for at fjerne en lytter, kan du bruge removeListenermetoden.

Det er alt, hvad jeg har til dette emne. Tak for læsningen! Indtil næste gang!

Learning React eller Node? Tjek mine bøger:

  • Lær React.js ved at bygge spil
  • Node.js ud over det grundlæggende