探索Node.js異步Hooks
你聽說過 Node.js 的`async hooks` [1]模塊嗎?如果沒有,那你應(yīng)該了解一下。
盡管它是與 Node.js 9 一起發(fā)布的新特性,但是因為該模塊仍處于測試階段,我并不建議將其用于生產(chǎn)環(huán)境,不過你仍然應(yīng)該對它有所了解。
簡而言之,Node.js 中的異步掛鉤,具體來說是 async_hooks 模塊,提供了一個清晰易用的 API 去追蹤 Node.js 中的異步資源。
該 API 最簡單的使用方式就是用 JS 中的 require import:
- const async_hooks = require('async_hooks');
我們在這里討論的異步特性指的是Node.js創(chuàng)建的具有關(guān)聯(lián)回調(diào)的對象,與回調(diào)可能被調(diào)用多少次沒有關(guān)系。這就有很多種類了例如:Promises、創(chuàng)建服務(wù)的操作、超時等。
請記住,大多數(shù)語言都可以關(guān)閉資源。其中一些通過容器關(guān)閉,其他的則是通過語言本身關(guān)閉。所以你的回調(diào)函數(shù)可能自始至終都沒有被調(diào)用過。但是沒有關(guān)系,AsyncHook 不會區(qū)分這些不同的情況。
這篇文章的目的是為了更深入的探討hooks,并且嘗試通過一些示例幫助你更深入的理解。準備好了嗎?
👋 在探索異步掛鉤時,你可能還希望了解 AppSignal forNode.js[2]。我們?yōu)槟闾峁ode.js Core,Express,Next.js,Apollo Server,node-postgres和node-redis的現(xiàn)成支持[3]。
API使用
我總是覺得官方文檔過于復(fù)雜以及苛刻。這就是為什么我通常會選擇傳統(tǒng)、友好的博客文章。
讓我們首先了解一下Async Hooks API提供的 5 個可用事件函數(shù):
- init: 顧名思義,當特定的異步資源初始化時會調(diào)用它。僅作記錄,此時,我們已經(jīng)將鉤子與異步資源相關(guān)聯(lián)。
- before 和 after: 這與普通語言中的函數(shù)的執(zhí)行前和執(zhí)行后非常相似。在資源執(zhí)行之前和之后分別調(diào)用它們。
- destroy: 很明顯,無論資源的回調(diào)函數(shù)發(fā)生了什么,只要資源被銷毀就會調(diào)用它。
- promiseResolve: promiseResolve與Promise有關(guān),當你的Promise調(diào)用它的 resolve 函數(shù)時,掛鉤就會觸發(fā)此函數(shù)。
非常的簡單直接,接下來讓我們看一個基本的例子:
- const myFirstAsyncHook = async_hooks.createHook({ init, before, after, destroy, promiseResolve });
是的,你必須先創(chuàng)建每個事件函數(shù),然后再將其分配給createHook函數(shù)。另外,必須顯式啟用該掛鉤:
- myFirstAsyncHook.enable();
讓我們繼續(xù)看一個更加完整的例子:
- const fs = require("fs");
- const async_hooks = require("async_hooks");
- // Sync write to the console
- const writeSomething = (phase, more) => {
- fs.writeSync(
- 1,
- `Phase: "${phase}", Exec. Id: ${async_hooks.executionAsyncId()} ${
- more ? ", " + more : ""
- }\n`
- );
- };
- // Create and enable the hook
- const timeoutHook = async_hooks.createHook({
- init(asyncId, type, triggerAsyncId) {
- writeSomething(
- "Init",
- `asyncId: ${asyncId}, type: "${type}", triggerAsyncId: ${triggerAsyncId}`
- );
- },
- before(asyncId) {
- writeSomething("Before", `asyncId: ${asyncId}`);
- },
- destroy(asyncId) {
- writeSomething("Destroy", `asyncId: ${asyncId}`);
- },
- after(asyncId) {
- writeSomething("After", `asyncId: ${asyncId}`);
- },
- });
- timeoutHook.enable();
- writeSomething("Before call");
- // Set the timeout
- setTimeout(() => {
- writeSomething("Exec. Timeout");
- }, 1000);
這個例子通過眾所周知的原生函數(shù) setTimeout 去追蹤超時的異步執(zhí)行過程。
在我們深入研究之前,先快速瀏覽一下第一個函數(shù) writeSomething 。你也許很好奇為什么在我們已經(jīng)有函數(shù)可以在控制臺輸出的情況下仍然創(chuàng)建了一個新的函數(shù)去完成相同的功能。
原因是你不能使用任何 console 函數(shù)去測試異步鉤子,因為它們本身就是異步的。因此當我們在下面提供了一個 init 函數(shù)時,它會產(chǎn)生一個無限循環(huán)。該函數(shù)會調(diào)用 console 的 log ,此日志又會再次觸發(fā)初始化,以此類推,陷入死循環(huán)。
這就是為什么我們需要重新寫一個“同步”日志功能。
好了,現(xiàn)在我們回過頭去看代碼。我們的異步鉤子提供了四個功能:init、 before、 after 以及 destory。而且,我們還在超時之前和執(zhí)行期間打印一條消息,所以你可以看到整個過程是如何線性進行的。
在你的命令行執(zhí)行 node index.js,你會得到如下圖所示的結(jié)果:
觀察下鉤子是如何一步一步執(zhí)行追蹤的??雌饋硎且环N很有趣的跟蹤方式,尤其是當你考慮將數(shù)據(jù)輸入到監(jiān)視工具中或者是你已經(jīng)使用的日志追蹤工具。
一個Promise例子
讓我們看看我們的示例在Promise下的執(zhí)行效果。思考下面這些代碼片段:
- const calcPow = async(n, exp) => {
- writeSomething("Exec. Promise");
- return Math.pow(n, exp);
- };
- (async() => {
- await calcPow(3, 4);
- })();
你也可以用之前的 setTimeout 示例來替代這個例子。在這段代碼中,我們有一個異步函數(shù)用來進行冪運算。同時也有一個相同的函數(shù)在異步塊中被調(diào)用。到目前為止,Node.js創(chuàng)建了兩個Promise。
下圖是日志記錄的結(jié)果:
奇怪的是,我們有兩個Promise,卻調(diào)用了三次 init 函數(shù)。不用擔心,這是因為Node.js團隊在版本12中引入了異步執(zhí)行性能方面的一些最新改進。你可以點擊此處[4]了解更多信息。
盡管如此,執(zhí)行過程依然符合我們的預(yù)期。
解析:鉤子函數(shù)的性能與度量
Node.js提供的另一個非常有趣的API是性能評估API[5],既然我們在這里討論度量,為什么不結(jié)合兩者的功能來了解我們可以收獲什么呢?
可以通過 perf_hooks 獲得該API,該API讓我們能夠用與W3C Web Performance API[6]相似的方式來獲得性能/用戶時間軸指標。
將它與異步鉤子相結(jié)合我們可以做一些事情,比如追蹤異步函數(shù)執(zhí)行完畢需要的時間。讓我們看另外一個例子:
- const async_hooks = require("async_hooks");
- const {
- performance,
- PerformanceObserver
- } = require("perf_hooks");
- const hook = async_hooks.createHook({
- init(asyncId) {
- performance.mark(`init-${asyncId}`);
- },
- destroy(asyncId) {
- performance.mark(`destroy-${asyncId}`);
- performance.measure(
- `entry-${asyncId}`,
- `init-${asyncId}`,
- `destroy-${asyncId}`
- );
- },
- });
- hook.enable();
- const observer = new PerformanceObserver((data) =>
- console.log(data.getEntries())
- );
- observer.observe({
- entryTypes: ["measure"],
- buffered: true
- });
- setTimeout(() => {
- console.log("I'm a timeout");
- }, 1200);
既然我們只是追蹤記錄執(zhí)行時間,就沒有必要用之前用的中間事件函數(shù)。用 init 和 destroy 就足夠了。
就像異步鉤子那樣,性能API通過創(chuàng)建觀察者來工作。不過,無論什么時候開始或者結(jié)束,你都必須明確標記每個事件的id。這樣,當我們調(diào)用API的 measure 函數(shù)時,它將匯總收集到的數(shù)據(jù)并將其立即發(fā)送給觀察者,觀察者將為我們記錄全部的日志。
注意了,這里我們使用了兩次 console.log 函數(shù)。第一次是無影響的因為它包含在觀察者中執(zhí)行。但是第二次它在 setTimeout 函數(shù)中執(zhí)行,另一個異步中的異步,這意味著在最后它會產(chǎn)生不同的輸出。
下圖是日志記錄:
本示例本并沒有考慮事件類型之間的差異。在這里,我們在同一測量場景中發(fā)生了超時和異步日志操作。
但是,考慮到生產(chǎn)環(huán)境,建議你創(chuàng)建一個更強大的機制在每次調(diào)用 init 時存儲事件類型,并在稍后調(diào)用 destroy 函數(shù),倒霉的沒有接收到參數(shù)類型時檢查存儲是否依然存在。
異步資源
Async Hooks中的另一個有用功能是 `AsyncResource`[7] 類。每當你為框架或庫創(chuàng)建自己的資源時,它都會為你提供幫助。
只需輸入以下代碼即可使用:
- const AsyncResource = require('async_hooks').AsyncResource;
用這種方式,你可以使用它實例化一個新對象,并手動定義其每個階段在整個代碼中何時開始。舉個例子:
- const resource = new AsyncResource('MyOwnResource');
- someFunction(function someCallback() {
- resource.emitBefore();
- // do your stuff...
- resource.emitAfter();
- });
- someOnClose() {
- resource.emitDestroy();
- }
這仍是資源生命周期的一個示例,如果要綁定本地的C++代碼,我們更建議使用它。我將為你提供官方文檔中的一個很好的例子[8]來簡化它。
結(jié)論
就像我們討論的那樣,異步鉤子仍處于實驗階段。因此,要謹慎使用它。
由于 hooks 僅在 Node.js 8 及更高版本中可用,因此你可以考慮遷移 Node.js 版本(很多時候這是不太合適的方法)或使用社區(qū)中的替代工具,例如 async-tracer[9]。