從地獄到天堂,Node 回撥向 async/await 轉變

邊城發表於2019-03-03

Node7 通過 --harmony_async_await 引數開始支援 async/await,而 async/await 由於其可以以同步形式的程式碼書寫非同步程式,被喻為非同步呼叫的天堂。然而 Node 的回撥模式在已經根深蒂固,這個被喻為“回撥地獄”的結構形式推動了 Promise 和 ES6 的迅速成型。然而,從地獄到天堂,並非一步之遙!

async/await 基於 Promise,而不是基於回撥,所以要想從回撥地獄中解脫出來,首先要把回撥實現修改為 Promise 實現——問題來了,Node 這麼多庫函式,還有更多的第三方庫函式都是使用回撥實現的,要想全部修改為 Promise 實現,談何容易?

使用第三方庫脫離地獄

Async

當然,解決辦法肯定是有的,比如 Async 庫通過 async.waterfall() 實現了對深度回撥的“扁平”化,當然它不是用 Promise 實現的,但是有它的扁平化工作作為基礎,再封裝 Promise 就已經簡潔不少了。

下面是 Async 官方文件給出的一個示例

async.waterfall([
    function(callback) {
        callback(null, `one`, `two`);
    },
    function(arg1, arg2, callback) {
        // arg1 now equals `one` and arg2 now equals `two`
        callback(null, `three`);
    },
    function(arg1, callback) {
        // arg1 now equals `three`
        callback(null, `done`);
    }
], function (err, result) {
    // result now equals `done`
});複製程式碼

如果把它封裝成 Promise 也很容易:

// promiseWaterfall 使用 async.waterfall 處理函式序列
// 並將最終結果封裝成 Promise
function promiseWaterfall(series) {
    return new Promise((resolve, reject) => {
        async.waterfall(series, function(err, result) {
            if (err) {
                reject(err);
            } else {
                resolve(result);
            }
        });
    });
}

// 呼叫示例
promiseWaterfall([
    function(callback) {
        callback(null, "one", "two");
    },
    function(arg1, arg2, callback) {
        // arg1 now equals `one` and arg2 now equals `two`
        callback(null, "three");
    },
    function(arg1, callback) {
        // arg1 now equals `three`
        callback(null, "done");
    }
]).then(result => {
    // result now equals `done`
});複製程式碼

Q

Q 也是一個常用的 Promise 庫,提供了一系列的工具函式來處理 Node 式的回撥,比如 Q.nfcall()Q.nfapply()Q.denodeify() 等。

其中,Q.denodeify(),別名 Q.nfbind(),可以將一個 Node 回撥風格的函式轉換成 Promise 風格的函式。雖然轉換之後的函式返回的不是原生的 Promise 物件,而是 Q 內部實現的一個 Promise 類的物件,我們可以稱之為 Promise alike 物件。

Q.denodeify() 的用法很簡單,直接對 Node 風格的函式進行封裝即可,下面也是官方文件中的例子

var readFile = Q.nfbind(FS.readFile);
readFile("foo.txt", "utf-8").done(function (text) {
    // do something with text
});複製程式碼

這裡需要說明的是,雖然用 Q.denodeify() 封裝的函式返回的是 Promise alike 物件,但是筆者親測它可以用於 await 運算[注1]

[注1]:await 在 MDN 上被描述為 “operator”,即運算子,所以這裡說 “await 運算”,或者可以說 “await 表示式”。

Bluebird

對於 jser 來說,Bluebird 也不陌生。它通過 Promise.promisify()Promise.promisifyAll() 等提供了對 Node 風格函式的轉換,這和上面提到的 Q.denodeify() 類似。注意這裡提到的 Promise 也不是原生的 Promise,而是 bluebird 實現的,通常使用下面的語句引用:

const Promise = require("bluebird").Promise;複製程式碼

為了和原生 Promise 區別開來,也可以改為

const BbPromise = require("bluebird").Promise;複製程式碼

Promise.promisifyAll() 相對特殊一些,它接受一個物件作為引數,將這個物件的所有方法處理成 Promise 風格,當然你也可以指定一個 filter 讓它只處理特定的方法——具體操作這裡就不多說,參考官方文件即可。

Q.denodeify() 類似,通過 bluebird 的 Promise.promisify()Promise.promisifyAll() 處理過後的函式,返回的也是一個 Promise alike 物件,而且,也可以用於 await 運算。

靠自己脫離地獄

ES6 已經提供了原生 Promise 實現,如果只是為了“脫離地獄”而去引用一個第三方庫,似乎有些不值。如果只需要少量程式碼就可以自己把回撥風格封裝成 Promise 風格,幹嘛不自己實現一個?

不妨分析一下,自己寫個 promisify() 需要做些什麼

[1]> 定義 promisify()

promisify() 是一個轉換函式,它的引數是一個回撥風格的函式,它的返回值是一個 Promise 風格的函式,所以不管是引數還是返回值,都是函式

// promisify 的結構
function promisify(func) {
    return function() {
        // ...
    };
}複製程式碼

[2]> 返回的函式需要返回 Promise 物件

既然 promisify() 的返回值是一個 Promise 風格的函式,它的返回值應該是一個 Promise 物件,所以

function promisify(func) {
    return function() {
        return new Promise((resolve, reject) => {
            // TODO
        });
    };
}複製程式碼

[3]> Promise 中呼叫 func

毋庸置疑,上面的 TODO 部分需要實現對 func 的呼叫,並根據結果適當的呼叫 resolve()reject()

function promisify(func) {
    return function() {
        return new Promise((resolve, reject) => {
            func((err, result) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(result);
                }
            });
        });
    };
}複製程式碼

Node 回撥風格的回撥函式第一個引數都是錯誤物件,如果為 null 表示沒有錯誤,所以會有 (err, result) => {} 這樣的回撥定義。

[4]> 加上引數

上面呼叫還沒有加上對引數的處理。對於 Node 回撥風格的函式,通常前面 n 個引數是內部實現需要使用的引數,而最後一個引數是回撥函式。使用 ES6 的可變引數和擴充套件資料語法很容易實現

// 最終實現如下
function promisify(func) {
    return function(...args) {
        return new Promise((resolve, reject) => {
            func(...args, (err, result) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(result);
                }
            });
        });
    };
}複製程式碼

至此,完整的 promisify() 就實現出來了。

[5]> 實現 promisifyArray()

promisifyArray() 用於批量處理一組函式,引數是回撥風格的函式列表,返回對應的 Promise 風格函式列表。在實現了 promisify() 的基礎上實現 promisifyArray() 非常容易。

function promisifyArray(list) {
    return list.map(promisify);
}複製程式碼

[6]> 實現 promisifyObject()

promisifyObject() 的實現需要考慮 this 指標的問題,相對比較複雜,而且也不能直接使用上面的 promisify()。下面是 promisifyObject() 的簡化實現,詳情參考程式碼中的註釋。

function promisifyObject(obj, suffix = "Promisified") {
    // 參照之前的實現,重新實現 promisify。
    // 這個函式沒用到外層的區域性變數,不必實現為局域函式,
    // 這裡實現為區域性函式只是為了組織演示程式碼
    function promisify(func) {
        return function(...args) {
            return new Promise((resolve, reject) => {
                // 注意呼叫方式的變化
                func.call(this, ...args, (err, result) => {
                    if (err) {
                        reject(err);
                    } else {
                        resolve(result);
                    }
                });
            });
        };
    }

    // 先找出所有方法名稱,
    // 如果需要過濾可以考慮自己加 filter 實現
    const keys = [];
    for (const key in obj) {
        if (typeof obj[key] === "function") {
            keys.push(key);
        }
    }

    // 將轉換之後的函式仍然附加到原物件上,
    // 以確保呼叫的時候,this 引用正確。
    // 為了避免覆蓋原函式,加了一個 suffix。
    keys.forEach(key => {
        obj[`${key}${suffix}`] = promisify(obj[key]);
    });

    return obj;
}複製程式碼

天堂就在眼前

脫離了地獄,離天堂就不遠了。我在之前的部落格 理解 JavaScript 的 async/await 已經說明了 async/await 和 Promise 的關係。而上面已經使用了大量的篇幅實現了回撥風格函式向 Promise 風格函式的轉換,所以接下來要做的就是 async/await 實踐。

把 promisify 相關函式封裝成模組

既然是在 Node 中使用,前面自己實現的 promisify()promisifyArray()promisifyObject() 還是封裝在一個 Node 模組中比較好。前面已經定義好了三個函式,只需要匯出就好

module.exports = {
    promisify: promisify,
    promisifyArray: promisifyArray,
    promisifyObject: promisifyObject
};

// 通過解構物件匯入
// const {promisify, promisifyArray, promisifyObject} = require("./promisify");複製程式碼

因為三個函式都是獨立的,也可以匯出成陣列,

module.exports = [promisify, promisifyArray, promisifyObject];

// 通過解構陣列匯入
// const [promisify, promisifyArray, promisifyObject] = require("./promisify");複製程式碼

模擬一個應用場景

這個模擬的應用場景裡需要進行一個操作,包括4個步驟 (均為非同步操作)

  1. first() 獲得一個使用者 ID
  2. second() 根據使用者 ID 獲取使用者的資訊
  3. third() 根據使用者 ID 獲取使用者的分數
  4. last() 輸出使用者資訊和分數

其中第 23 步可以並行。

這個場景用到的資料結構定義如下

class User {
    constructor(id) {
        this._id = id;
        this._name = `User_${id}`;
    }

    get id() {
        return this._id;
    }

    get name() {
        return this._name;
    }

    get score() {
        return this._score || 0;
    }

    set score(score) {
        this._score = parseInt(score) || 0;
    }

    toString() {
        return `[#${this._id}] ${this._name}: ${this._score}`;
    }
}複製程式碼

使用 setTimeout 來模擬非同步

定義一個 toAsync() 來將普通函式模擬成非同步函式。可以少寫幾句 setTimeout()

function toAsync(func, ms = 10) {
    setTimeout(func, ms);
}複製程式碼

以回撥風格模擬4個步驟

function first(callback) {
    toAsync(() => {
        // 產生一個 1000-9999 的隨機數作為 ID
        const id = parseInt(Math.random() * 9000 + 1000);
        callback(null, id);
    });
}

function second(id, callback) {
    toAsync(() => {
        // 根據 id 產生一個 User 物件
        callback(null, new User(id));
    });
}

function third(id, callback) {
    toAsync(() => {
        // 根據 id 計算一個分值
        // 這個分值在 50-100 之間
        callback(null, id % 50 + 50);
    });
}

function last(user, score, callback) {
    toAsync(() => {
        // 將分值填入 user 物件
        // 輸出這個物件的資訊
        user.score = score;
        console.log(user.toString());
        if (callback) {
            callback(null, user);
        }
    });
}複製程式碼

當然,還有匯出

module.exports = [first, second, third, last];複製程式碼

async/await 實踐

const [promisify, promisifyArray, promisifyObject] = require("./promisify");

const [first, second, third, last] = promisifyArray(require("./steps"));

// 使用 async/await 實現
// 用 node 執行的時候需要 --harmoney_async_await 引數
async function main() {
    const userId = await first();

    // 並行呼叫要用 Promise.all 將多個並行處理封裝成一個 Promise
    const [user, score] = await Promise.all([
        second(userId),
        third(userId)
    ]);
    last(user, score);
}

main();複製程式碼

相關文章