nodejs promisify 实现

Liu Bowen

Liu Bowen / 2020, 八月, 20

本文主要阐述了在 nodejs 中的 promisify 的实现方式,并归纳其核心原理。

本文以成文之时的 nodejslatest 版本 v14.8.0 为例。

是什么

回答 promisify 是什么 这个问题,本质上是在解决 promisify 可以为我们解决什么问题。在 node.js v14.x 文档中,promisify 有以下定义:

js
const util = require('util')
const fs = require('fs')

const stat = util.promisify(fs.stat)
stat('.')
  .then(stats => {
    // Do something with `stats`
  })
  .catch(error => {
    // Handle the error.
  })

Takes a function following the common error-first callback style, i.e. taking an (err, value) => ... callback as the last argument, and returns a version that returns promises.

nodejs promisify 函数支持将所有形如支持 (err, value) => ... 形式回调函数的异步操作函数转化为一个始终返回 promise 实例的异步操作函数。

函数签名

TypeScript v3.9.7@types/node 中存在多种函数重载签名,其中 常规签名 如下:

ts
function promisify<TResult>(
  fn: (callback: (err: any, result: TResult) => void) => void
): () => Promise<TResult>

没有指定自定义的 promisify 的行为时,将始终返回回调结果的 第一个 结果值。

ts
// promisify 后将返回 value 值
(err, value) => void

// 在没有自定义 promisify 时,将 **仅返回** value0,而不返回 value1
(err, value0, value1) => void

源码实现

首先 debug nodejs 源码存在至少两种可行方式:

  1. 根据引入的 package 在源码中,逐步向上找到目标源码;

  2. 借助 editorIDE 自带断点功能和单步调试直接找到目标源码。

方式一需要额外的前置 nodejs 源码通识找到 package 的引入代码入口,故本文为了集中文章思想,将通过方式二阐述 promisify 的实现。并且在后文对断点的阐述其实可以反推方式一的推导过程。

首先定义以下 demo file:

js
const { promisify } = require('util')
const fs = require('fs')

debugger
const readFile = promisify(fs.readFile)

在开启 VS Code 自动附着 功能后,在 VS Code 集成终端中输入:

bash
node --inspect-brk <DEMO_FILE_NAME>.js

此时进入专有 Debug 模式,那么在 F11 单步调试的情况下,可进入 promisify 在当前 nodejs 版本下的实现(于 lib/internal/util.js):

js
const kCustomPromisifiedSymbol = Symbol('util.promisify.custom')
const kCustomPromisifyArgsSymbol = Symbol('customPromisifyArgs')

function promisify(original) {
  if (typeof original !== 'function')
    throw new ERR_INVALID_ARG_TYPE('original', 'Function', original)

  if (original[kCustomPromisifiedSymbol]) {
    const fn = original[kCustomPromisifiedSymbol]
    if (typeof fn !== 'function') {
      throw new ERR_INVALID_ARG_TYPE('util.promisify.custom', 'Function', fn)
    }
    return ObjectDefineProperty(fn, kCustomPromisifiedSymbol, {
      value: fn,
      enumerable: false,
      writable: false,
      configurable: true
    })
  }

  // Names to create an object from in case the callback receives multiple
  // arguments, e.g. ['bytesRead', 'buffer'] for fs.read.
  const argumentNames = original[kCustomPromisifyArgsSymbol]

  function fn(...args) {
    return new Promise((resolve, reject) => {
      original.call(this, ...args, (err, ...values) => {
        if (err) {
          return reject(err)
        }
        if (argumentNames !== undefined && values.length > 1) {
          const obj = {}
          for (let i = 0; i < argumentNames.length; i++)
            obj[argumentNames[i]] = values[i]
          resolve(obj)
        } else {
          resolve(values[0])
        }
      })
    })
  }

  ObjectSetPrototypeOf(fn, ObjectGetPrototypeOf(original))

  ObjectDefineProperty(fn, kCustomPromisifiedSymbol, {
    value: fn,
    enumerable: false,
    writable: false,
    configurable: true
  })
  return ObjectDefineProperties(fn, ObjectGetOwnPropertyDescriptors(original))
}

对于以上源码进行伪代码抽象,可以得到:

js
function promisify(original) {
  // 保证 promisify 处理仅对函数生效
  if (isNotFunction(original)) {
    throw INVALID_ORIGINAL_TYPE_ERROR
  }

  // 边界情况处理,当传入的函数为已经被 promisify 的函数时,确保原 promisify 函数
  // 及其特定字段仍然符合 promisify 后续的调用规则
  if (isPromisifyFunction(original)) {
    if (
      isFunctionValueOfPromisifySpecialField(original[PROMISIFY_SYMBOL_FIELD])
    ) {
      throw INVALID_PROMISIFY_FILED_VALUE
    }
  }

  // 将传入的 original 函数 promisify 的关键处理逻辑,此时的 fn 即为 promisify 化
  // 后的 original 结果函数
  function fn(...args) {
    // 始终返回一个 Promise 实例
    return promisifyOriginal
  }

  // 重新定义 fn 的隐式原型,以保证能够通过 fn 调用 original 原型链上的原型方法
  reReferenceFnPrototypeToOriginal(fn, original)

  // 标识结果函数 fn 为 promisify 化处理过的函数,通过基本类型值 symbol 来确保
  // 键名唯一
  // 该特定属性保存的是 promisify 结果本身
  definePromisifyFieldIntoFn(fn, promisifyField, descriptors)

  // 重新定义 fn 的所有属性描述符回到 original 自身的属性描述符,此举为了保证
  // promisify 化后的结果函数 fn 仍具有与 original 相同的各项属性及其属性描述
  return reDefineAllDescriptorFnToDescriptor(fn, original)
}

经过以上抽象,不难看出 promisify 实现功能的核心在于其内部的 fn 函数:

js
// Names to create an object from in case the callback receives multiple
// arguments, e.g. ['bytesRead', 'buffer'] for fs.read.
const argumentNames = original[kCustomPromisifyArgsSymbol]

function fn(...args) {
  return new Promise((resolve, reject) => {
    original.call(this, ...args, (err, ...values) => {
      if (err) {
        return reject(err)
      }
      // 该 if 分支仅对自定义 promisify 函数生效
      if (argumentNames !== undefined && values.length > 1) {
        const obj = {}
        for (let i = 0; i < argumentNames.length; i++)
          obj[argumentNames[i]] = values[i]
        resolve(obj)
      } else {
        resolve(values[0])
      }
    })
  })
}

其核心功能在于:

  1. 始终返回一个 promise 实例;

  2. promise 实例化回调中,通过 call 原型方法调用 original 函数,并传入特定的回调函数;

  3. 基于 2 的回调函数会在被调用时,resolve 异步操作的结果或 reject 异步操作失败的原因。值得注意的是,处理定义了自定义 promisifysymbol(即 kCustomPromisifyArgsSymbol)的异步方法的回调会以 object 的形式 resolve 结果外,其他所有异步操作的结果仅支持 第一个回调结果值

自定义 promisify

promisify 提供了自定义 promisify 功能,开发者可通过形如以下方式实现自定义 promisify 行为:

js
const util = require('util')

// 在 nodejs 运行时中,Symbol.for('nodejs.util.promisify.custom') 等价于下文
// util.promisify.custom
doSomething[util.promisify.custom] = foo => {
  return getPromiseSomehow()
}

前文中我们忽略了存在 kCustomPromisifyArgsSymbol 定义的自定义的 promisify 的实现路径。本质上,不论是自定义的 promisify 还是 nodejs 内置默认的 promisify 行为其实现核心都是一致的,那么就是始终通过一个 promise 实例来包裹对应的异步操作,并在异步操作完成时,在回调函数内部 fulfilled 当前 promise 实例,进而实现基于 promise 的回调链路。他们的 不同之处 在于对回调函数实参的处理方式。

js
function fn(...args) {
  return new Promise((resolve, reject) => {
    original.call(this, ...args, (err, ...values) => {
      // ...
      if (argumentNames !== undefined && values.length > 1) {
        const obj = {}
        for (let i = 0; i < argumentNames.length; i++)
          obj[argumentNames[i]] = values[i]
        resolve(obj)
      } // ...
    })
  })
}

从上文源码可见,对于自定义的 promisify 行为,在 fulfilled 返回的 promise 实例时,将 不再 直接传入原 (err, value) => any 中的第二结果参数了,转而将后续的所有参数通过 rest 参数 的形式接收,并通过 key-value 的形式赋值给 fulfilledpromise 结果实例。那么就是说在自定义 promisify 行为时,若存在多个结果参数时,将通过 object 返回给 then 回调。