Understanding Node.js Event-Driven Architecture

Most Node modules, such as HTTP and STREAM, are implemented based on the EventEmitter module, so they have the ability to fire and listen for events.

const EventEmitter = require('events');
Copy the code

In the event-driven world, most Node.js functions are easiest in the form of callbacks, such as fs.readfile. In this case, the event will be raised once (when Node is ready to call the callback), and the callback will act as the event handler.

Let’s look at the basic form first.

Node, call me when you’re ready

Node initially controls asynchronous events through callback functions. This was a long time ago, before Javascript supported native Promise and async/await features.

Callbacks are originally just functions that you pass to other functions. This behavior is made possible by the fact that functions are first class objects in Javascript.

It is important to understand that callbacks do not mean that code is called asynchronously. A function can call a callback either synchronously or asynchronously.

For example, the following fileSize function accepts CB as a callback and can trigger the callback asynchronously or synchronously, depending on the condition.

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

Note: This is a bad practice that can lead to unexpected errors. When designing functions, it is best to call callback functions asynchronously only, or synchronously only.

Let’s look at a simple example of a typical asynchronous Node function written as a callback:

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

The readFileAsArray argument includes a path and a callback function. The host function reads the contents of the file, separates them into the Lines array, and passes lines into the callback function.

Here is a use case. If we are in the same directory, we have a file called numbers.txt that looks like this:

10, 11, 12, 13, 14, 15Copy the code

If we need to find the number of odd numbers in a file, we can use readFileAsArray to simplify the code:

readFileAsArray('./numbers.txt', (err, lines) => {
  if (err) throw err;
  const numbers = lines.map(Number);
  const oddNumbers = numbers.filter(n= > n % 2= = =1);
  console.log('The number of odd numbers is:', oddNumbers.length);
});
Copy the code

The code above reads the numeric content and converts it into an array of strings, parses them into numbers, and finds odd numbers.

This uses only the Node callback form. The first argument to the callback is an err object, which returns NULL if there are no errors. In the host function, the callback function is passed in as the last argument. In your functions, you should always do this. That is, the last argument to the host function is set to the callback function and the first argument to the callback function is set to the error object.

Modern Javascript alternatives to callbacks

In modern Javascript, we have Promise objects. Promise becomes an alternative to callback functions in asynchronous apis. In promises, success and failure cases are handled individually by a Promise object, allowing us to invoke them asynchronously. Instead of passing in callback functions as arguments, error handling is not in the same place.

If the readFileAsArray function supports promises, we can use it 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 passing in a callback function, we call the function.then on the return value of the host function. The.then function gives us a way to get an array of the same rows, just like the callback version, and we can do it as before. If we want to do error handling, we need to call the.catch function on the return value, which allows us to handle errors when they occur.

Because there are Promise objects in modern Javascript, it’s easy to get host functions to support the Promise interface. Here is an example of the readFileAsArray function, which already has a callback interface, modified to support the Promise interface:

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

We have the function return a Promise object wrapped around the asynchronous call to fs.readFile. The Promise object exposes two arguments, resolve and Reject.

We can use the Promise reject method to handle error calls. You can also handle normal calls to get data through the resolve function.

With Promise already in use, all we need to do is add a default value for the callback function. We can use a simple, default empty function in the argument :() => {}.

Use promises with async/await

When you need to loop through an asynchronous function, add the Promise interface to make your code easier to run. If you use callback functions, it gets messy.

Promises make things easy, and the Generator makes things even easier. In other words, the more recent way to run asynchronous code is through the use of async functions, which allow us to write asynchronous code in a synchronous manner and make the code more readable.

Here is an example of how to use the readFileAsArray function with 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();
Copy the code

First, we create an asynchronous function with one more async field in front of the normal function. In this asynchronous function, we call the readFileAsArray function with await keyword, as if the function returns the number of lines directly. The code that calls readFileAsArray is then synchronized.

We execute asynchronous functions to make it work. This is much simpler and much more readable. If we want error handling, we need to wrap the asynchronous call in a try/catch statement.

With async/await, we don’t need to use special apis (e.g..then and.catch). All we need to do is tag the function and use native Javascript code.

We can use the async/await feature as long as the function supports the Promise interface. However, in async functions, we cannot use code in the form of callback functions (such as setTimeout).

EventEmitter module

In Node, EventEmitter is a module that can speed up communication between objects and is the core of Node’s asynchronous event-driven architecture. Many Node built – in modules are also inherited from EventEmitter.

The core concept is simple: Emitter objects fire named events, which cause named events to be invoked when listeners are previously registered. So an Emitter object has two basic properties:

  • Triggering event
  • Register and unregister listener functions

To make EventEmitter work, we just need to create a class that inherits from EventEmitter.

class MyEmitter extends EventEmitter {
  //
}
Copy the code

Emitter objects are instantiated objects based on the EventEmitter class:

const myEmitter = new MyEmitter();
Copy the code

At any point in the Emitter object’s life cycle, we can use the emit function to emit named events we want.

myEmitter.emit('something-happened');
Copy the code

A trigger event is a sign that something has happened. This condition is usually created by changes in states in an Emitter object.

We add the listener by using the method on. These functions are called each time an Emitter object fires an associated event.

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 defines the instance property execute. The excute function takes one argument, which is a task function, and wraps it in a log statement. It fires events before and after execution.

To be able to see the order of execution, we registered two events and fired them through a task.

Output from the following code:

Before executing
About to execute
*** Executing task ***
Done with execute
After executing
Copy the code

What I want you to notice about this output is that everything is done synchronously, not asynchronously.

  • First execute the line “Before executing”.
  • beginEvent triggers the execution of the “About to execute” line.
  • Actual execution output*** Executing task ***.
  • endThe event triggers the execution of the “Done with execute” line.
  • And then we get “After Executing”

Just like old-fashioned callback functions, don’t assume that events mean your code is synchronous or asynchronous.

This concept is important because if we pass in an asynchronous taskFunc to execute, the event firing order is no longer precise.

We can simulate this situation with setImmediate:

// ...

withLog.execute((a)= > {
  setImmediate((a)= > {
    console.log('*** Executing task ***');
  });
});
Copy the code

Here is the output:

Before executing
About to execute
Done with execute
After executing
*** Executing task ***
Copy the code

This is wrong. If asynchronous invocation is used, this line of code will be executed only After Done with execute and After Executing are called. This is no longer precise.

To trigger an event after the asynchronous function call completes, we need to bind the callback function (or Promise) through event-based communication. The following example demonstrates this.

One advantage of using events instead of callbacks is that we can register multiple listeners to respond multiple times to events of the same signal. If we were to accomplish the same thing with a callback, we would have to write more logic in a single callback. For applications, the event system is a great way to apply top-level build functionality, which also allows us to extend multiple plug-ins. You can also think of it as a state change after the hook point that allows us to customize the task.

Asynchronous events

Let’s make the synchronous example asynchronous to make the code more practical.

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 an asyncFunc function and prints out the time when asyncFunc runs by using console.time and console.timeEnd. It triggers the correct sequence of events before and after execution. An error/data event is also raised using an asynchronous call to the general flag.

We test withTime by calling the asynchronous function fs.readfile. Instead of using callbacks to process file data, we can now listen for data events.

When we execute the code, we get the correct order as expected, and we get the events used to execute the code, which is very useful:

About to execute
execute: 4.507ms
Done with execute
Copy the code

So how do we combine callback functions with event triggers? If asyncFunc also supports promises, we could 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

Overall, this approach to the code is more readable to me than the callbacks and.then/.catch. The Async /await feature brings us closer to the Javascript language itself, which is definitely a success.

Event parameters and errors

In the example above, both events are fired with additional parameters.

When an error event is raised, an error object is attached.

this.emit('error', err);
Copy the code

When the data event is fired, data data is attached.

this.emit('data', data);
Copy the code

We can attach many parameters to named events, all of which can be accessed in previously registered listener functions.

For example, when the data event is available, our registered listener can get the parameters passed when the event is fired. This data object is exposed by asyncFunc.

withTime.on('data', data => {
  // do something with data
});
Copy the code

Error events are usually a special one. In the callback-based example, if we do not set a listener for the error event, the Node process will exit automatically.

To demonstrate, we added another callback that executes the error argument method:

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 execute call above will raise an error. 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 execute call is affected by a crash and never executes.

If we register a special error event, the Node process will change its behavior. Such as:

withTime.on('error', err => {
  // do something with err, for example log it somewhere
  console.log(err);
});
Copy the code

If we do this, the error from the first execute call will be reported to the event and the Node process will not crash and exit. Another execute call 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

Note that a Node based on promise will now behave differently, just printing 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.
Copy the code

Another way to catch an error event is by registering a global uncaughtException event. However, catching errors globally through this event is not a good idea.

Avoid uncaughtException, but if you must use it (such as to report what happened or do a cleanup), you should make your program 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);
});
Copy the code

However, imagine if many error events were fired at the same time. This means that the uncaughtException listener above will fire many times, which can be problematic for some cleanup code. For example, stop the operation when many database calls occur.

The EventEmitter module exposes a once method. This method means that the listener is called only once, not every time an event is fired. So, this is a practical use case for uncaughtException, because when the first uncaughtException occurs, we will start clearing and the process will exit anyway.

Order of listeners

If we register multiple listeners for the same event, these listeners will be called sequentially. That is, the first listener registered will be called first.

/ / the first
withTime.on('data', data => {
  console.log(`Length: ${data.length}`);
});

/ / another
withTime.on('data', data => {
  console.log(`Characters: ${data.toString().length}`);
});

withTime.execute(fs.readFile, __filename);
Copy the code

The code above will execute the Length line and then the Characters line, since this is in the order in which we defined the listeners.

If you need to define a new listener, but if you want to set this listener to be called first, you need to use the prependListener method:

/ / the first
withTime.on('data', data => {
  console.log(`Length: ${data.length}`);
});

/ / another
withTime.prependListener('data', data => {
  console.log(`Characters: ${data.toString().length}`);
});

withTime.execute(fs.readFile, __filename);
Copy the code

This code will have Characters printed first.

Finally, if you need to remove a listener, you can use the removeListener method.

That’s all for this topic. Thanks for reading! Looking forward to next time!