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

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

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

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

让我们来探究一下这个基础形式。

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

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

回调函数从本质上讲就是作为参数传递给其他函数的函数,在 JS 中这是可能的,因为函数是一等公民。

回调函数并不一定异步调用,这一点非常重要。在函数中,我们可以根据需要同步/异步调用回调函数。

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

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

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

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

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。

这里有一个使用它的示例,假设同目录下我们有一个 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前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值