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

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 的具体行为。它包含一系列可选的预定义方法(如 getsetapply 等),叫做 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

参考资料

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

你可能感兴趣的:(Node.js 设计模式笔记 —— Proxy 模式)