Node.js 源码解析 util.promisify 如何将 Callback 转为 Promise

2,319 阅读6分钟

Nodejs util 模块提供了很多工具函数。为了解决回调地狱问题,Nodejs v8.0.0 提供了 promisify 方法可以将 Callback 转为 Promise 对象。

工作中对于一些老项目,有 callback 的通常也会使用 util.promisify 进行转换,之前更多是知其然不知其所以然,本文会从基本使用和对源码的理解实现一个类似的函数功能。

1. Promisify 简单版本实现

在介绍 util.promisify 的基础使用之后,实现一个自定义的 util.promisify 函数的简单版本。

1.1 util promisify 基本使用

将 callback 转为 promise 对象,首先要确保这个 callback 为一个错误优先的回调函数,即 (err, value) => ... err 指定一个错误参数,value 为返回值。

以下将以 fs.readFile 为例进行介绍。

创建一个 text.txt 文件

创建一个 text.txt 文件,写入一些自定义内容,下面的 Demo 中我们会使用 fs.readFile 来读取这个文件进行测试。

// text.txt
Nodejs Callback 转 Promise 对象测试

传统的 Callback 写法

const util = require('util');

fs.readFile('text.txt', 'utf8', function(err, result) {
  console.error('Error: ', err); 
  console.log('Result: ', result); // Nodejs Callback 转 Promise 对象测试
});

Promise 写法

这里我们使用 util.promisify 将 fs.readFile 转为 Promise 对象,之后我们可以进行 .then、.catch 获取相应结果

const { promisify } = require('util');
const readFilePromisify = util.promisify(fs.readFile); // 转化为 promise

readFilePromisify('text.txt', 'utf8')
  .then(result => console.log(result)) // Nodejs Callback 转 Promise 对象测试
  .catch(err => console.log(err));

1.2 自定义 mayJunPromisify 函数实现

自定义 mayJunPromisify 函数实现 callback 转换为 promise,核心实现如下:

  • 行 {1} 校验传入的参数 original 是否为 Function,不是则抛错
  • promisify(fs.readFile) 执行之后会返回一个函数 fn,行 {2} 定义待返回的 fn 函数,行 {3} 处返回
  • fn 返回的是一个 Promise 对象,在返回的 Promise 对象里执行 callback 函数
function mayJunPromisify(original) {
  if (typeof original !== 'function') { // {1} 校验
    throw new Error('The "original" argument must be of type Function. Received type undefined')
  }

  function fn(...args) { // {2} 
    return new Promise((resolve, reject) => {
      try {
        // original 例如,fs.readFile.call(this, 'filename', 'utf8', (err, result) => ...)
        original.call(this, ...args, (err, result) => {
          if (err) {
            reject(err);
          } else {
            resolve(result);
          }
        });
      } catch(err) {
        reject(err);
      }
    });
  }

  return fn; // {3}
}

现在使用我们自定义的 mayJunPromisify 函数做一个测试

const readFilePromisify = mayJunPromisify(fs.readFile);

readFilePromisify('text.txt', 'utf8')
  .then(result => console.log(result)) // Nodejs Callback 转 Promise 对象测试
  .catch(err => console.log(err));

2. Promisify 自定义 Promise 函数版本实现

另一个功能是可以使用 util.promisify.custom 符号重写 util.promisify 返回值。

2.1 util.promisify.custom 基本使用

在 fs.readFile 上定义 util.promisify.custom 符号,其功能为禁止读取文件。

注意顺序要在 util.promisify 之前。

fs.readFile[util.promisify.custom] = () => {
  return Promise.reject('该文件暂时禁止读取');
}

const readFilePromisify = util.promisify(fs.readFile);

readFilePromisify('text.txt', 'utf8')
  .then(result => console.log(result))
  .catch(err => console.log(err)); // 该文件暂时禁止读取

2.2 自定义 mayJunPromisify.custom 实现

  • 定义一个 Symbol 变量 kCustomPromisifiedSymbol 赋予 mayJunPromisify.custom
  • 行 {1} 校验是否有自定义的 promise 化函数
  • 行 {2} 自定义的 mayJunPromisify.custom 也要保证是一个函数,否则抛错
  • 行 {3} 直接返回自定义的 mayJunPromisify.custom 函数,后续的 fn 函数就不会执行了,因此在这块也就重写了 util.promisify 返回值
// 所以说 util.promisify.custom 是一个符号
const kCustomPromisifiedSymbol = Symbol('util.promisify.custom');
mayJunPromisify.custom = kCustomPromisifiedSymbol;

function mayJunPromisify(original) {
  if (typeof original !== 'function') {
    throw new Error('The "original" argument must be of type Function. Received type undefined')
  }

  // 变动之处 -> start
  if (original[kCustomPromisifiedSymbol]) { // {1}
    const fn = original[kCustomPromisifiedSymbol];
    if (typeof fn !== 'function') { // {2}
      throw new Error('The "mayJunPromisify.custom" property must be of type Function. Received type number');
    }
    
    // {3}
    return Object.defineProperty(fn, kCustomPromisifiedSymbol, {
      value: fn, enumerable: false, writable: false, configurable: true
    });
  }
  // end <- 变动之处
  
  function fn(...args) {
    ...
  }

  return fn;
}

同样测试下我们自定义的 mayJunPromisify.custom 函数。

fs.readFile[mayJunPromisify.custom] = () => {
  return Promise.reject('该文件暂时禁止读取');
}

const readFilePromisify = mayJunPromisify(fs.readFile);

readFilePromisify('text.txt', 'utf8')
  .then(result => console.log(result))
  .catch(err => console.log(err)); // 该文件暂时禁止读取

3. Promisify 回调函数的多参转换

通常情况下我们是 (err, value) => ... 这种方式实现的,结果只有 value 一个参数,但是呢有些例外情况,例如 dns.lookup 它的回调形式是 (err, address, family) => ... 拥有三个参数,同样我们也要对这种情况做兼容。

3.1 util.promisify 中的基本使用

和上面区别的地方在于 .then 接收到的是一个对象 { address, family } 先明白它的基本使用,下面会展开具体是怎么实现的

const dns = require('dns');
const lookupPromisify = util.promisify(dns.lookup);

lookupPromisify('nodejs.red')
  .then(({ address, family }) => {
    console.log('地址: %j 地址族: IPv%s', address, family);
  })
  .catch(err => console.log(err));

3.2 util.promisify 实现解析

类似 dns.lookup 这样的函数在回调(Callback)时提供了多个参数列表。

为了支持 util.promisify 也都会在函数上定义一个 customPromisifyArgs 参数,value 为回调时的多个参数名称,类型为数组,例如 dns.lookup 绑定的 customPromisifyArgs 的 value 则为 ['address', 'family'],其主要目的也是为了适配 util.promisify。

dns.lookup 支持 util.promisify 核心实现

// https://github.com/nodejs/node/blob/v12.x/lib/dns.js#L33
const { customPromisifyArgs } = require('internal/util');

// https://github.com/nodejs/node/blob/v12.x/lib/dns.js#L159
ObjectDefineProperty(lookup, customPromisifyArgs,
                     { value: ['address', 'family'], enumerable: false });

customPromisifyArgs

customPromisifyArgs 这个参数是从 internal/util 模块导出的,仅内部调用,因此我们在外部 util.promisify 上是没有这个参数的。

也意味着只有 Nodejs 模块中例如 dns.klookup()、fs.read() 等方法在多参数的时候可以使用 util.promisify 转为 Promise,如果我们自定义的 callback 存在多参数的情况,使用 util.promisify 则不行,当然,如果你有需要也可以基于 util.promisify 自己封装一个。

// https://github.com/nodejs/node/blob/v12.x/lib/internal/util.js#L429
module.exports = {
  ...
  // Symbol used to customize promisify conversion
  customPromisifyArgs: kCustomPromisifyArgsSymbol,
};

util.promisify 核心实现解析

参见源码 internal/util.js#L277

  • 行 {1} 定义 Symbol 变量 kCustomPromisifyArgsSymbol
  • 行 {2} 获取参数名称列表
  • 行 {3} (err, result) 改为 (err, ...values),原先 result 仅接收一个参数,改为 ...values 接收多个参数
  • 行 {4} argumentNames 存在且 values > 1,则回调会存在多个参数名称,进行遍历,返回一个 obj
  • 行 {5} 否则 values 最多仅有一个参数名称,即数组 values 有且仅有一个元素
// https://github.com/nodejs/node/blob/v12.x/lib/internal/util.js#L277
const kCustomPromisifyArgsSymbol = Symbol('customPromisifyArgs'); // {1}

function promisify(original) {
  ...

  // 获取多个回调函数的参数名称列表
  const argumentNames = original[kCustomPromisifyArgsSymbol]; // {2}

  function fn(...args) {
    return new Promise((resolve, reject) => {
      try {
        // (err, result) 改为 (err, ...values) {3}
        original.call(this, ...args, (err, ...values) => {
          if (err) {
            reject(err);
          } else {
            // 变动之处 -> start
            // argumentNames 存在且 values > 1,则回调会存在多个参数名称,进行遍历,返回一个 obj
            if (argumentNames !== undefined && values.length > 1) { // {4}
              const obj = {};
              for (let i = 0; i < argumentNames.length; i++)
                obj[argumentNames[i]] = values[i];
              resolve(obj);
            } else { // {5} 否则 values 最多仅有一个参数名称,即数组 values 有且仅有一个元素
              resolve(values[0]);
            }
            // end <- 变动之处
          }
        });
      } catch(err) {
        reject(err);
      }
    });
  }

  return fn;
}

4. 实现一个完整的 promisify

上面第一、第二节我们自定义实现的 mayJumPromisify 分别实现了含有 (err, result) => ... 和自定义 Promise 函数功能。

第三节中介绍的回调函数多参数转换,由于 kCustomPromisifyArgsSymbol 使用 Symbol 声明(每次重新定义都会不一样),且没有对外提供,如果要实现第三个功能,需要我们每次在 callback 函数上重新定义 kCustomPromisifyArgsSymbol 属性。

例如,以下定义了一个 callback 函数用来获取用户信息,返回值是多个参数 name、age,通过定义 kCustomPromisifyArgsSymbol 属性,即可使用我们自己写的 mayJunPromisify 来转换为 Promise 形式。

function getUserById(id, cb) {
  const name = '张三', age = 20;

  cb(null, name, age);
}

Object.defineProperty(getUserById, kCustomPromisifyArgsSymbol, {
  value: ['name', 'age'], enumerable: false 
})

const getUserByIdPromisify = mayJunPromisify(getUserById);

getUserByIdPromisify(1)
  .then(({ name, age }) => {
    console.log(name, age);
  })
  .catch(err => console.log(err));
  

自定义 mayJunPromisify 实现源码

https://github.com/Q-Angelo/project-training/tree/master/nodejs/module/promisify

总结

util.promisify 是 Nodejs 提供的一个实用工具函数用于将 callback 转为 promise,本节从基本使用 (err, result) => ... 转 Promise自定义 Promise 函数重写 util.promisify 返回值Promisify 回调函数的多参转换三个方面进行了讲解,在理解了其实现之后自己也可以实现一个类似的函数。