Node.js 设计模式笔记 —— Proxy 模式

代理(proxy) 可以理解为一种对象,其能够控制客户端对另一个对象(subject)的访问。代理(proxy)和目标对象(subject)拥有完全相同的接口,可以自由地进行替换。
proxy 会拦截所有或者部分本应该直接交给 subject 执行的操作,通过额外的预处理或后处理增强其行为,再转发给 subject。
Proxy pattern schematic

Proxy 的主要应用场景:

  • Data validation:proxy 对输入数据进行验证,再转发给 subject
  • Security:proxy 检查客户端是否有权限执行请求的操作,若检查通过则将请求转发给 subject
  • Caching:proxy 负责维护一份内部缓存,只有当请求的数据不在缓存中时,才将该请求转发给 subject 处理
  • Lazy initialization:若创建某个对象代价很高,proxy 可以延迟该创建操作直到必要的时候
  • Logging:proxy 拦截函数和对应的参数,在函数执行的同时记录日志信息
  • Remote objects:proxy 可以接收一个远程对象并令其表现为本地对象

示例代码:StackCalculator

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
42
43
44
45
class StackCalculator {
constructor() {
this.stack = []
}

putValue(value) {
this.stack.push(value)
}

getValue() {
return this.stack.pop()
}

peekValue() {
return this.stack[this.stack.length - 1]
}

clear() {
this.stack = []
}

divide() {
const divisor = this.getValue()
const dividend = this.getValue()
const result = dividend / divisor
this.putValue(result)
return result
}

multiply() {
const multiplicand = this.getValue()
const multiplier = this.getValue()
const result = multiplier * multiplicand
this.putValue(result)
return result
}
}


const calculator = new StackCalculator()
calculator.putValue(3)
calculator.putValue(2)
console.log(calculator.multiply()) // 3 * 2 = 6
calculator.putValue(2)
console.log(calculator.multiply()) // 6 * 2 = 12

现代的计算器基本上都遵循类似的逻辑,即上一个式子的计算结果可以作为下一次计算的输入。
在 JavaScript 中,当用户尝试除以 0 时,并不会报错而是返回 Infinity。现在我们尝试借助 Proxy 模式来增强 StackCalculator 除以 0 时的行为。

Object composition

组合(Composition)表示一个对象通过引用另一个对象,来扩展或者使用后者的功能。
借助组合可以实现 Proxy 模式。创建一个新的对象,令其有着和 subject 完全一致的接口,同时内部还保存着一个对 subject 的引用。参考如下代码:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class StackCalculator {
// see above
}

class SafeCalculator {
constructor(calculator) {
this.calculator = calculator
}

divide() {
const divisor = this.calculator.peekValue()
if (divisor === 0) {
throw Error('Division by 0')
}
return this.calculator.divide()
}

putValue(value) {
return this.calculator.putValue(value)
}

getValue() {
return this.calculator.getValue()
}

peekValue() {
return this.calculator.peekValue()
}

clear() {
return this.calculator.clear()
}

multiply() {
return this.calculator.multiply()
}
}

const calculator = new StackCalculator()
const safeCalculator = new SafeCalculator(calculator)

calculator.putValue(3)
calculator.putValue(2)
console.log(calculator.multiply()) // 3 * 2 = 6

safeCalculator.putValue(2)
console.log(safeCalculator.multiply()) // 6 * 2 = 12

calculator.putValue(0)
console.log(calculator.divide()) // 12 / 0 = Infinity

safeCalculator.clear()
safeCalculator.putValue(4)
safeCalculator.putValue(0)
console.log(safeCalculator.divide()) // 4 / 0 -> Error

在这次的实现中,proxy 拦截了感兴趣的方法(divide()),为其实现了新的行为(除以 0),而其他的操作(如 putValue()getValue()peekValue()clear()multiply())则是简单地分派给 subject 去做。
计算器的状态(栈中存放的值)仍由 calculator 实例在维护,SafeCalculator 只是调用 calculator 的方法来读取或者修改这些状态。

上面的实现方式,需要我们显式地将很多方法指派给 subject。即需要写出很多如下形式的代码片段:

1
2
3
getValue() {
return this.calculator.getValue()
}

这在很大程度上增加了代码的冗余度。

Object augmentation

对象增强(Object augmentation)又叫做猴子补丁(monkey patching),能够只代理某个对象的部分方法,并且可能是所有方案中最简单、最常见的一种。
它可以将 subject 的某个方法直接替换为 proxy 版本的实现,即直接修改 subject 对象本身。

参考如下代码:

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
class StackCalculator {
// see above
}


function patchToSafeCalculator(calculator) {
const divideOrig = calculator.divide
calculator.divide = () => {
// additional validation logic
const divisor = calculator.peekValue()
if (divisor === 0) {
throw Error('Division by 0')
}
// if valid, delegates to the subject
return divideOrig.apply(calculator)
}

return calculator
}

const calculator = new StackCalculator()
const safeCalculator = new patchToSafeCalculator(calculator)

safeCalculator.putValue(4)
safeCalculator.putValue(0)
// console.log(calculator.divide()) // Error, not Infinity
console.log(safeCalculator.divide()) // 4 / 0 -> Error

当只需要代理某一个或几个方法的时候,上述方案会非常方便。用户不需要再手动重新实现一遍 putValue() 等方法。
不幸的是,简单化也带来了一定的代价,像上面那样直接修改 subject 对象是一种危险的行为。当该 subject 对象被其他部分的代码共享时,修改行为必须尽一切可能避免,从而不至于引发意想不到的 side effect。
尝试将代码中的 // console.log(calculator.divide()) 取消注释,会发现 calculator 并没有像之前那样输出 Infinity,而是跟 safeCalculator 一样报出错误。即原来的 calculator 对象已经被猴子补丁所改变。

内置的 Proxy 对象

ES2015 引入了一种原生的创建 proxy 对象的方式。其语法如下:
const proxy = new Proxy(target, handler)

其中 target 代表被 proxy 代理的对象(即 subject),handler 对象则用来定义 proxy 的具体行为。它包含一系列可选的预定义方法(如 getsetapply 等),叫做 trap methods,在 subject 上执行对应的操作时会自动触发这些方法。

示例代码:

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
class StackCalculator {
// see above
}


const safeCalculatorHandler = {
get: (target, property) => {
if (property === 'divide') {
// proxied method
return function () {
// additional validation logic
const divisor = target.peekValue()
if (divisor === 0) {
throw Error('Division by 0')
}
// if valid, delegates to the subject
return target.divide()
}
}

// delegated methods and properties
return target[property]
}
}

const calculator = new StackCalculator()
const safeCalculator = new Proxy(
calculator,
safeCalculatorHandler
)


calculator.putValue(4)
calculator.putValue(0)
console.log(calculator.divide()) // Infinity

safeCalculator.clear()
safeCalculator.putValue(4)
safeCalculator.putValue(0)
console.log(safeCalculator.divide()) // 4 / 0 -> Error

在上面的代码中,通过 get trap method 捕获对于原本的 calculator 对象的属性和方法的访问,当访问的方法是 divide() 时,proxy 就会返回一个添加了额外验证逻辑的新函数。
之后又简单地使用 target[property] 返回了所有未修改过的属性和方法。

总的来说,Proxy 对象为我们提供了一个非常简单的方法,只代理 subject 的一部分功能,且不需要显式地将未代理的方法移交给 subject。同时也不会对原本的 subject 做出任何改动。

几种 proxy 实现机制的比较
  • Composition:最直观和安全,subject 不会被修改。但需要手动将未代理的方法指派给 subject。冗余代码
  • Object augmentation:会直接修改原本的 subject 对象,不够安全。不需要手动处理未代理的方法
  • Proxy 对象:提供了更高级的访问控制。支持更多类型的属性访问,比如可以拦截 subject 对自身属性的删除等操作。不会修改 subject 本身,只需要使用一句代码处理未代理的方法

实例:logging Writable stream

1
mkdir logwritting && cd logwritting

package.json:

1
2
3
{
"type": "module"
}

logging-writable.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function createLoggingWritable(writable) {
return new Proxy(writable, {
get(target, propKey) {
if (propKey === 'write') {
return function (...args) {
const [chunk] = args
console.log('Writing', chunk)
return writable.write(...args)
}
}
return target[propKey]
}
})
}

index.js:

1
2
3
4
5
6
7
8
9
10
11
12
import {createWriteStream} from 'fs'
import {createLoggingWritable} from './logging-writable.js'

const writable = createWriteStream('test.txt')
const writableProxy = createLoggingWritable(writable)

writableProxy.write('First chunk')
writableProxy.write('Second chunk')
writable.write('This is not logged')
writableProxy.end()
// => Writing First chunk
// => Writing Second chunk

参考资料

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