[译] 理解 NodeJS 中基于事件驱动的架构

理解 NodeJS 中基于事件驱动的架构

绝大部分 Node.js 对象,好比 HTTP 请求、响应以及“流”,都使用了 eventEmitter 模块来支持监听和触发事件。javascript

事件驱动最简单的形式是常见的 Node.js 函数回调,例如:fs.readFile。事件被触发时,Node 就会调用回调函数,因此回调函数可视为事件处理程序。html

让咱们来探究一下这个基础形式。前端

Node,在你准备好的时候调用我吧!

之前没有原生的 promise、async/await 特性支持,Node 最原始的处理异步的方式是使用回调。java

回调函数从本质上讲就是做为参数传递给其余函数的函数,在 JS 中这是可能的,由于函数是一等公民。node

回调函数并不必定异步调用,这一点很是重要。在函数中,咱们能够根据须要同步/异步调用回调函数。react

例如,在下面例子中,主函数 fileSize 接收一个回调函数 cb 为参数,根据不一样状况以同步/异步方式调用 cbandroid

function fileSize (fileName, cb) {
  if (typeof fileName !== 'string') {
    return cb(new TypeError('argument should be string')); // 同步
  }

  fs.stat(fileName, (err, stats) => {
    if (err) { return cb(err); } // 异步

    cb(null, stats.size); // 异步
  });
}复制代码

请注意,这并非一个好的实践,它也许会带来一些预期外的错误。最好将主函数设计为始终同步或始终异步地使用回调。ios

咱们再来看看下面这种典型的回调风格处理的异步 Node 函数:git

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);
  });
};复制代码

readFileAsArray 以一个文件路径和回调函数 callback 为参,读取文件并切割成行的数组来当作参数调用 callback。github

这里有一个使用它的示例,假设同目录下咱们有一个 numbers.txt 文件中有以下内容:

10
11
12
13
14
15复制代码

要找出这个文件中的奇数的个数,咱们能够像下面这样调用 readFileAsArray 函数:

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);
});复制代码

这段代码会读取数组中的字符串,解析成数字并统计奇数个数。

在 NodeJS 的回调风格中的写法是这样的:回调函数的第一个参数是一个可能为 null 的错误对象 err,而回调函数做为主函数的最后一个参数传入。 你应该永远这么作,由于使用者们极有多是这么觉得的。

现代 JavaScript 中回调函数的替代品

在 ES6+ 中,咱们有了 Promise 对象。对于异步 API,它是 callback 的有力竞争者。再也不须要将 callback 做为参数传递的同时处理错误信息,Promise 对象容许咱们分别处理成功和失败两种状况,而且链式的调用多个异步方法避免了回调的嵌套(callback hell,回调地狱)。

若是刚刚的 readFileAsArray 方法容许使用 Promise,它的调用将是这个样子的:

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);复制代码

做为调用 callback 的替代品,咱们用 .then 函数来接受主方法的返回值,.then 中咱们能够和以前在回调函数中同样处理数据,而对于错误咱们用.catch函数来处理。

现代 JavaScript 中的 Promise 对象,使主函数支持 Promise 接口变得更加容易。咱们把刚刚的 readFileAsArray 方法用改写一下以支持 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);
    });
  });
};复制代码

如今这个函数返回了一个 Promise 对象,该对象包含 fs.readFile 的异步调用,Promise 对象暴露了两个参数:resolve 函数和 reject 函数。

reject 函数的做用就和咱们以前 callback 中处理错误是同样的,而 resolve 函数也就和咱们正常处理返回值同样。

剩下惟一要作的就是在实例中指定 reject resolve 函数的默认值,在 Promise 中,咱们只要写一个空函数便可,例如 () => {}.

在 async/await 中使用 Promise

当你须要循环异步函数时,使用 Promise 会让你的代码更易阅读,而若是使用回调函数,事情只会变得混乱。

Promise 是一个小小的进步,generator 是更大一些的小进步,可是 async/await 函数的到来,让这一步变得更有力了,它的编码风格让异步代码就像同步同样易读。

咱们用 async/await 函数特性来改写刚刚的调用 readFileAsArray 过程:

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();复制代码

首先咱们建立了一个 async 函数,只是在定义 function 的时候前面加了 async 关键字。在 async 函数里,使用关键字 await 使 readFileAsArray 函数好像返回普通变量同样,这以后的编码也好像 readFileAsArray 是同步方法同样。

async 函数的执行过程很是易读,而处理错误只须要在异步调用外面包上一层 try/catch 便可。

async/await 函数中咱们咱们不须要使用任何特殊 API(像: .then.catch\),咱们仅仅使用了特殊关键字,并使用普通 JavaScript 编码便可。

咱们能够在支持 Promise 的函数中使用 async/await 函数,可是不能在回调风格的异步方法中使用它,好比 setTimeout 等等。

EventEmitter 模块

EventEmitter 是 Node.js 中基于事件驱动的架构的核心,它用于对象之间通讯,不少 Node.js 的原生模块都继承自这个模块。

模块的概念很简单,Emitter 对象触发已命名事件,使以前已注册的监听器被调用,因此 Emitter 对象有两个主要特征:

  • 触发已命名事件
  • 注册和取消注册监听函数

如何使用呢?咱们只须要建立一个类来继承 EventEmitter 便可:

class MyEmitter extends EventEmitter {

}复制代码

实例化前面咱们基于 EventEmitter 建立的类,便可获得 Emitter 对象:

const myEmitter = new MyEmitter();复制代码

在 Emitter 对象的生命周期中的任何一点,咱们均可以用 emit 方法发出任何已命名的事件:

myEmitter.emit('something-happened');复制代码

触发一个事件即某种状况发生的信号,这些状况一般是关于 Emitter 对象的状态改变的。

咱们使用 on 方法来注册,而后这些监听的方法将会在每个 Emitter 对象 emit 它们对应名称的事件的时候执行。

事件 != 异步

让咱们看一个例子:

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 ***'));复制代码

WithLog 类是一个 event emitter。它有一个 excute 方法,接收一个 taskFunc 任务函数做为参数,并将此函数的执行包含在 log 语句之间,分别在执行以前和以后调用了 emit 方法。

执行结果以下:

Before executing
About to execute
*** Executing task ***
Done with execute
After executing复制代码

咱们须要注意的是全部的输出 log 都是同步的,在代码里没有任何异步操做。

  • 第一步 “Before executing”;
  • 命名为 begin 的事件 emit 输出了 “About to execute”;
  • 内含方法的执行输出了“*** Executing task ***”;
  • 另外一个命名事件输出“Done with execute”;
  • 最后“After executing”。

如同以前的回调方式,events 并不意味着同步或者异步。

这一点很重要,假如咱们给 excute 传递异步函数 taskFunc,事件的触发就再也不精确了。

可使用 setImmediate 来模拟这种状况:

// ...

withLog.execute(() => {
  setImmediate(() => {
    console.log('*** Executing task ***')
  });
});复制代码

会输出:

Before executing
About to execute
Done with execute
After executing
*** Executing task ***复制代码

这明显有问题,异步调用以后再也不精确,“Done with execute”、“After executing”出如今了“***Executing task***”以前(应该在后)。

当异步方法结束的时候 emit 一个事件,咱们须要把 callback/promise 与事件通讯结合起来,刚刚的例子证实了这一点。

使用事件驱动来代替传统回调函数有一个好处是:在定义多个监听器后,咱们能够屡次对同一个 emit 作出反应。若是要用回调来作到这一点的话,咱们须要些不少的逻辑在同一个回调函数中,事件是应用程序容许多个外部插件在应用程序核心之上构建功能的一个好方法,你能够把它们看成钩子点来容许利用状态变化作更多自定义的事。

异步事件

咱们把刚刚的例子修改一下,将同步改成异步方式,让它更有意思一点:

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);复制代码

WithTime 类执行 asyncFunc 函数,使用 console.timeconsole.timeEnd 来返回执行的时间,它 emit 了正确的序列在执行以前和以后,一样 emit error/data 来保证函数的正常工做。

咱们给 withTime emitter 传递一个异步函数 fs.readFile 做为参数,这样就再也不须要回调函数,只要监听 data 事件就能够了。

执行以后的结果以下,正如咱们期待的正确事件序列,咱们获得了执行的时间,这是颇有用的:

About to execute
execute: 4.507ms
Done with execute复制代码

请注意咱们是如何将回调函数与事件发生器结合来完成的,若是 asynFunc 一样支持 Promise 的话,咱们可使用 async/await 特性来作到一样的事情:

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);
    }
  }
}复制代码

这真的看起来更易读了呢!async/await 特性使咱们的代码更加贴近 JavaScript 自己,我认为这是一大进步。

事件参数及错误

在以前的例子中,咱们使用了额外的参数触发了两个事件。

error 事件使用了 error 对象。

this.emit('error', err);复制代码

data 事件使用了 data 对象。

this.emit('data', data);复制代码

咱们能够在命名事件以后使用任何须要的参数,这些参数将在咱们为命名事件注册的监听器函数内部可用。

例如:data 事件执行的时候,监听函数在注册的时候就会容许咱们的接收事件触发的 data 参数,而 asyncFunc 函数也实实在在暴露给了咱们。

withTime.on('data', (data) => {
  // do something with data
});复制代码

error 事件一般是特例。在咱们基于 callback 的例子中,若是没用监听函数来处理错误,Node 进程就会直接终止-。-

咱们写个例子来展现这一点:

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);复制代码

第一个 execute 函数的调用会触发一个错误,Node 进程会崩溃而后退出:

events.js:163
      throw er; // Unhandled 'error' event
      ^
Error: ENOENT: no such file or directory, open ''复制代码

第二个 excute 函数调用将受到以前崩溃的影响,可能并不会执行。

若是咱们注册一个监听函数来处理 error 对象,状况就不同了:

withTime.on('error', (err) => {
  // do something with err, for example log it somewhere
  console.log(err)
});复制代码

加上了上面的错误处理,第一个 excute 调用的错误会被报告,但 Node 进程不会再崩溃退出了,其它的调用也会正常执行:

{ Error: ENOENT: no such file or directory, open '' errno: -2, code: 'ENOENT', syscall: 'open', path: '' }
execute: 4.276ms复制代码

记住:Node.js 目前的表现和 Promise 不一样 :只是输出警告,但最终会改变:

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.复制代码

另外一种处理异常的方法是注册一个全局的 uncaughtException 进程事件,可是,全局的捕获错误对象并非一个好办法。

关于 uncaughtException 的建议是不要使用。你必定要用的话(好比说报告发生了什么或者作一些清理工做),应该让进程在此结束:

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);
});复制代码

然而,想象在同一时间发生多个错误事件。这意味着上述的 uncaughtException 监听器会屡次触发,这可能对一些清理代码是个问题。一个典型例子是,屡次调用数据库关闭操做。

EventEmitter 模块暴露一个 once 方法。这个方法仅容许调用一次监听器,而非每次触发都调用。因此,这是一个 uncaughtException 的实际用例,在第一次未捕获的异常发生时,咱们开始作清理工做,而且知道咱们最终会退出进程。

监听器的顺序

若是咱们在同一个事件上注册多个监听器,则监听器会按顺序触发,第一个注册的监听器就是第一个触发的。

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

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

withTime.execute(fs.readFile, __filename);复制代码

上面代码的输出结果里,“Length” 将会在 “Characters” 以前,由于咱们是按照这个顺序定义的。

若是你想定义一个监听器,还想插队到前面的话,要使用 prependListener 方法来注册。

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

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

withTime.execute(fs.readFile, __filename);复制代码

上面的代码使得 “Characters” 在 “Length” 以前。

最后,想移除的话,用 removeListener 方法就好啦!

感谢阅读,下次再会,以上。

若是以为本文有帮助,点击阅读原文能够看到更多关于 Node 和 JavaScript 的文章。

关于本文或者我写的其它文章有任何问题,欢迎在 slack 找我,也能够在 #questions room 向我提问。

做者在 PluralsightLynda 上有开设线上课程,最近的课程有React.js入门Node.js进阶JavaScript全栈,有兴趣的能够试听。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划

相关文章
相关标签/搜索