原文地址:Understanding Node.js Event-Driven Architecturejavascript
大部分 Node 模块,例如 http 和 stream,都是基于EventEmitter
模块实现的,因此它们拥有触发和监听事件的能力。java
const EventEmitter = require('events');
复制代码
事件驱动的世界中,对于大部分 Node.js 函数,经过回调的形式就是最简单的,例如fs.readFile
。在这个例子中,事件会被触发一次(当 Node 已经准备好去调用回调函数时),而且回调函数将做为事件处理函数。node
首先让咱们看一下基本形式。数据库
Node 控制异步事件最初的形式是经过回调函数。那是在好久之前,那时候 Javascript 尚未支持原生的 Promise 和 async/await 特性。数组
回调函数最初只是你传递到其余函数的函数。由于 Javascript 中,函数是第一类对象,因此才让这种行为成为可能。promise
回调函数不表明代码就是异步调用的,理解这一点是很是重要的。一个函数调用回调函数时,既能够经过同步,也能够经过异步。bash
例如,下面的fileSize
函数接受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); // 异步
});
}
复制代码
注意:这是一个可能会致使意料以外错误的坏实践。设计函数时,回调函数调用最好只经过异步,或者只经过同步。app
让咱们看一个用回调形式编写,典型异步 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
参数包括一个路径和一个回调函数。该宿主函数读取文件内容,并将它们分离到 lines 数组中,并将 lines 传入回调函数中。
下面是一个使用案例。假如在同一目录下,咱们有一个文件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('奇数的数量为:', oddNumbers.length);
});
复制代码
上面的代码会读取数字内容并转化为字符串数组,将它们解析为数字,并找出奇数。
这里只用了 Node 的回调函数形式。回调函数第一个参数是err
错误对象,没有错误时,返回null
。宿主函数中,回调函数做为最后一个参数传入其中。在你的函数中,你应该老是这么作。也就是将宿主函数的最后一个参数设置为回调函数,而且将回调函数第一个参数设置为错误对象。
现代的 Javascript 中,咱们拥有 Promise 对象。Promise 成为异步 API 中回调函数的替代方案。在 Promise 中,是经过一个 Promise 对象来单独处理成功和失败的状况,而且容许咱们异步链式调用它们。而不是经过传入回调函数做为参数,而且错误处理也不会在同一个地方。
若是函数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);
复制代码
咱们经过在宿主函数的返回值上,调用函数.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);
});
});
};
复制代码
咱们让函数返回了一个包裹fs.readFile
异步调用的 Promise 对象。这个 Promise 对象暴露了两个参数,分别是resolve
函数和reject
函数。
咱们可使用Promise的reject
方法处理错误时的调用。也能够经过resolve
函数,处理正常获取数据的调用。
在 Promise 已经被使用的状况下,咱们须要作的事情只有为回调函数添加一个默认值。咱们能够在参数中使用一个简单,默认的空函数:() => {}
。
当须要循环一个异步函数时,添加 Promise 接口让你的代码运行起来更简单。若是使用回调函数,会变得很杂乱。
Promise 让事情变得简单,而 Generator(生成器)让事情变得更简单了。也就是说,更近代的运行异步代码的方式,是经过使用async
函数,这让咱们可使用同步的方式书写异步代码,也让代码可读性更强。
下面是经过 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
字段。在这个异步函数中,咱们经过await
关键字,调用readFileAsArray
函数,就像这个函数直接返回了行数同样。而后,调用readFileAsArray
的代码就像同步同样。
咱们执行异步函数,让它能够运做。这很是简单而且更具可读性。若是想要进行错误处理,咱们须要把异步调用包裹在try
/catch
语句中。
经过 async/await 特性,咱们不须要使用一些特殊的 API(例如.then 和.catch)。咱们只须要标记函数,并使用原生的 Javascript 代码就能够了。
只要函数支持 Promise 接口,咱们就可使用 async/await 特性。可是,在 async 函数中,咱们不能使用回调函数形式的代码(例如 setTimeout)。
在 Node 中,EventEmitter 是一个能够加快对象之间通讯的模块,也是 Node 异步事件驱动架构的核心。许多 Node 内建模块也是继承于 EventEmitter 的。
核心概念很是简单:Emitter 对象触发具名事件,这会致使事先注册了监听器的具名事件被调用。因此,一个 Emitter 对象拥有两个基本特性:
咱们只须要建立一个继承于 EventEmitter 的类,就可让 EventEmitter 起做用了。
class MyEmitter extends EventEmitter {
//
}
复制代码
Emitter 对象是基于 EventEmitter 类的实例化对象:
const myEmitter = new MyEmitter();
复制代码
在 Emitter 对象生命周期的任什么时候刻,咱们均可以经过使用 emit 函数去触发咱们想要的具名事件。
myEmitter.emit('something-happened');
复制代码
触发事件是某些条件发生了的标志。这个条件一般是 Emitter 对象中状态的变化产生的。
咱们经过使用方法on
添加监听函数。每当 Emitter 对象触发相关联的事件时,这些函数将会被调用。
让咱们看一个例子:
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
类是一个事件 Emitter。它定义了实例属性execute
。这个excute
函数接收一个参数,也就是一个任务函数,并把这个函数包裹在 log 语句中。它在执行先后触发了事件。
为了可以看到执行的前后顺序,咱们注册了两个事件,并经过一个任务去触发它们。
下面代码的输出结果:
Before executing
About to execute
*** Executing task ***
Done with execute
After executing
复制代码
关于上面这个输出信息,我想让你注意的就是全部代码是同步进行的,而不是经过异步。
begin
事件触发执行 "About to execute" 这一行。*** Executing task ***
。end
事件触发执行 "Done with execute" 这一行。就像老式的回调函数同样,因此千万不要认为事件就意味着代码是同步的或者是异步的。
这个概念很重要,由于若是咱们传入一个异步taskFunc
来进行execute
,事件触发顺序就再也不精确。
咱们能够经过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
以后,才会执行这一行代码,这样将再也不精确。
为了在异步函数调用完成以后触发事件,咱们须要经过基于事件的通讯,绑定回调函数(或者是 Promise)。下面这个例子作了示范。
使用事件,而不使用回调的一个好处就是咱们能够经过注册多个监听器,对相同信号的事件进行屡次响应。若是经过回调完成相同的事情,咱们必须在单个回调中写更多的逻辑代码。对于应用程序,事件系统是一个在应用顶级构建功能的极好方式,这也容许咱们扩展多个插件。你也能够认为是一个状态变化后,容许咱们自定义任务的钩子点。
让咱们把刚才同步的例子转化为异步,这样可让代码更实用一些。
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.time
和console.timeEnd
打印asyncFunc
运行的时间。它触发了事件执行先后,正确的顺序。而且也使用异步调用常规的标志,去触发error/data
事件。
咱们经过调用异步函数fs.readFile
测试withTime
。咱们如今能够经过监听 data 事件,而没必要使用回调来处理文件数据。
当执行这些代码时,咱们如期地获取到了正确顺序,而且获取了代码执行所用的事件,这很是有用:
About to execute
execute: 4.507ms
Done with execute
复制代码
那咱们该如何作才能将回调函数和事件触发器结合起来呢?若是asyncFunc
也支持 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);
}
}
}
复制代码
总之,这种方式的代码对我来讲比回调函数和.then/.catch 的方式更具可读性。async/await 特性让咱们更贴近 Javascript 语言自己,这无疑是一大成功。
在上面的例子中,两个事件被触发的时候,都附带了额外的参数。
error 事件触发时,附带了错误对象。
this.emit('error', err);
复制代码
data 事件触发时,附带了 data 数据。
this.emit('data', data);
复制代码
咱们能够在具名事件中附带不少参数,全部的这些参数能够在以前注册的监听器函数中访问到。
例如,data 事件可用时,咱们注册的监听函数就能够获取到事件触发时传递的参数。这个 data 对象就是asyncFunc
暴露的。
withTime.on('data', data => {
// do something with data
});
复制代码
一般error
事件是比较特殊的一个。在基于回调函数的例子中,若是咱们没有设置错误事件的监听器,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 ''
复制代码
而第二个 execute 调用会由于程序崩溃受到影响,而且永远不会执行。
若是咱们注册了一个特殊的error
事件,node 进程的行为将会改变。例如:
withTime.on('error', err => {
// do something with err, for example log it somewhere
console.log(err);
});
复制代码
若是咱们像上面这样作,来自第一个 execute 调用的错误将会被报告给事件,从而 node 进程就不会崩溃和退出了。另一个 execute 调用将会正常执行:
{ Error: ENOENT: no such file or directory, open '' errno: -2, code: 'ENOENT', syscall: 'open', path: '' }
execute: 4.276ms
复制代码
注意如今基于 promise 的 Node 的行为将有所不一样,只是会输出一个警告,可是最终将会改变。
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 先被打印。
最后,若是你须要删除某一个监听器,你可使用removeListener
方法。
这就是本次话题的全部内容。感谢你的阅读!期待下一次!