- Understanding Node.js Event-Driven Architecture
- By Samer Buna
- The Nuggets translation Project
- Schrodinger’s cat
- Proofread by: Bambooom Zaraguo
Understand event-driven architecture in NodeJS
Most Node.js objects, such as HTTP requests, responses, and “flows,” use the eventEmitter module to support listening and firing events.
The simplest form of event-driven is the common Node.js function callback, such as fs.readfile. Node invokes the callback function when an event is raised, so the callback function can be considered an event handler.
Let’s explore this basic form.
Node, call me when you’re ready!
Without native Promise, async/await support, Node’s original way of handling asynchrony is to use callbacks.
Callback functions are essentially functions passed as arguments to other functions, which is possible in JS because functions are first-class citizens.
It is important to note that callbacks do not have to be called asynchronously. Within the function, we can call the callback function synchronously/asynchronously as needed.
For example, in the following example, the main function fileSize takes a callback function cb as an argument and calls cb synchronously/asynchronously, depending on the case:
function fileSize (fileName, cb) {
if (typeoffileName ! = ='string') {
return cb(new TypeError('argument should be string')); / / synchronize
}
fs.stat(fileName, (err, stats) => {
if (err) { return cb(err); } / / asynchronous
cb(null, stats.size); / / asynchronous
});
}Copy the code
Please note that this is not a good practice and may introduce some unexpected errors. It is best to design the main function to always use callbacks synchronously or asynchronously.
Let’s take a look at the following asynchronous Node functions handled in a typical callback style:
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);
});
};Copy the code
ReadFileAsArray takes a file path and the callback function callback, reads the file, and calls the callback as an array of lines.
Here’s an example of using it, assuming we have a numbers. TXT file in the same directory with the following contents:
10
11
12
13
14
15Copy the code
To find the number of odd numbers in this file, we can call the readFileAsArray function as follows:
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);
});Copy the code
This code reads the string from the array, parses it into numbers and counts the odd number.
The NodeJS callback style is written like this: the first argument to the callback function is an error object, err, which may be null, and the callback function is passed in as the last argument to the main function. You should always do this, because users most likely think so.
An alternative to callback functions in modern JavaScript
In ES6+, we have Promise objects. It is a strong competitor to callback for asynchronous apis. Instead of handling error messages while passing callback as a parameter, the Promise object allows us to handle both success and failure cases separately, and to chain calls to multiple asynchronous methods avoids the nesting of callbacks (callback hell).
If the readFileAsArray method just allowed promises, its call would look something like this:
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);Copy the code
Instead of calling callback, we use.then to accept the return value of the main method. We can handle data in.then as we did in the callback before, and we use.catch to handle errors.
The Promise object in modern JavaScript makes it easier for main functions to support the Promise interface. Let’s rewrite the readFileAsArray method to support Promise:
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);
});
});
};Copy the code
This function now returns a Promise object containing an asynchronous call to fs.readfile that exposes two arguments: resolve and Reject.
Reject does what we did with the error in callback, and resolve does what we normally do with the return value.
The only thing left to do is specify the default reject resolve function in the instance. In the Promise, we just write an empty function, such as () => {}.
Use promises in async/await
When you need to loop asynchronous functions, using promises makes your code easier to read, whereas using callback functions just gets messy.
Promise is a small step forward, generator is a much larger step forward, but this step is made much more powerful with the advent of async/await functions, whose coding style makes asynchronous code as readable as synchronous.
We use the async/await function feature to override the readFileAsArray procedure we just called:
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();Copy the code
First we create an async function with the async keyword in front of the function definition. In async, use the keyword await to make readFileAsArray as if it were returning a normal variable, and then code as if readFileAsArray were a synchronous method.
The execution of async functions is very readable, and handling errors requires only a try/catch layer around the asynchronous call.
In async/await functions we don’t need to use any special apis (like.then,.catch\), we just use special keywords and use normal JavaScript encoding.
We can use async/await functions in functions that support promises, but not in callback-style asynchronous methods such as setTimeout and so on.
EventEmitter module
EventEmitter is the core of the event-driven architecture in Node.js. It is used for communication between objects, and many node.js native modules inherit from this module.
The concept of a module is simple. An Emitter object fires named events that cause previously registered listeners to be called, so an Emitter object has two main characteristics:
- Triggers the named event
- Register and unregister listener functions
How do you use it? We just need to create a class to inherit EventEmitter:
class MyEmitter extends EventEmitter {}Copy the code
Instantiate the class we created above based on EventEmitter to get an Emitter object:
const myEmitter = new MyEmitter();Copy the code
We can emit any named event using the Emit method at any point in the Emitter object’s life cycle:
myEmitter.emit('something-happened');Copy the code
Triggering an event is a signal that something is happening, usually about changing the state of an Emitter object.
We use the ON method to register, and these listening methods will be executed when each Emitter emits an event with its name.
Event! = asynchronous
Let’s 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((a)= > console.log('*** Executing task ***'));Copy the code
The WithLog class is an Event Emitter. It has an excute method, takes a taskFunc task function as an argument, and includes the execution of this function between log statements, calling the emit method before and after the execution.
The result is as follows:
Before executing
About to execute
*** Executing task ***
Done with execute
After executingCopy the code
It is important to note that all output logs are synchronous and there is no asynchronous operation in the code.
- Step 1: “Before Executing”
- The event emit named BEGIN prints “About to execute”;
- Executing a method says “*** Task ***”.
- Another named event outputs “Done with execute”;
- And then “After Executing”.
As with previous callbacks, Events does not mean synchronous or asynchronous.
This is important because if we passed the asynchronous function taskFunc to Excute, events would not be triggered precisely.
SetImmediate can be used to simulate this situation:
// ...
withLog.execute((a)= > {
setImmediate((a)= > {
console.log('*** Executing task ***')}); });Copy the code
Will output:
Before executing
About to execute
Done with execute
After executing
*** Executing task ***Copy the code
“Done with execute” and “After executing” are no longer exact After an asynchronous call. A “*** executing” or “After executing” should be performed before a “*** task***”.
When the asynchronous method ends and emits an event, we need to combine the callback/promise with the event communication, as the previous example demonstrates.
One advantage of using event-driven instead of traditional callback functions is that we can react to the same emit multiple times after defining multiple listeners. To do this with callbacks, we need a lot of logic within the same callback function. Events are a good way for an application to allow multiple external plug-ins to build functionality on top of the application core. You can use them as hook points to allow you to do more customization with state changes.
Asynchronous events
Let’s modify the example to make it more interesting by changing the synchronization mode to asynchronous mode:
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);Copy the code
The WithTime class executes the asyncFunc function using console.time and console.timeEnd to return the time of execution, which emits the correct sequence before and after execution, Also emit error/data to ensure that the function works properly.
We’re passing an asynchronous function to The withTime Emitter, fs.readfile, so we don’t need callbacks anymore. We’re just listening for data events.
The result after execution is as follows, as we expect the correct sequence of events, we get the execution time, which is useful:
About to execute
execute: 4.507ms
Done with executeCopy the code
Note how we combine the callback function with the event generator to do this. If asynFunc also supports promises, we can use the async/await feature to do the same thing:
class WithTime extends EventEmitter {
asyncexecute(asyncFunc, ... args) {this.emit('begin');
try {
console.time('execute');
const data = awaitasyncFunc(... args);this.emit('data', data);
console.timeEnd('execute');
this.emit('end');
} catch(err) {
this.emit('error', err); }}}Copy the code
This really does seem easier to read! The async/await feature brings our code closer to JavaScript itself, which I think is a big step forward.
Event parameters and errors
In the previous example, we used additional parameters to trigger two events.
Error events use error objects.
this.emit('error', err);Copy the code
Data events use data objects.
this.emit('data', data);Copy the code
We can use any required parameters after the named event, which will be available inside the listener function we registered for the named event.
For example, when a data event is executed, the listener function is registered to allow us to receive the data argument triggered by the event, and the asyncFunc function is actually exposed to us.
withTime.on('data', (data) => {
// do something with data
});Copy the code
Error events are usually special cases. In our callback – based example, the Node process terminates if no listener is used to handle the error. –
Let’s write an example to illustrate this:
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);Copy the code
The first call to execute will trigger an error and the Node process will crash and exit:
events.js:163
throw er; // Unhandled 'error' event
^
Error: ENOENT: no such file or directory, open ' 'Copy the code
The second excute function call will be affected by the previous crash and may not execute.
If we register a listener function to handle error objects, the situation is different:
withTime.on('error', (err) => {
// do something with err, for example log it somewhere
console.log(err)
});Copy the code
With the error handling above, the first excute call will be reported, but the Node process will no longer crash and exit, and other calls will execute normally:
{ Error: ENOENT: no such file or directory, open ' ' errno: -2, code: 'ENOENT', syscall: 'open', path: ' '} the execute: 4.276 msCopy the code
Remember: Node.js currently behaves differently than Promise: it just prints warnings, 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.Copy the code
Another way to handle exceptions is to register a global uncaughtException process event, but catching the error object globally is not a good idea.
The recommendation for uncaughtException is not to use it. If you must use it (such as reporting what happened or doing some cleaning up), let the process end here:
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);
});Copy the code
However, imagine multiple error events happening at the same time. This means that the uncaughtException listener above fires multiple times, which can be a problem for some cleanup code. A typical example is when a database shutdown operation is called multiple times.
The EventEmitter module exposes a once method. This method allows the listener to be called only once, not every time it is triggered. So, here’s a real use case for uncaughtException, where the first uncaughtException occurs and we start doing the cleanup, knowing that we’ll eventually exit the process.
Order of listeners
If we register more than one listener on the same event, the listeners fire in order, and the first listener registered is the first to fire.
withTime.on('data', (data) => {
console.log(`Length: ${data.length}`);
});
withTime.on('data', (data) => {
console.log(`Characters: ${data.toString().length}`);
});
withTime.execute(fs.readFile, __filename);Copy the code
In the output of the code above, “Length” will come before “Characters” because we defined it in that order.
If you want to define a listener and want to jump the queue, use the prependListener method to register it.
withTime.on('data', (data) => {
console.log(`Length: ${data.length}`);
});
withTime.prependListener('data', (data) => {
console.log(`Characters: ${data.toString().length}`);
});
withTime.execute(fs.readFile, __filename);Copy the code
The code above places “Characters” before “Length”.
Finally, to remove it, use the removeListener method.
Thanks for reading, see you next time, above.
If you found this article helpful, click here to read more about Node and JavaScript.
If you have any questions about this article or anything ELSE I’ve written, feel free to ask me at Slack or in the # Questions Room.
The author runs online courses on Pluralsight and Lynda. His most recent courses include Getting started with React.js, Advanced node. js, and full stack JavaScript.
The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. Android, iOS, React, front end, back end, product, design, etc. Keep an eye on the Nuggets Translation project for more quality translations.