Izpratne par Node.js uz notikumiem balstītu arhitektūru

Atjauninājums: Šis raksts tagad ir daļa no manas grāmatas “Node.js Beyond The Basics” . Izlasiet šī satura atjaunināto versiju un vairāk par Node vietnē jscomplete.com/node-beyond-basics .

Lielākā daļa mezgla objektu, piemēram, HTTP pieprasījumi, atbildes un straumes, ievieš EventEmittermoduli, lai tie varētu nodrošināt veidu, kā izstarot un klausīties notikumus.

Vienkāršākais notikumu virzītais veids ir dažu populāro Node.js funkciju atzvanīšanas stils, piemēram fs.readFile,. Šajā analoģijā notikums tiks aktivizēts vienu reizi (kad mezgls ir gatavs izsaukt atzvanīšanu), un atzvanīšana darbojas kā notikumu apstrādātājs.

Vispirms izpētīsim šo pamatformu.

Zvaniet man, kad esat gatavs, Mezgls!

Sākotnējais veids, kā mezgls rīkojās ar asinhroniem notikumiem, bija ar atzvanīšanu. Tas bija ļoti sen, pirms JavaScript bija vietējo solījumu atbalsts un async / await funkcija.

Atzvani galvenokārt ir tikai funkcijas, kuras jūs nododat citām funkcijām. Tas ir iespējams JavaScript, jo funkcijas ir pirmās klases objekti.

Ir svarīgi saprast, ka atzvanīšana kodā nenorāda uz asinhronu zvanu. Funkcija var izsaukt atzvanīšanu gan sinhroni, gan asinhroni.

Piemēram, šeit ir resursdatora funkcija, fileSizekas pieņem atzvanīšanas funkciju cbun var atsaukt šo atzvanīšanas funkciju gan sinhroni, gan asinhroni, pamatojoties uz nosacījumu:

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

Ņemiet vērā, ka tā ir slikta prakse, kas noved pie negaidītām kļūdām. Izstrādājiet resursdatora funkcijas, lai patērētu atzvanīšanu vienmēr sinhroni vai vienmēr asinhroni.

Izpētīsim vienkāršu tipiskas asinhronās mezgla funkcijas piemēru, kas rakstīts ar atzvanīšanas stilu:

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

readFileAsArrayaizņem faila ceļu un atzvanīšanas funkciju. Tas nolasa faila saturu, sadala to rindu masīvā un izsauc atzvana funkciju ar šo masīvu.

Šeit ir piemērs tam. Pieņemot, ka fails numbers.txtatrodas vienā direktorijā ar šādu saturu:

10 11 12 13 14 15

Ja mums ir uzdevums saskaitīt nepāra skaitļus šajā failā, mēs varam izmantot readFileAsArraykoda vienkāršošanu:

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

Kods nolasa skaitļu saturu virkņu masīvā, parsē tos kā skaitļus un skaita nepāra skaitļus.

Mezgla atzvanīšanas stils tiek izmantots tikai šeit. Atzvanam ir pirmais kļūdas arguments, errkas nav atceļams, un mēs atzvanām kā pēdējo argumentu resursdatora funkcijai. Tas vienmēr jādara savās funkcijās, jo lietotāji, iespējams, to pieņems. Lieciet resursdatora funkcijai atzvanīt kā pēdējo argumentu un lieciet atzvanam kā pirmo argumentu gaidīt kļūdas objektu.

Mūsdienu JavaScript alternatīva atzvaniem

Mūsdienu JavaScript valodā mums ir solījumu objekti. Solījumi var būt alternatīva asinhrono API atzvanīšanai. Tā vietā, lai izsauktu atzvanīšanu kā argumentu un apstrādātu kļūdu tajā pašā vietā, solījumu objekts ļauj atsevišķi apstrādāt veiksmes un kļūdu gadījumus, kā arī ļauj mums ķēdīt vairākus asinhronus zvanus, nevis tos ligzdot.

Ja readFileAsArrayfunkcija atbalsta solījumus, mēs varam to izmantot šādi:

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

Tā vietā, lai ievadītu atzvanīšanas funkciju, mēs izsaucām .thenfunkciju uz resursdatora funkcijas atgriešanās vērtību. Šī .thenfunkcija parasti dod mums piekļuvi tam pašam līniju masīvam, kuru mēs iegūstam atzvanīšanas versijā, un mēs varam to apstrādāt tāpat kā iepriekš. Lai novērstu kļūdas, rezultātam mēs pievienojam .catchzvanu, un tas mums ļauj piekļūt kļūdai, kad tā notiek.

Pateicoties jaunajam objektam Promise, modernajā JavaScript valodā ir vieglāk nodrošināt resursdatora funkcijas solījumu saskarni. Šeit ir readFileAsArraymodificēta funkcija, lai atbalstītu solījumu saskarni papildus atzvanīšanas saskarnei, kuru tā jau atbalsta:

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

Tāpēc mēs liekam funkcijai atgriezt solījumu objektu, kas aptver fs.readFileasinhrono zvanu. Solījuma objekts atklāj divus argumentus, resolvefunkciju un rejectfunkciju.

Ikreiz, kad mēs vēlamies izsaukt atzvanīšanu ar kļūdu, mēs izmantojam arī apsolīšanas rejectfunkciju, un vienmēr, kad mēs vēlamies izsaukt atzvanīšanu ar datiem, mēs izmantojam arī solījuma resolvefunkciju.

Vienīgā cita lieta, kas mums bija jādara šajā gadījumā, ir šī atzvanīšanas argumenta noklusējuma vērtība, ja kods tiek izmantots ar solījuma saskarni. Šī gadījuma argumentā mēs varam izmantot vienkāršu noklusējuma tukšu funkciju: () =>{}.

Patērē solījumus ar asinhronizāciju / gaidīšanu

Ja pievienojat solījumu saskarni, kods ir daudz vieglāk strādājams, ja ir nepieciešams veikt saikni ar asinhrono funkciju. Ar atzvanīšanu viss kļūst nekārtīgs.

Solījumi to nedaudz uzlabo, un funkciju ģeneratori to nedaudz uzlabo. Tas nozīmē, ka jaunāka alternatīva darbam ar asinhrono kodu ir asyncfunkcijas izmantošana, kas ļauj mums apstrādāt asinhrono kodu tā, it kā tas būtu sinhrons, padarot to kopumā daudz lasāmāku.

Lūk, kā mēs varam patērēt readFileAsArrayfunkciju ar 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();

Vispirms mēs izveidojam asinhrono funkciju, kas ir tikai normāla funkcija ar vārdu asyncpirms tās. Async funkcijas iekšpusē mēs saucam readFileAsArrayfunkciju tā, it kā tā atgrieztu mainīgo līnijas, un, lai tas darbotos, mēs izmantojam atslēgvārdu await. Pēc tam mēs turpinām kodu tā, it kā readFileAsArrayzvans būtu sinhrons.

Lai lietas palaistu, mēs izpildām asinhronizācijas funkciju. Tas ir ļoti vienkārši un vairāk lasāms. Lai strādātu ar kļūdām, mums jāinstalē asinhronais izsaukums try/ catchpriekšrakstā.

Izmantojot šo funkciju asinhronizācija / gaidīšana, mums nebija jāizmanto īpaša API (piemēram, .tad un .catch). Mēs vienkārši iezīmējām funkcijas atšķirīgi un kodam izmantojām tīru JavaScript.

Async / await funkciju mēs varam izmantot ar jebkuru funkciju, kas atbalsta solījumu saskarni. Tomēr mēs to nevaram izmantot ar atzvanīšanas stila asinhronizācijas funkcijām (piemēram, setTimeout).

EventEmitter modulis

EventEmitter ir modulis, kas atvieglo saziņu starp mezglā esošajiem objektiem. EventEmitter ir mezgla asinhronās, notikumu virzītās arhitektūras pamatā. Daudzi no mezgla iebūvētajiem moduļiem tiek mantoti no EventEmitter.

Koncepcija ir vienkārša: izstarojošie objekti izstaro nosauktus notikumus, kas liek izsaukt iepriekš reģistrētos klausītājus. Tātad izstarojošajam objektam pamatā ir divas galvenās iezīmes:

  • Izstaro vārda notikumus.
  • Klausītāja funkciju reģistrēšana un reģistrācijas atcelšana.

Lai strādātu ar EventEmitter, mēs vienkārši izveidojam klasi, kas paplašina EventEmitter.

class MyEmitter extends EventEmitter {}

Emitera objekti ir tie, kurus mēs izstudējam no EventEmitter balstītajām klasēm:

const myEmitter = new MyEmitter();

Jebkurā šo izstarojošo objektu dzīves cikla laikā mēs varam izmantot funkciju emit, lai izstarotu jebkuru vēlamo notikumu.

myEmitter.emit('something-happened');

Notikuma izstarošana ir signāls, ka ir radušies kādi apstākļi. Šis nosacījums parasti attiecas uz stāvokļa izmaiņām izstarojošajā objektā.

Mēs varam pievienot klausītāja funkcijas, izmantojot onmetodi, un šīs klausītāja funkcijas tiks izpildītas katru reizi, kad izstarojošais objekts izstaro ar to saistīto nosaukuma notikumu.

Notikumi! == Asinhronija

Apskatīsim piemēru:

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

Klase WithLogir notikumu izstarotājs. Tas nosaka vienu instances funkciju execute. Šī executefunkcija saņem vienu argumentu, uzdevuma funkciju un aptin tās izpildi ar žurnāla paziņojumiem. Tas izšauj notikumus pirms un pēc izpildes.

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

Iepriekšminētā rinda “Rakstzīmes” vispirms tiks reģistrēta.

Un visbeidzot, ja jums ir nepieciešams noņemt klausītāju, varat izmantot removeListenermetodi.

Tas ir viss, kas man ir par šo tēmu. Paldies, ka lasījāt! Līdz nākamajai reizei!

Mācās React vai Node? Apmeklēt manas grāmatas:

  • Uzziniet React.js, veidojot spēles
  • Node.js ārpus pamatiem