观察者模式
Node.js 中使用的另一个重要且基本的模式是观察者模式。 与 Reactor 模式和回调一起,观察者模式是掌握 Node.js 异步世界的绝对要求。
观察者模式是对 Node.js 反应性建模的理想解决方案,也是回调的完美补充。 我们给出一个正式的定义,如下:
观察者模式定义了一个对象(称为主题,subject),当其状态发生变化时,该对象可以通知一组观察者(或侦听器,listeners)。 |
与 Callback 模式的主要区别在于,主体实际上可以通知多个观察者,而传统的 CPS 回调通常只会将其结果传播到一个侦听器(即回调)。
事件发射器
在传统的面向对象编程中,观察者模式需要接口、具体类和层次结构。 在 Node.js 中,这一切都变得更加简单。 观察者模式已经内置到核心中,并且可以通过 EventEmitter 类使用。 EventEmitter 类允许我们将一个或多个函数注册为侦听器,当触发特定事件类型时将调用这些函数。 图 3.2 直观地解释了这个概念:
/image-2024-05-06-12-42-22-168.png)
EventEmitter 是从事件核心模块导出的。 下面的代码展示了我们如何获取它的引用:
import { EventEmitter } from 'events'
const emitter = new EventEmitter()
EventEmitter 的基本方法如下:
-
on(event,listener):此方法允许我们为给定的事件类型(字符串)注册新的侦听器(函数)。
-
once(event,listener):此方法注册一个新的侦听器,然后在第一次发出事件后将其删除。
-
emit(event, [arg1], […]):此方法生成一个新事件并提供要传递给侦听器的附加参数。 •removeListener(event,listener):此方法删除指定事件类型的侦听器。
前面的所有方法都将返回 EventEmitter 实例以允许链接。 侦听器函数具有签名函数([arg1], […]),因此它只接受事件发出时提供的参数。
您已经可以看到侦听器和传统 Node.js 回调之间存在很大差异。 事实上,第一个参数不是错误,但它可以是在调用时传递给 emmit() 的任何数据。
创建和使用 EventEmitter
现在让我们看看如何在实践中使用 EventEmitter。 最简单的方法是创建一个新实例并立即使用它。 以下代码向我们展示了一个函数,当文件列表中匹配特定正则表达式时,该函数使用 EventEmitter 实时通知其订阅者:
import { EventEmitter } from 'events'
import { readFile } from 'fs'
function findRegex (files, regex) {
const emitter = new EventEmitter()
for (const file of files) {
readFile(file, 'utf8', (err, content) => {
if (err) {
return emitter.emit('error', err)
}
emitter.emit('fileread', file)
const match = content.match(regex)
if (match) {
match.forEach(elem => emitter.emit('found', file, elem))
}
})
}
return emitter
}
我们刚刚定义的函数返回一个 EventEmitter 实例,该实例将产生三个事件:
-
fileread,当正在读取文件时
-
found,当找到匹配项时
-
error,当读取文件期间发生错误时
现在让我们看看如何使用 findRegex() 函数:
findRegex(
['fileA.txt', 'fileB.json'],
/hello \w+/g
)
.on('fileread', file => console.log(`${file} was read`))
.on('found', (file, match) => console.log(`Matched "${match}" in ${file}`))
.on('error', err => console.error(`Error emitted ${err.message}`))
在刚才定义的代码中,我们为 findRegex() 函数创建的 EventEmitter 产生的三种事件类型分别注册了一个监听器。
传播错误
与回调一样,当发生错误情况时,EventEmitter 不能只是抛出异常。 相反,约定是发出一个称为 error 的特殊事件,并传递一个 Error 对象作为参数。 这正是我们之前定义的 findRegex() 函数中所做的事情。
EventEmitter 以特殊方式处理错误事件。 如果发出此类事件并且未找到关联的侦听器,它将自动引发异常并退出应用程序。 因此,建议始终为错误事件注册一个侦听器。 |
让任何对象都可被观察
在 Node.js 世界中,EventEmitter 很少单独使用,正如您在前面的示例中看到的那样。 相反,更常见的是它被其他类扩展。 实际上,这使得任何类都可以继承 EventEmitter 的功能,从而成为可观察的对象。
为了演示这种模式,让我们尝试在类中实现 findRegex() 函数的功能,如下所示:
import { EventEmitter } from 'events'
import { readFile } from 'fs'
class FindRegex extends EventEmitter {
constructor (regex) {
super()
this.regex = regex
this.files = []
}
addFile (file) {
this.files.push(file)
return this
}
find () {
for (const file of this.files) {
readFile(file, 'utf8', (err, content) => {
if (err) {
return this.emit('error', err)
}
this.emit('fileread', file)
const match = content.match(this.regex)
if (match) {
match.forEach(elem => this.emit('found', file, elem))
}
})
}
return this
}
}
我们刚刚定义的 FindRegex 类扩展了 EventEmitter,成为一个成熟的可观察类。 始终记住在构造函数中使用 super() 来初始化 EventEmitter 内部。
下面是如何使用我们刚刚定义的 FindRegex 类的示例:
const findRegexInstance = new FindRegex(/hello \w+/)
findRegexInstance
.addFile('fileA.txt')
.addFile('fileB.json')
.find()
.on('found', (file, match) => console.log(`Matched "${match}" in file ${file}`))
.on('error', err => console.error(`Error emitted ${err.message}`))
现在您将注意到 FindRegex 对象还提供了 on() 方法,该方法继承自 EventEmitter。 这是 Node.js 生态系统中非常常见的模式。 例如,核心 HTTP 模块的 Server 对象继承自 EventEmitter 函数,从而允许它产生诸如请求(当收到新请求时)、连接(当建立新连接时)或关闭(当服务器套接字已关闭)。
扩展 EventEmitter 的其他值得注意的对象示例是 Node.js 流。 我们将在第 6 章 “使用流进行编码” 中更详细地分析流。
事件发射器和内存泄漏
当订阅具有较长生命周期的可观察对象时,一旦不再需要我们的 listeners,我们就取消订阅它们是非常重要的。 这允许我们释放侦听器范围内的对象使用的内存并防止内存泄漏。 未释放的 EventEmitter 侦听器是 Node.js(以及一般的 JavaScript)中内存泄漏的主要来源。
内存泄漏是一种软件缺陷,不再需要的内存不会被释放,导致应用程序的内存使用量无限增长。 例如,考虑以下代码:
const thisTakesMemory = 'A big string....'
const listener = () => {
console.log(thisTakesMemory)
}
emitter.on('an_event', listener)
变量 thisTakesMemory 在侦听器中被引用,因此它的内存将被保留,直到侦听器从发射器释放,或者直到发射器本身被垃圾收集,这只有在没有更多活动引用它时才会发生,从而使其无法访问。
您可以在 nodejsdp.link/garbage-collection 上找到有关 JavaScript 中的垃圾收集和可达性概念的详细解释。 |
这意味着,如果 EventEmitter 在应用程序的整个持续时间内保持可访问,那么它的所有侦听器以及它们引用的所有内存也是如此。 例如,如果我们在每个传入的 HTTP 请求时向 “永久” EventEmitter 注册一个侦听器并且从不释放它,那么我们就会导致内存泄漏。 应用程序使用的内存会无限增长,有时缓慢,有时更快,但最终会使应用程序崩溃。 为了防止这种情况,我们可以使用 EventEmitter 的 removeListener() 方法释放监听器:
emitter.removeListener('an_event', listener)
EventEmitter 有一个非常简单的内置机制,用于警告开发人员可能的内存泄漏。 当注册到事件的侦听器数量超过特定数量(默认情况下为 10)时,EventEmitter 将生成警告。 有时,注册超过 10 个监听器是完全没问题的,因此我们可以使用 EventEmitter 的 setMaxListeners() 方法来调整此限制。
我们可以使用方便的方法 once(event,listener) 代替 on(event,listener) 在第一次接收到事件后自动注销监听器。 但是,请注意,如果我们指定的事件从未发出,则侦听器永远不会释放,从而导致内存泄漏。 |
同步和异步事件
与回调一样,事件也可以在触发生成事件的任务时同步或异步发出。 至关重要的是,我们永远不要在同一个 EventEmitter 中混合使用这两种方法,但更重要的是,我们永远不应该使用同步和异步代码的混合来发出相同的事件类型,以避免产生释放 Zalgo 部分中描述的相同问题。 发出同步事件和异步事件之间的主要区别在于注册侦听器的方式。
当异步发出事件时,即使在触发生成事件的任务之后,我们也可以注册新的侦听器,直到当前堆栈让出 cpu 给事件循环。 这是因为保证在事件循环的下一个周期之前不会触发事件,因此我们可以确保不会错过任何事件。
我们之前定义的 FindRegex() 类在调用 find() 方法后异步发出其事件。 这就是为什么我们可以在调用 find() 方法后注册监听器,而不会丢失任何事件,如以下代码所示:
findRegexInstance
.addFile(...)
.find()
.on('found', ...)
另一方面,如果我们在任务启动后同步发出事件,则必须在启动任务之前注册所有侦听器,否则我们将错过所有事件。 要了解其工作原理,让我们修改之前定义的 FindRegex 类并使 find() 方法同步:
find () {
for (const file of this.files) {
let content
try {
content = readFileSync(file, 'utf8')
} catch (err) {
this.emit('error', err)
}
this.emit('fileread', file)
const match = content.match(this.regex)
if (match) {
match.forEach(elem => this.emit('found', file, elem))
}
}
return this
}
现在,让我们尝试在启动 find() 任务之前注册一个侦听器,然后再注册第二个侦听器,看看会发生什么:
const findRegexSyncInstance = new FindRegexSync(/hello \w+/)
findRegexSyncInstance
.addFile('fileA.txt')
.addFile('fileB.json')
// this listener is invoked
.on('found', (file, match) => console.log(`[Before] Matched "${match}"`))
.find()
// this listener is never invoked
.on('found', (file, match) => console.log(`[After] Matched "${match}"`))
正如预期的那样,调用 find() 任务后注册的侦听器永远不会被调用; 事实上,前面的代码将打印:
[Before] Matched "hello world"
[Before] Matched "hello NodeJS"
在某些(极少数)情况下,以同步方式发出事件是合理的,但 EventEmitter 的本质就在于它能够处理异步事件。在大多数情况下,同步事件的发生表明我们根本不需要 EventEmitter,或者在其他地方,同一观察对象正在以异步方式发生另一个事件,从而可能导致 Zalgo 类型的情况。
可以使用 process.nextTick() 推迟同步事件的发出,以保证它们是异步发出的。 |
事件发射器与回调
在定义异步 API 时,一个常见的难题是决定是使用 EventEmitter 还是简单地接受回调。一般的区分规则是语义上的:回调应在必须以异步方式返回结果时使用,而事件应在需要传达某件事已发生时使用。
但除了这个简单的原则之外,由于这两种范式在大多数情况下是等效的并且允许我们获得相同的结果,因此产生了很多混乱。 以以下代码为例:
import { EventEmitter } from 'events'
function helloEvents () {
const eventEmitter = new EventEmitter()
setTimeout(() => eventEmitter.emit('complete', 'hello world'), 100)
return eventEmitter
}
function helloCallback (cb) {
setTimeout(() => cb(null, 'hello world'), 100)
}
helloEvents().on('complete', message => console.log(message))
helloCallback((err, message) => console.log(message))
helloEvents() 和 helloCallback() 这两个函数在功能上可以认为是等效的。 第一个使用事件来传达超时的完成,而第二个则使用回调。 但真正区别它们的是可读性、语义以及实现或使用它们所需的代码量。
虽然无法给出一组确定的规则供您在一种样式或另一种样式之间进行选择,但以下一些提示可帮助您决定使用哪种方法:
-
在支持不同类型的事件时,回调有一些限制。 事实上,我们仍然可以通过将类型作为回调的参数传递,或者接受多个回调(每个支持的事件一个)来区分多个事件。 然而,这并不能完全被认为是一个优雅的 API。 在这种情况下,EventEmitter 可以提供更好的界面和更精简的代码。
-
当同一事件可能发生多次或可能根本不发生时,应使用 EventEmitter。 事实上,无论操作成功与否,回调都应该被调用一次。 可能重复的情况应该让我们重新思考事件的语义本质,这更类似于必须传达的事件,而不是要返回的结果。
-
使用回调的 API 只能通知一个特定的回调,而使用 EventEmitter 则允许我们为同一事件注册多个侦听器。
回调与事件的结合
在某些特殊情况下,EventEmitter 可以与回调结合使用。 这种模式非常强大,因为它允许我们使用传统回调异步传递结果,同时返回一个 EventEmitter,它可用于提供有关异步进程状态的更详细说明。
glob 包 (nodejsdp.link/npmglob) 提供了这种模式的一个示例,它是一个执行 glob 样式文件搜索的库。 该模块的主要入口点是它导出的函数,该函数具有以下签名:
const eventEmitter = glob(pattern, [options], callback)
该函数的第一个参数是一个模式、一组选项和一个回调函数,回调函数将调用与所提供模式匹配的所有文件列表。与此同时,该函数还会返回一个 EventEmitter,提供有关搜索过程状态的更精细报告。例如,通过监听匹配事件,可以在匹配发生时获得实时通知;通过监听结束事件,可以获得所有匹配文件的列表;通过监听中止事件,可以了解进程是否被手动中止。下面的代码展示了实际应用中的情况:
import glob from 'glob'
glob('data/*.txt',
(err, files) => {
if (err) {
return console.error(err)
}
console.log(`All files found: ${JSON.stringify(files)}`)
})
.on('match', match => console.log(`Match found: ${match}`))
将 EventEmitter 与传统回调相结合是一种为同一 API 提供两种不同方法的优雅方式。 一种方法通常更简单、更容易使用,而另一种则针对更高级的场景。
EventEmitter 还可以与其他异步机制(例如 Promises)结合使用(我们将在第 5 章 “具有 Promises 和 Async/Await 的异步控制流模式” 中介绍)。 在这种情况下,只需返回一个包含 Promise 和 EventEmitter 的对象(或数组)。 然后调用者可以对该对象进行解构,如下所示:{promise, events} = foo()。 |