Node.js 设计模式笔记 —— Proxy 模式
代理(proxy) 可以理解为一种对象,其能够控制客户端对另一个对象(subject)的访问。代理(proxy)和目标对象(subject)拥有完全相同的接口,可以自由地进行替换。
proxy 会拦截所有或者部分本应该直接交给 subject 执行的操作,通过额外的预处理或后处理增强其行为,再转发给 subject。
Proxy 的主要应用场景:
- Data validation:proxy 对输入数据进行验证,再转发给 subject
- Security:proxy 检查客户端是否有权限执行请求的操作,若检查通过则将请求转发给 subject
- Caching:proxy 负责维护一份内部缓存,只有当请求的数据不在缓存中时,才将该请求转发给 subject 处理
- Lazy initialization:若创建某个对象代价很高,proxy 可以延迟该创建操作直到必要的时候
- Logging:proxy 拦截函数和对应的参数,在函数执行的同时记录日志信息
- Remote objects:proxy 可以接收一个远程对象并令其表现为本地对象
示例代码:StackCalculator
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 的引用。参考如下代码:
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。即需要写出很多如下形式的代码片段:
getValue() {
return this.calculator.getValue()
}
这在很大程度上增加了代码的冗余度。
Object augmentation
对象增强(Object augmentation)又叫做猴子补丁(monkey patching),能够只代理某个对象的部分方法,并且可能是所有方案中最简单、最常见的一种。
它可以将 subject 的某个方法直接替换为 proxy 版本的实现,即直接修改 subject 对象本身。
参考如下代码:
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 的具体行为。它包含一系列可选的预定义方法(如 get
、set
、apply
等),叫做 trap methods,在 subject 上执行对应的操作时会自动触发这些方法。
示例代码:
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
mkdir logwritting && cd logwritting
package.json:
{
"type": "module"
}
logging-writable.js:
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:
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