在 Node.js 中使用 Async Hooks 處理 HTTP 請求上下文實現鏈路追蹤

2021-12-29 Nodejs技術棧

作者簡介:五月君,Software Designer,公眾號「Nodejs技術棧」作者。

Async Hooks 一個實際的使用場景是存儲請求上下文,在異步調用之間共享數據。上節對基礎使用做了介紹,還沒看的參見之前的分享 使用 Node.js 的 Async Hooks 模塊追蹤異步資源

本節將會介紹如何基於 Async hooks 提供的 API 從零開始實現一個 AsyncLocalStorage 類(異步本地存儲)及在 HTTP 請求中關聯日誌的 traceId 實現鏈路追蹤,這也是 Async Hooks 的一個實際應用場景了。

何為異步本地存儲?

我們所說的異步本地存儲類似於多線程程式語言中的線程本地存儲。拿之前筆者寫過的 Java 做個舉例,例如 Java 中的 ThreadLocal 類,可以為使用相同變量的不同線程創建一個各自的副本,避免共享資源產生的衝突,在一個線程請求之內通過 get()/set() 方法獲取或設置這個變量在當前線程中對應的副本值,在多線程並發訪問時線程之間各自創建的副本互不影響。

在 Node.js 中我們的業務通常都工作在主線程(使用 work_threads 除外),是沒有 ThreadLocal 類的。並且以事件驅動的方式來處理所有的 HTTP 請求,每個請求過來之後又都是異步的,異步之間還很難去追蹤上下文信息,我們想做的是在這個異步事件開始,例如從接收 HTTP 請求到響應,能夠有一種機可以讓我們隨時隨地去獲取在這期間的一些共享數據,也就是我們本節所提的異步本地存儲技術。

在接下來我會講解實現 AsyncLocalStorage 的四種方式,從最開始的手動實現到官方 v14.x 支持的 AsyncLocalStorage 類,你也可以從中學習到其實現原理。

現有業務問題

假設,現在有一個需求對現有日誌系統做改造,所有記錄日誌的地方增加 traceId 實現全鏈路日誌追蹤。

一種情況是假設你使用一些類似 Egg.js 這樣的企業級框架,可以依賴於框架提供的中間件能力在請求上掛載 traceId,可以看看之前的一篇文章 基於 Egg.js 框架的日誌鏈路追蹤實踐 也是可以實現的,不過當時是基於 egg 的一個插件自己做了繼承實現,現在已經不需要這麼麻煩,可以通過配置自定義日誌格式來實現 https://eggjs.org/zh-cn/core/logger.html#自定義日誌格式。

另一種情況假設你是用的 Express、Koa 這些基礎框架,所有業務都是模塊加載函數式調用,如果每次把請求的 traceId 手動在 Controller -> Service -> Model 之間傳遞,這樣對業務代碼的侵入太大了,日誌與業務的耦合度就太高了。

如下代碼,是我精簡後的一個例子,現在有一個需求,在不更改業務代碼的情況下每次日誌列印都輸出當前 HTTP 請求處理 Headers 中攜帶的 traceId 欄位,如果是你會怎麼做呢?

// logger.js
const logger = {
  info: (...args) => {
    console.log(...args);
  }
}
module.exports = { logger }

// app.js
const express = require('express');
const app = express();
const PORT = 3000;
const { logger } = require('./logger');
global.logger = contextLogger;

app.use((req, res, next) => contextLogger.run(req, next));

app.get('/logger', async (req, res, next) => {
  try {
   const users = await getUsersController();
   res.json({ code: 'SUCCESS', message: '', data: users });
  } catch (error) {
    res.json({ code: 'ERROR', message: error.message })
  }
});

app.listen(PORT, () => console.log(`server is listening on ${PORT}`));

async function getUsersController() {
  logger.info('Get user list at controller layer.');
  return getUsersService();
}

async function getUsersService() {
  logger.info('Get user list at service layer.');
  setTimeout(function() { logger.info('setTimeout 2s at service layer.') }, 3000);
  return getUsersModel();
}

async function getUsersModel() {
  logger.info('Get user list at model layer.');
  return [];
}

方式一:動手實現異步本地存儲

解決方案是實現請求上下文本地存儲,在當前作用域代碼中能夠獲取上下文信息,待處理完畢清除保存的上下文信息,這些需求可以通過 Async Hooks 提供的 API 實現。

創建 AsyncLocalStorage 類行 {1} 創建一個 Map 集合存儲上下文信息。行 {2} 裡面的 init 回調是重點,當一個異步事件被觸發前會先收到 init 回調,其中 triggerAsyncId 是當前異步資源的觸發者,我們則可以在這裡獲取上個異步資源的信息存儲至當前異步資源中。當 asyncId 對應的異步資源被銷毀時會收到 destroy 回調,所以最後要記得在 destroy 回調裡清除當前 asyncId 裡存儲的信息。行 {3} 拿到當前請求上下文的 asyncId 做為 Map 集合的 Key 存入傳入的上下文信息。行 {4} 拿到 asyncId 獲取當前代碼的上下文信息。
// AsyncLocalStorage.js
const asyncHooks = require('async_hooks');
const { executionAsyncId } = asyncHooks;
class AsyncLocalStorage {
  constructor() {
    this.storeMap = new Map(); // {1}
    this.createHook(); // {2}
  }
  createHook() {
    const ctx = this;
    const hooks = asyncHooks.createHook({
      init(asyncId, type, triggerAsyncId) {
        if (ctx.storeMap.has(triggerAsyncId)) {
          ctx.storeMap.set(asyncId, ctx.storeMap.get(triggerAsyncId));
        }
      },
      destroy(asyncId) {
        ctx.storeMap.delete(asyncId);
      }
    });
    hooks.enable();
  }
  run(store, callback) { // {3}
    this.storeMap.set(executionAsyncId(), store);
    callback();
  }
  getStore() { // {4}
    return this.storeMap.get(executionAsyncId());
  }
}
module.exports = AsyncLocalStorage;

注意,在我們定義的 createHook() 方法裡有 hooks.enable(); 這樣一段代碼,這是因為 Promise 默認是沒有開啟的,通過顯示的調用可以開啟 Promise 的異步追蹤。

改造 logger.js 文件

在我們需要列印日誌的地方拿到當前代碼所對應的上下文信息,取出我們存儲的 traceId, 這種方式只需要改造我們日誌中間即可,不需要去更改我們的業務代碼。

const { v4: uuidV4 } = require('uuid');
const AsyncLocalStorage = require('./AsyncLocalStorage');
const asyncLocalStorage = new AsyncLocalStorage();

const logger = {
  info: (...args) => {
    const traceId = asyncLocalStorage.getStore();
    console.log(traceId, ...args);
  },
  run: (req, callback) => {
    asyncLocalStorage.run(req.headers.requestId || uuidV4(), callback);
  }
}

module.exports = {
  logger,
}

改造 app.js 文件

註冊一個中間件,傳遞請求信息。

app.use((req, res, next) => logger.run(req, next));

運行後輸出結果
e82d1a1f-5038-4ac9-a9c8-2aa5abb0f96a Get user list at router layer.
e82d1a1f-5038-4ac9-a9c8-2aa5abb0f96a Get user list at controller layer.
e82d1a1f-5038-4ac9-a9c8-2aa5abb0f96a Get user list at service layer.
e82d1a1f-5038-4ac9-a9c8-2aa5abb0f96a Get user list at model layer.
e82d1a1f-5038-4ac9-a9c8-2aa5abb0f96a setTimeout 2s at service layer.

這種方式就是完全基於 Async Hooks 提供的 API 來實現,不理解其實現原理的可以在動手實踐下,這種方式需要我們額外維護維護一個 Map 對象,還要處理銷毀操作。

方式二:executionAsyncResource() 返回當前執行的異步資源

executionAsyncResource() 返回當前執行的異步資源,這對於實現連續的本地存儲很有幫助,無需像 「方式一」 再創建一個 Map 對象來存儲元數據。

const asyncHooks = require('async_hooks');
const { executionAsyncId, executionAsyncResource } = asyncHooks;

class AsyncLocalStorage {
  constructor() {
    this.createHook();
  }
  createHook() {
    const hooks = asyncHooks.createHook({
      init(asyncId, type, triggerAsyncId, resource) {
        const cr = executionAsyncResource();
        if (cr) {
          resource[asyncId] = cr[triggerAsyncId];
        }
      }
    });
    hooks.enable();
  }
  run(store, callback) {
    executionAsyncResource()[executionAsyncId()] = store;
    callback();
  }
  getStore() {
    return executionAsyncResource()[executionAsyncId()];
  }
}

module.exports = AsyncLocalStorage;

方式三:基於 ResourceAsync 創建 AsyncLocalStorage 類

ResourceAysnc 可以用來自定義異步資源,此處的介紹也是參考 Node.js 源碼對 AsyncLocalStorage 的實現。

一個顯著的改變是 run() 方法,每一次的調用都會創建一個資源,調用其 runInAsyncScope() 方法,這樣在這個資源的異步作用域下,所執行的代碼(傳入的 callback)都是可追蹤我們設置的 store。

const asyncHooks = require('async_hooks');
const { executionAsyncResource, AsyncResource } = asyncHooks;

class AsyncLocalStorage {
  constructor() {
    this.kResourceStore = Symbol('kResourceStore');
    this.enabled = false;
    const ctx = this;
    this.hooks = asyncHooks.createHook({
      init(asyncId, type, triggerAsyncId, resource) {
        const currentResource = executionAsyncResource();
        ctx._propagate(resource, currentResource)
      }
    });
  }

  // Propagate the context from a parent resource to a child one
  _propagate(resource, triggerResource) {
    const store = triggerResource[this.kResourceStore];
    if (store) {
      resource[this.kResourceStore] = store;
    }
  }

  _enable() {
    if (!this.enabled) {
      this.enabled = true;
      this.hooks.enable();
    }
  }

  enterWith(store) {
    this._enable();
    const resource = executionAsyncResource();
    resource[this.kResourceStore] = store;
  }

  run(store, callback) {
    const resource = new AsyncResource('AsyncLocalStorage', {
      requireManualDestroy: true,
    });
    return resource.emitDestroy().runInAsyncScope(() => {
      this.enterWith(store);
      return callback();
    });
  }

  getStore() {
    return executionAsyncResource()[this.kResourceStore];
  }
}

module.exports = AsyncLocalStorage;

方式四:AsyncLocalStorage 類

Node.js v13.10.0 async_hooks 模塊新加入了 AsyncLocalStorage 類,實例化一個對象調用 run() 方法實現本地存儲,也是推薦的方式,不需要自己去再額外維護一個 AsyncLocalStorage 類。

AsyncLocalStorage 類的實現也就是上面講解的方式三,所以也不需要我們在外部顯示的調用 hooks.enable() 來啟用 Promise 異步追蹤,因為其內部已經實現了。

const { AsyncLocalStorage } = require('async_hooks');

Async Hooks 的性能開銷

這一點是大家最關心的問題,如果開啟了 Async Hooks(Promise 需要調用 Async Hooks 實例的 enable() 方法開啟)每一次異步操作或 Promise 類型的操作,包括 console 只要是異步的都會觸發 hooks,也必然是有性能開銷的。

參考 Kuzzle 的性能基準測試,使用了 AsyncLocalStorage 與未使用之間相差 ~8%。

----Log with AsyncLocalStorageLog classicdifferencereq/s26132842〜8%

當然不同的業務也有著不同的差異,如果你擔心會有很大的性能開銷,可以基於自己的業務做一些基準測試。

Reference[1]

nodejs.org/api/async_hooks.html: https://nodejs.org/api/async_hooks.html

[2]

Node.js 14 & AsyncLocalStorage: Share a context between asynchronous calls: https://blog.kuzzle.io/nodejs-14-asynclocalstorage-asynchronous-calls

[3]

在 Node 中通過 Async Hooks 實現請求作用域: https://mp.weixin.qq.com/s/I22TvmTqCKFClsp0YLDoZw

[4]

Async Hooks 性能影響: https://github.com/nodejs/benchmarking/issues/181

[5]

kuzzle 基準測試: https://github.com/kuzzleio/kuzzle/pull/1604

相關焦點

  • Node.js 異步延續模型
    async_hooks 即是 Node.js 對上述模型的實現。而 domain 的主要用途是異步錯誤的處理,但是因為在 domain 提出的時候還不存在 async_hooks,並且對於異步資源、異步執行的語義定義並不清晰,從而導致實際生產中 domain 的使用非常容易導致錯誤並且難以排查(多個 domain 的使用方其中如果使用了不是那么正確的方法,會將 domain 的狀態攪得一團糟)。
  • 一起來看看 Node.js v14.x LTS 中的這些新功能
    Async Hooks 模塊提供了 API 用來追蹤 Node.js 程序中異步資源的聲明周期,在最新的 v14.x LTS 版本中新增加了一個 AsyncLocalStorage 類可以方便實現上下文本地存儲,在異步調用之間共享數據,對於實現日誌鏈路追蹤場景很有用。
  • Nodejs 14 大版本中新增特性總結
    AsyncLocalStorage 類可以方便實現上下文本地存儲,在異步調用之間共享數據,對於實現日誌鏈路追蹤場景很有用。下面是一個 HTTP 請求的簡單示例,模擬了異步處理,並且在日誌輸出時去追蹤存儲的 id。
  • Node.js 並發能力總結
    以 I/O 操作為主的應用,更適合用 Node.js 來做,比如 Web 服務中同時執行 M 個 SQL,亦或是離線腳本中同時訪問發起 N 個 RPC 服務。所以在代碼中使用 async/await 的確很舒服,但是適當的合併請求,使用 Promise.all 才能提高性能。
  • 如何在 Node.js 中正確的使用日誌對象
    console.log('hello world');這就是最簡單的主動列印的例子。但是大多數場景下,我們都不會使用 console 來進行列印,畢竟除了內置之外,在性能和功能方面沒有特別的優勢。由於 debug 模塊由 TJ 出品,並且在非常早的時候就投入,使用過於廣泛,至今仍有非常多的模塊使用了它。Node.js 官方一直希望能夠內置一個 debug 模塊。從 v0.11.3 開始,終於加上了一個 util.debuglog 方法。它的功能和 debug 模塊類似,同時是內置的模塊,所以逐步也有一些模塊開始過渡到它。
  • Node.js 診斷指南 第二彈
    我們可以在 Node.js 中通過 --prof 來開啟。NODE_ENV=production node --prof app.js在通過 ab 之類的測試工具,多次調用我們的 http 服務:ab -k -c 20 -n 250 "http://localhost:8080/auth"之後,我們可以查找一下工作目錄下面一個類似 isolate-0xnnnnnnnnnnnn-v8
  • 滲透測試中的Node.js——Downloader的實現
    我最近在一篇文章中學到了利用Node.js繞過主動防禦的技巧,於是對Node.js的語法進行了學習,開源一個Downloader的實現代碼,分享腳本開發中需要注意的細節。Node.js繞過主動防禦的學習地址:https://bbs.pediy.com/thread-249573.htm
  • 走進Node.js 之 HTTP實現分析
    既然 Node.js 的強項是處理網絡請求,那我們就來分析一個 HTTP 請求在 Node.js 中是怎麼被處理的,以及 JavaScript 在這個過程中引入的開銷到底有多大。Node.js 採用的網絡請求處理模型是 IO 多路復用。
  • Node.js v14 官方發布說明來了
    診斷報告變得穩定診斷報告將會在 Node.js 14 中作為穩定功能發布(在 Node.js 12 中作為實驗性功能添加)。這是項目正在進行的工作中的重要一步,目的是建立使用 Node.js 時可用的診斷程序並改進它們的易用性,其中大部分工作由 Node.js 診斷工作組推進。診斷報告功能使你可以按需或在某些事件發生時生成報告。
  • 如何使用HTTP模塊在Node.js中創建Web伺服器(上)
    後端代碼與如何交換、處理和存儲數據有關,處理來自瀏覽器的網絡請求或與資料庫通信的代碼主要由後端代碼管理。Node.js允許開發人員使用JavaScript編寫後端代碼,儘管傳統上它是在瀏覽器中用於編寫前端代碼的。這樣將前端和後端結合在一起可以減少製作Web伺服器的工作量,這是Node.js成為編寫後端代碼的流行選擇的主要原因。
  • 使用vue+node搭建前端異常監控系統
    使用vue+node搭建前端異常監控系統使用vue+node搭建前端異常監控系統(一)-原理剖析您將Get的技能收集前端錯誤(原生、React、
  • 基於 Unix Socket 的可靠 Node.js HTTP 代理實現(支持 WebSocket 協議)
    此時業務服務偵聽任何埠都可以,因為在傳輸層根本沒有使用該埠,這樣就避免了系統埠的浪費。流量轉發流量轉發包括了HTTP請求和WebSocket握手報文,雖然WebSocket握手報文仍然是基於HTTP協議實現,但需要不同的處理,因此這裡分開來說。
  • Nest.js:給你看個不一樣的 Node.js
    看下官方給的簡介,NestJs 模塊化的體系結構,允許開發者使用任何其他的庫,從而提供靈活性;為 Nodejs 提供一個適應性強大的生態系統;利用最新的js特性,為 nodejs 提供更加方便的設計模式和成熟的解決方案。
  • 為什麼 HTTP 請求會返回 304?
    這篇文章中,介紹了 ETag 是如何生成的。在 ETag 實戰環節,阿寶哥基於 koa、koa-conditional-get、koa-etag 和 koa-static 這些庫,演示了在實際項目中如何利用 ETag 響應頭和 If-None-Match 請求頭實現資源的緩存控制。
  • Node.js 中的 stream 模塊詳解
    可能看一張圖會更直觀:水桶管道流轉圖注意:stream不是node.js獨有的概念,而是一個作業系統最基本的操作方式,只不過node.js有API支持這種操作方式。linux命令的|就是stream。stream到哪裡去-deststream的常見輸出方式有三種:輸出控制臺http請求中的response寫入文件stream應用場景stream的應用場景主要就是處理IO操作,而http請求和文件操作都屬於IO操作。
  • Node.js VS 瀏覽器以及事件循環機制
    而在Node.js中,microtask會在事件循環的各個階段之間執行,也就是一個階段執行完畢,就會去執行microtask隊列的任務。node.js 是⼀個 JS 的服務端運⾏環境,簡單的來說,是在 JS 語⾔規範的基礎上,封裝了⼀些服務端的運⾏時 對象,讓我們能夠簡單實現⾮常多的業務功能。> 基於 JS 語法增加與作業系統之間的交互。
  • 如何使用HTTP模塊在Node.js中創建Web伺服器(下)
    在web瀏覽器中,訪問http://localhost:8000,你將看到此頁面:我們的錯誤處理程序也已更改,如果無法加載文件,我們將捕獲錯誤並將其列印到控制臺。然後,我們使用exit()函數退出Node.js程序,而無需啟動伺服器。這樣,我們可以了解文件讀取失敗的原因,解決該問題,然後再次啟動伺服器。現在,我們創建了不同的web伺服器,這些伺服器將各種類型的數據返回給用戶。到目前為止,我們尚未使用任何請求數據來確定應返回的內容。
  • 【譯文】正確關閉Node.js的應用程式
    在這一步中我們將關閉我們的伺服器,以便處理正掛起的請求並防止接入新的請求。function handleExit(signal) { console.log(`Received ${signal}.現在我們的伺服器處理好請求之後正確的關機了。在Nairi Harutyunyan的文章中你可以了解到更多。
  • Node.js GET/POST請求
    表單提交到伺服器一般都使用GET/POST請求。本章節我們將為大家介紹 Node.js GET/POST請求。獲取GET請求內容由於GET請求直接被嵌入在路徑中,URL是完整的請求路徑,包括了?後面的部分,因此你可以手動解析後面的內容作為GET請求的參數。node.js中url模塊中的parse函數提供了這個功能。
  • 國內 Node.js 2015 總結
    data APIhttp://wangxinhan.com/2015/11/15/11.14BJNodeParty/北京12月13日 NodeParty@零壹時光 照片,演講PPT倪楷分享了在baidu地圖,百度慧眼中,渲染大量點,線,面的情況下,使用node-canvas的經驗。