端午節後福利:Node.js 8
端午節結束了。雖然接下來的四個月都沒有節假日,但筆者一點都不煩惱。因為 Node.js 8 在端午後第一個工作日就正式發布,這足以讓我與 Node.js 的激情燃燒一個夏天!本文挑選了筆者認為 Node.js 8 最令人興奮的四大新功能,與大家分享。
async/await 與 util.promisify
Node.js 一直以來的關鍵設計就是把用戶關在一個“異步編程的監獄”裏,以換取非阻塞 I/O 的高性能,讓用戶輕易開發出高度可擴展的網絡服務器。這從 Node.js 的 API 設計上就可見一斑,很多API——如 fs.open(path, flags[, mode], callback)——要求用戶必須把該操作執行成功後的邏輯放在最後參數裏,作為函數傳遞進去;而 fs.open 本身是立即返回的,用戶不能把依賴於 fs.open 結果的邏輯與 fs.open 本身線性地串聯起來。
在這座“異步編程的監獄”裏,不掌握異步編程就寸步難行。而我們習慣性地使用線性思維去思考業務問題,卻在實現的時候,被迫把業務邏輯被切成很多小片段去書寫,就是一件很痛苦的事情了。為了減輕異步編程的痛苦,幾年間我們見證了數個解決方案的出現:從深度嵌套的回調金字塔,到帶有長長的 then() 鏈條的 Promise 設計模式,再到 Generator 函數,到如今 Node.js 8 的 async/await 操作符。筆者認為,所有這些解決方案中,async/await 操作符是最接近命令式編程風格的,使用起來最為自然的。
例如我們想先創建一個文件,再讀取、輸出它的大小,隻需三行代碼:
await writeFile('a_new_file.txt', 'hello');
let result = await stat('a_new_file.txt');
console.log(result.size);
這簡直是最簡單的異步編程了!我們用自然、流暢的代碼表達了線性業務邏輯,同時還得到了 Node.js 非阻塞 I/O 帶來的高性能,簡直是兼得了魚和熊掌。
但別著急,這段代碼不是立即就可以執行的,細心的讀者肯定會問:例子中的 writeFile 和 stat 分別是什麼?其實它們就是標準庫的 fs.writeFile 和 fs.stat,但又不完全是。這是因為 async 和 await 本質上是對 Promise 設計模式的封裝,一般情況下 await 的參數應是一個返回 Promise 對象的函數。而 fs.writeFile 和 fs.stat 這些標準庫 API 沒有返回值(返回 undefined),需要一個方法把他們包裝成返回 Promise 對象的函數。
但總不能一個一個包裝去吧,這樣工作量堪比重寫標準庫了。幸好,我們觀察到所有這些標準庫 API 基本都滿足一個共同特征:它們都是用最後一個參數來接受一個類似“ (err, value) => ... ”的回調函數。於是我們就可以用一個 API 把幾乎所有標準庫 API 都轉換為返回 Promise 對象的函數。這就是 util.promisify。利用 util.promisify,我們可以添加以下代碼:
const util = require('util');
const fs = require('fs');
const writeFile = util.promisify(fs.writeFile);
const stat = util.promisify(fs.stat);
若沒有 util.promisify,async/await 是很難用的,因為它們需要配合 Promise 一起使用,而之前很多庫函數又不返回 Promise。筆者認為 async/await 運算符和 util.promisify 的絕配,是 Node.js 8 最大的亮點。
以上示例的完整代碼如下:
const util = require('util');
const fs = require('fs');
const writeFile = util.promisify(fs.writeFile);
const stat = util.promisify(fs.stat);
(async function () {
await writeFile('a_new_file.txt', 'hello');
let result = await stat('a_new_file.txt');
console.log(result.size);
})();
Async Hooks
調試過 Node.js 的小夥伴都知道,Node.js 一個很大的弱點就是——出錯時調用棧不完整。這也是“異步編程的監獄”的設計帶來的另一個缺點,因為在異步編程下,我們的代碼被切成了無數個小片段,報錯時隻能得到一個小片段的調用棧,而全局的來龍去脈卻看不到,用戶隻能推測是何處代碼觸發了何種事件導致執行了小片段,再不斷往前推演。
舉一個簡單的例子:
function IWantFullCallbacks() {
setTimeout(function() {
const localStack = new Error();
console.log(localStack.stack);
}, 1000);
}
IWantFullCallbacks();
在這個例子中,我們模擬了 setTimeout 內出錯時打印調用棧的情景。將它存為 1.js 並執行,我們期望,如果調用棧能包含外層的 IWantFullCallbacks(),並打印其行號 8,定是極好的,因為那樣對我們排查錯誤很有幫助。但現實中卻並非如此,調用棧隻有四行,行號頂多打印到了第 3 行的報錯本身,我們根本看不出來是第 8 行觸發了這個錯誤。因為第 8 行作為異步調用成功地結束了,它才不關心“後事如何”。
Error
at Timeout._onTimeout (/Users/pmq20/1.js:3:24)
at ontimeout (timers.js:488:11)
at tryOnTimeout (timers.js:323:5)
at Timer.listOnTimeout (timers.js:283:5)
而 Node.js 8 中新增的 Async Hooks 功能就可以解決這個問題。Node.js 8 中添加了四種 Async Hooks 回調,它們可以跟蹤 Node.js 的所有異步資源的生命周期。這裏所謂的資源是指 Node.js 底層 libuv 中的各類短期請求和長期句柄,如本例中的定時器,就是這樣一個異步資源。這四種回調分別涵蓋了這些異步資源的創建、回調前、回調後、銷毀這四個生命階段。
通過自定義這四種回調函數,我們就可以跨調用棧來做事件追蹤,我們可以先做一個 Map 容器放在回調函數的閉包裏,用來作為異步資源 ID 到調試信息的映射,並在異步資源的創建時進行調試信息的累積。閉包裏再聲明一個 currentUid 表示目前正在執行的異步資源 ID,於回調前、回調後兩個生命階段的時機進行記錄。這樣下來,第 8 行執行 IWantFullCallbacks() 的時候創建的異步資源的 ID,與後期定時器到期自行回調的異步資源的 ID,是同一個 ID,因而可以起到跨調用棧累積調試信息的作用。我們通過 Node.js 8 的 async_hooks 的 createHook API 來創建回調,並通過 enable() 方法注冊並執行,代碼如下:
const stack = new Map();
stack.set(-1, '');
let currentUid = -1;
function init(id, type, triggerId, resource) {
const localStack = (new Error()).stack.split('\n').slice(1).join('\n');
const extraStack = stack.get(triggerId || currentUid);
stack.set(id, localStack + '\n' + extraStack);
}
function before(uid) {
currentUid = uid;
}
function after(uid) {
currentUid = -1;
}
function destroy(uid) {
stack.delete(uid);
}
const async_hooks = require('async_hooks');
const hook = async_hooks.createHook({init, before, after, destroy});
hook.enable();
最後我們修改定時器的回調內容,讓它輸出 Map 中累積的調試信息:
function IWantFullCallbacks() {
setTimeout(function() {
const localStack = new Error();
console.log(localStack.stack);
console.log('---');
console.log(stack.get(currentUid));
}, 1000);
}
這次的效果如下:
Error
at Timeout._onTimeout (/Users/pmq20/2.js:26:24)
at ontimeout (timers.js:488:11)
at tryOnTimeout (timers.js:323:5)
at Timer.listOnTimeout (timers.js:283:5)
---
at init (/Users/pmq20/2.js:6:23)
at runInitCallback (async_hooks.js:459:5)
at emitInitS (async_hooks.js:327:7)
at new Timeout (timers.js:592:5)
at createSingleTimeout (timers.js:472:15)
at setTimeout (timers.js:456:10)
at IWantFullCallbacks (/Users/pmq20/2.js:25:3)
at Object.<anonymous> (/Users/pmq20/2.js:33:1)
at Module._compile (module.js:569:30)
at Object.Module._extensions..js (module.js:580:10)
可見,我們以同一個異步資源的 ID 為線索,把兩次的調用棧都完整保留了。
但這隻是 Node.js 8 的 Async Hooks 的用途之一,有了這個功能,我們甚至可以來測量一些事件各個階段所花費的時間。隻要我們有異步資源 ID 這枚鑰匙,配合回調函數,就可以在事件循環的多個周期那看似毫無頭緒的執行過程中,篩選出有用的信息。
Node.js API (N-API)
經曆過 Node.js 大版本升級的同學肯定會發現,每次升級後我們都得重新編譯像 node-sass 這種用 C++ 寫的擴展模塊,否則會遇到下麵這樣的報錯,
Error: The module 'node-sass'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 51. This version of Node.js requires
NODE_MODULE_VERSION 55. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).
NODE_MODULE_VERSION 是每一個 Node.js 版本內人為設定的數值,意思為 ABI 的版本號。一旦這個號碼與已經編譯好的二進製模塊的號碼不符,便判斷為 ABI 不兼容,需要用戶重新編譯。
這其實是一個工程難題,亦即 Node.js 上遊的代碼變化如何最小地降低對 C++ 模塊的影響,從而維持一個良好的向下兼容的模塊生態係統。最壞的情況下,每次發布 Node.js 新版本,因為 API 的變化,C++ 模塊的作者都要修改它們的源代碼,而那些不再有人維護或作者失聯的老模塊就會無法繼續使用,在作者修改代碼之前社區就失去了這些模塊的可用性。其次壞的情況是,每次發布 Node.js 新版本,雖然 API 保持兼容使得 C++ 模塊的作者不需要修改他們的代碼,但 ABI 的變化導致必須這些模塊必須重新編譯。而最好的情況就是,Node.js 新版本發布後,所有已編譯的 C++ 模塊可以繼續正常工作,完全不需要任何人工幹預。
Node.js Compiler 也麵臨同樣的問題,之前 nodec 強製用戶編譯環境中的 Node.js 版本與編譯器的內置 Node.js 版本一致,就是為了消除編譯時與運行時 C++ 模塊的版本不兼容問題,但這也給用戶帶來了使用的不便。見: https://github.com/pmq20/node-compiler/issues/27 如果能做到上述最好的情況,那麼這個問題也就完美解決了。
Node.js 8 的 Node.js API (N-API) 就是為了解決這個問題,做到上述最好的情況,為 Node.js 模塊生態係統的長期發展鋪平道路。N-API 追求以下目標:
- 有穩定的 ABI
- 抽象消除 Node.js 版本之間的接口差異
- 抽象消除 V8 版本之間的接口差異
- 抽象消除 V8 與其他 JS 引擎(如 ChakraCore)之間的接口差異
筆者觀察到,N-API 采取以下手段達到上述目標:
- 采用 C 語言頭文件而不是 C++,消除 Name Mangling 以便最小化一個穩定的 ABI 接口
- 不使用 V8 的任何數據類型,所有 JavaScript 數據類型變成了不透明的 napi_value
- 重新設計了異常管理 API,所有 N-API 都返回 napi_status,通過統一的手段處理異常
- 重新了設計對象的生命周期 API,通過 napi_open_handle_scope 等 API 替代了 v8 的 Scope 設計
N-API 目前在 Node.js 8 仍是實驗階段的功能,需要配合命令行參數 --napi-modules 使用。
TurboFan 與 Ignition (TF+I)
Node.js 8 得益於將 v8 升級到了 5.8,引入了 TurboFan 與 Ignition 的支持。關於 Ignition 的詳細介紹,請見 https://www.atatech.org/articles/78497
前麵已經提到,如今借助 Node.js 8 我們可以用 await/async 書寫程序,但並未提到異常處理,其實 await/async 的異常處理多借助 try/catch 配合使用。而在以前的 Node.js 版本中,try/catch 是個昂貴的操作,性能並不高。這主要是由於 v8 內老的 Crankshaft 不易優化這些 ES5 的新語法。但隨著 TF+I 新架構的引入,try/catch 的寫法也可以得到優化,作為用戶就可以高枕無憂的使用 await/async + try/catch 了。
目前 Node.js 8 內的 v8 版本僅更新到了 5.8,TF+I 需要配合命令行參數 --turbo --ignition 使用。一旦升級到 v8 5.9,TF+I 將會默認開啟。
最後更新:2017-06-01 17:01:31