作者簡介:五月君,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