Node.js 设计模式笔记 —— Callbacks 和 Events

在同步式编程中,为了解决特定的问题,代码被组织成一系列连贯的计算步骤。其中每一个步骤都是阻塞的,即只有当某个操作完成以后,才有可能继续执行下一个步骤。这种方式形成的代码非常容易阅读、理解和调试。

而在异步式编程中,某些操作比如读取文件或者处理一个网络请求,是在“后台”启动和执行的。当我们调用某个异步操作后,即使其并没有执行完毕,该异步操作之后的代码指令也会立刻继续执行。
在这种情况下,我们就需要一种“通知”机制。当异步操作执行完毕,我们会收到通知,获取该操作的结果并继续之前定义的执行流程。在 Node.js 中,最基础的通知机制就是回调函数。它本质上就是一种由 runtime 调用的带有异步操作结果的函数。

Callback 模式

回调函数是一种能够传递操作结果的函数,正是异步编程所需要的。JavaScript 对于回调函数来说是一种理想的语言,函数是第一等对象,可以轻松地赋值给变量、作为参数传递给另一个函数、作为函数的返回值,以及存储到数据结构中。

The continuation-passing style

在 JavaScript 中,回调函数会作为参数传递给另一个函数,并且在操作完成时连同结果一起被调用。即执行结果被传递给另一个函数(callback),而不是直接返回给调用者。这种方式在函数式编程里称作 continuation-passing style (CPS)

下面是一个非常简单的同步函数:

1
2
3
function add(a, b) {
return a + b
}

和上述函数等效的 CPS 形式:

1
2
3
4
5
6
7
8
9
10
function addCps(a, b, callback) {
callback(a + b)
}

console.log('before')
addCps(1, 2, result => console.log(`Result: $result`))
console.log('after')
// => before
// => Result: $result
// => after

addCps 就是一个同步的 CPS 函数。

Asynchronous CPS

addCps 函数的异步版本:

1
2
3
4
5
6
7
8
9
10
function additionAsync(a, b, callback) {
setTimeout(() => callback(a + b), 100)
}

console.log('before')
additionAsync(1, 2, result => console.log(`Result: ${result}`))
console.log('after')
// => before
// => after
// => Result: 3

上面的代码使用 setTimeout 来模拟回调函数的异步调用。由于 setTimeout 触发的是异步操作,它并不会等待回调函数 callback 执行,而是立即返回。将控制权交还给 additionAsync 进而回到调用者身上,执行主程序中的第二个 console.log。当异步操作执行完毕后,程序从之前控制权转移时的位置起恢复执行,callback 中的 console.log 被执行。

Control flow of an asynchronous function's invocation

总结一下就是,同步函数会阻塞其他操作步骤,直到其自身执行完毕;异步函数会立即返回,它的执行结果会在 event loop 的后续周期中传递给 handler(即回调函数)。

同步 or 异步

指令的执行顺序取决于函数的自然属性——同步还是异步,这对于整个应用流程的正确性和效率都有很大的影响。所以需要时刻注意避免制造矛盾和困惑。

Unleashing Zalgo

一个 API 最危险的情形之一,就是有些时候表现为同步另一些情况下表现为异步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {readFile} from 'fs'

const cache = new Map()

function inconsistentRead(filename, cb) {
if (cache.has(filename)) {
// invoked synchronously
cb(cache.get(filename))
} else {
// asynchronous function
readFile(filename, 'utf8', (err, data) => {
cache.set(filename, data)
cb(data)
})
}
}

上述程序就是危险的。假如某个文件是第一次被读取,它会表现为异步操作,读取文件设置缓存;当某个文件的内容已经存在于缓存中时,它会表现为同步操作。

参考下面的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createFileReader(filename) {
const listeners = []
inconsistentRead(filename, value => {
listeners.forEach(listener => listener(value))
})

return {
onDataReady: listener => listeners.push(listener)
}
}

const reader1 = createFileReader('data.txt')
reader1.onDataReady(data => {
console.log(`First call data: ${data}`)

const reader2 = createFileReader('data.txt')
reader2.onDataReady(data => {
console.log(`Second call data: ${data}`)
})
})

其中 createFileReader 函数会创建一个新的 { onDataReady: function() } 对象作为通知器,以帮助我们为文件读取操作设置多个 listener。若 inconsistentRead 是纯异步操作,实际上 onDataReady 会先被调用,将传入的 listener 添加到 listeners 列表中。之后 inconsistentRead 读取文件内容完毕,回调函数 cb 执行,遍历 listeners 列表并将读取到的文件内容传给 listener。

实际的执行结果为:

1
First call data: some data

第二次读取同一个文件并没有获取到任何内容。

原因在于,当 reader1 创建时,inconsistentRead 函数表现为异步的,因为该文件是第一次被读取。因而 onDataReady 会在刚开始读取文件时就将传入的 listener 添加到 listeners 列表中。文件读取完毕后 listeners 中注册的 listener 被调用。
reader2 创建时同一个文件的缓存内容已经存在,inconsistentRead 表现为同步的。它的回调函数会立即调用,遍历 listeners 列表。然而我们是先创建的 reader2 再添加的 listener,这就导致遍历 listeners 列表时,向 listeners 添加 listener 的操作还没有执行,我们传入的 listener 并没有来得及注册。

在实际的应用中,上述类型的 bug 会非常难以定位和复现。npm 的创造者 Isaac Z. Schlueter 将类似的使用不可预测函数的行为,叫做 unleashing Zalgo

使用同步 API

想修复前面的 inconsistentRead 函数,一种可能的方案就是令其彻底变成同步的。实际上 Node.js 针对基础的 I/O 操作提供了一系列同步的 API。比如 fs.readFileSync

1
2
3
4
5
6
7
8
9
10
11
12
13
import {readFileSync} from 'fs'

const cache = new Map()

function consistentReadSync(filename) {
if (cache.has(filename)) {
return cache.get(filename)
} else {
const data = readFileSync(filename)
cache.set(filename, data)
return data
}
}

但是,使用同步 API 而不是异步 API 也有一定的风险:

  • 针对特定功能的同步 API 有可能不存在
  • 同步 API 会阻塞 event loop,暂停任何并发请求。从而破坏 Node.js 的并发模型并拖慢整个应用

在很多情况下,使用同步 I/O 操作在 Node.js 里都是非常不推荐的。但在一些场景下,同步 I/O 可能是最简单和高效的方案。比如在应用启动时使用同步阻塞 API 加载配置文件。

通过延迟执行保证异步性

另一种修复 inconsistentRead 函数的方案就是,将其变成纯异步操作。诀窍就是将同步的回调函数延期到“未来”执行,而不是在同一个 event loop 周期里立即被调用。
在 Node.js 中,可以通过 process.nextTick() 来实现。它会接收一个回调函数作为参数,将其推入到事件队列顶部,位于所有 pending 的 I/O 事件之前,然后立即返回。回调函数会在 event loop 再次收回控制权时立即被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {readFile} from 'fs'

const cache = new Map()

function inconsistentRead(filename, callback) {
if (cache.has(filename)) {
// deferred callback invocation
process.nextTick(() => callback(cache.get(filename)))
} else {
// asynchronous function
readFile(filename, 'utf8', (err, data) => {
cache.set(filename, data)
callback(data)
})
}
}

Node.js 回调函数的最佳实践

回调函数出现在最后

在所有核心的 Node.js 函数中,当其接收一个回调函数作为输入时,回调函数必须作为最后一个参数传入。

1
readFile(filename, [options], callback)

error 总是出现在前面

在 Node.js 中,任何 CPS 函数产生的错误都必须作为回调函数的第一个参数传递,任何实际的执行结果都从第二个参数开始。

1
2
3
4
5
6
7
readFile('foo.txt', 'utf8', (err, data) => {
if (err) {
handleError(err)
} else {
processData(data)
}
})

最佳实践还在于总是检查 error 是否存在,以及 error 的定义必须是 Error 类型。

传递 error

在同步的函数中,传递 error 可以通过常用的 throw 语句。而在异步的 CPS 函数中,则可以简单地将 error 传递给链条上的下一个回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {readFile} from 'fs'

function readJSON(filename, callack) {
readFile(filename, 'utf8', (err, data) => {
let parsed
if (err) {
// propagate the error and exit the current function
return callack(err)
}

try {
// parse the file contents
parsed = JSON.parse(data)
} catch (err) {
// catch parsing errors
return callack(err)
}
// no errors, propagate just the data
callack(null, parsed)
})
}

观察者模式

在 Node.js 中另外一种非常重要和基础的模式就是观察者(Ovserver)模式。同 Reactor 模式、回调函数一起,它们都是掌握 Node.js 异步编程的绝对要求。
观察者模式定义了一类称为 subject 的对象,它们可以在状态改变时向一系列称为观察者的对象发送通知。它是对回调函数的完美补充。主要区别在于 subject 能够通知多个观察者,而传统的 CPS 回调函数通常只会将结果传递给一个 listener。

EventEmitter

观察者模式实际上已经通过 EventEmitter 类内置到 Node.js 的核心中了。EventEmitter 类允许我们注册一个或者多个函数作为 listener,这些 listener 会在特定的事件触发时自动被调用。

Listeners receiving events from an EventEmitter

EventEmitter 类的基础方法如下:

  • on(event, listener):该方法允许我们为指定的事件类型(一个字符串)注册一个新的 listener(一个函数)
  • once(event, listener):该方法允许我们注册一个新的 listener,并且该 listener 会在事件触发一次之后自动被移除
  • emit(event, [arg1], [...]):该方法会产生一个新的事件,并向指定向 listeners 传递的额外的参数
  • removeListener(event, listener):该方法用来移除某个 listener

上述所有的方法都会返回一个 EventEmitter 实例并允许被串联起来。

创建和使用 EventEmitter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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
}

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}`))
令任意对象变得“可监测”

在 Node.js 的世界里,EventEmitter 很少像上面的例子那样被直接使用。更为常见的情况是其他类继承 EventEmitter 从而变成一个可监测的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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
}
}

const findRegexInstance = new FindRegex(/hello \w+/g)
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}`))

EventEmitter vs Callback

以下的几点可以作为选择 EventEmitter 还是 Callback 的依据:

  • 当涉及到需要支持不同类型的事件时,Callback 会有一定的限制。实际上 Callback 也可以区分多个事件,只需要将事件类型作为参数传给回调函数,或者接收多个回调函数。但在这样的情况下,EventEmitter 可以提供更优雅的接口和更精简的代码
  • 当同样的事件可能多次发生或者根本不会发生时,应该使用 EventEmitter。而无论操作是否成功,回调函数都只会被调用一次
  • 回调函数机制只支持通知一个特定的 listener,而 EventEmitter 允许我们为同一个事件注册多个 listener

参考资料

Node.js Design Patterns: Design and implement production-grade Node.js applications using proven patterns and techniques, 3rd Edition