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

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 来读取这个文件进行测试。

  1. // text.txt
  2. Nodejs Callback Promise 对象测试

传统的 Callback 写法

  1. const util = require('util');
  2. fs.readFile('text.txt', 'utf8', function(err, result) {
  3. console.error('Error: ', err);
  4. console.log('Result: ', result); // Nodejs Callback 转 Promise 对象测试
  5. });

Promise 写法

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

  1. const { promisify } = require('util');
  2. const readFilePromisify = util.promisify(fs.readFile); // 转化为 promise
  3. readFilePromisify('text.txt', 'utf8')
  4. .then(result => console.log(result)) // Nodejs Callback 转 Promise 对象测试
  5. .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 函数
  1. function mayJunPromisify(original) {
  2. if (typeof original !== 'function') { // {1} 校验
  3. throw new Error('The "original" argument must be of type Function. Received type undefined')
  4. }
  5. function fn(...args) { // {2}
  6. return new Promise((resolve, reject) => {
  7. try {
  8. // original 例如,fs.readFile.call(this, 'filename', 'utf8', (err, result) => ...)
  9. original.call(this, ...args, (err, result) => {
  10. if (err) {
  11. reject(err);
  12. } else {
  13. resolve(result);
  14. }
  15. });
  16. } catch(err) {
  17. reject(err);
  18. }
  19. });
  20. }
  21. return fn; // {3}
  22. }

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

  1. const readFilePromisify = mayJunPromisify(fs.readFile);
  2. readFilePromisify('text.txt', 'utf8')
  3. .then(result => console.log(result)) // Nodejs Callback 转 Promise 对象测试
  4. .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 之前。

  1. fs.readFile[util.promisify.custom] = () => {
  2. return Promise.reject('该文件暂时禁止读取');
  3. }
  4. const readFilePromisify = util.promisify(fs.readFile);
  5. readFilePromisify('text.txt', 'utf8')
  6. .then(result => console.log(result))
  7. .catch(err => console.log(err)); // 该文件暂时禁止读取

2.2 自定义 mayJunPromisify.custom 实现

  • 定义一个 Symbol 变量 kCustomPromisifiedSymbol 赋予 mayJunPromisify.custom
  • 行 {1} 校验是否有自定义的 promise 化函数
  • 行 {2} 自定义的 mayJunPromisify.custom 也要保证是一个函数,否则抛错
  • 行 {3} 直接返回自定义的 mayJunPromisify.custom 函数,后续的 fn 函数就不会执行了,因此在这块也就重写了 util.promisify 返回值
  1. // 所以说 util.promisify.custom 是一个符号
  2. const kCustomPromisifiedSymbol = Symbol('util.promisify.custom');
  3. mayJunPromisify.custom = kCustomPromisifiedSymbol;
  4. function mayJunPromisify(original) {
  5. if (typeof original !== 'function') {
  6. throw new Error('The "original" argument must be of type Function. Received type undefined')
  7. }
  8. // 变动之处 -> start
  9. if (original[kCustomPromisifiedSymbol]) { // {1}
  10. const fn = original[kCustomPromisifiedSymbol];
  11. if (typeof fn !== 'function') { // {2}
  12. throw new Error('The "mayJunPromisify.custom" property must be of type Function. Received type number');
  13. }
  14. // {3}
  15. return Object.defineProperty(fn, kCustomPromisifiedSymbol, {
  16. value: fn, enumerable: false, writable: false, configurable: true
  17. });
  18. }
  19. // end <- 变动之处
  20. function fn(...args) {
  21. ...
  22. }
  23. return fn;
  24. }

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

  1. fs.readFile[mayJunPromisify.custom] = () => {
  2. return Promise.reject('该文件暂时禁止读取');
  3. }
  4. const readFilePromisify = mayJunPromisify(fs.readFile);
  5. readFilePromisify('text.txt', 'utf8')
  6. .then(result => console.log(result))
  7. .catch(err => console.log(err)); // 该文件暂时禁止读取

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

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

3.1 util.promisify 中的基本使用

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

  1. const dns = require('dns');
  2. const lookupPromisify = util.promisify(dns.lookup);
  3. lookupPromisify('nodejs.red')
  4. .then(({ address, family }) => {
  5. console.log('地址: %j 地址族: IPv%s', address, family);
  6. })
  7. .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 核心实现

  1. // https://github.com/nodejs/node/blob/v12.x/lib/dns.js#L33
  2. const { customPromisifyArgs } = require('internal/util');
  3. // https://github.com/nodejs/node/blob/v12.x/lib/dns.js#L159
  4. ObjectDefineProperty(lookup, customPromisifyArgs,
  5. { value: ['address', 'family'], enumerable: false });

customPromisifyArgs

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

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

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

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 有且仅有一个元素
  1. // https://github.com/nodejs/node/blob/v12.x/lib/internal/util.js#L277
  2. const kCustomPromisifyArgsSymbol = Symbol('customPromisifyArgs'); // {1}
  3. function promisify(original) {
  4. ...
  5. // 获取多个回调函数的参数名称列表
  6. const argumentNames = original[kCustomPromisifyArgsSymbol]; // {2}
  7. function fn(...args) {
  8. return new Promise((resolve, reject) => {
  9. try {
  10. // (err, result) 改为 (err, ...values) {3}
  11. original.call(this, ...args, (err, ...values) => {
  12. if (err) {
  13. reject(err);
  14. } else {
  15. // 变动之处 -> start
  16. // argumentNames 存在且 values > 1,则回调会存在多个参数名称,进行遍历,返回一个 obj
  17. if (argumentNames !== undefined && values.length > 1) { // {4}
  18. const obj = {};
  19. for (let i = 0; i < argumentNames.length; i++)
  20. obj[argumentNames[i]] = values[i];
  21. resolve(obj);
  22. } else { // {5} 否则 values 最多仅有一个参数名称,即数组 values 有且仅有一个元素
  23. resolve(values[0]);
  24. }
  25. // end <- 变动之处
  26. }
  27. });
  28. } catch(err) {
  29. reject(err);
  30. }
  31. });
  32. }
  33. return fn;
  34. }

4. 实现一个完整的 promisify

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

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

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

  1. function getUserById(id, cb) {
  2. const name = '张三', age = 20;
  3. cb(null, name, age);
  4. }
  5. Object.defineProperty(getUserById, kCustomPromisifyArgsSymbol, {
  6. value: ['name', 'age'], enumerable: false
  7. })
  8. const getUserByIdPromisify = mayJunPromisify(getUserById);
  9. getUserByIdPromisify(1)
  10. .then(({ name, age }) => {
  11. console.log(name, age);
  12. })
  13. .catch(err => console.log(err));

自定义 mayJunPromisify 实现源码

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

总结

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