端午節後福利:Node.js 8

weixin_34295316發表於2017-06-01

端午節結束了。雖然接下來的四個月都沒有節假日,但筆者一點都不煩惱。因為 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 操作符是最接近指令式程式設計風格的,使用起來最為自然的。

brains.jpg

例如我們想先建立一個檔案,再讀取、輸出它的大小,只需三行程式碼:

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 追求以下目標:

  1. 有穩定的 ABI
  2. 抽象消除 Node.js 版本之間的介面差異
  3. 抽象消除 V8 版本之間的介面差異
  4. 抽象消除 V8 與其他 JS 引擎(如 ChakraCore)之間的介面差異

筆者觀察到,N-API 採取以下手段達到上述目標:

  1. 採用 C 語言標頭檔案而不是 C++,消除 Name Mangling 以便最小化一個穩定的 ABI 介面
  2. 不使用 V8 的任何資料型別,所有 JavaScript 資料型別變成了不透明的 napi_value
  3. 重新設計了異常管理 API,所有 N-API 都返回 napi_status,通過統一的手段處理異常
  4. 重新了設計物件的生命週期 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 將會預設開啟。

相關文章