在 Node.js 中使用 Async Hooks 處理 HTTP 請求上下文實(shí)現(xiàn)鏈路追蹤
作者簡介:五月君,Software Designer,公眾號(hào)「Nodejs技術(shù)棧」作者。
Async Hooks 一個(gè)實(shí)際的使用場景是存儲(chǔ)請求上下文,在異步調(diào)用之間共享數(shù)據(jù)。上節(jié)對基礎(chǔ)使用做了介紹,還沒看的參見之前的分享 使用 Node.js 的 Async Hooks 模塊追蹤異步資源。
本節(jié)將會(huì)介紹如何基于 Async hooks 提供的 API 從零開始實(shí)現(xiàn)一個(gè) AsyncLocalStorage 類(異步本地存儲(chǔ))及在 HTTP 請求中關(guān)聯(lián)日志的 traceId 實(shí)現(xiàn)鏈路追蹤,這也是 Async Hooks 的一個(gè)實(shí)際應(yīng)用場景了。
何為異步本地存儲(chǔ)?
我們所說的異步本地存儲(chǔ)類似于多線程編程語言中的線程本地存儲(chǔ)。拿之前筆者寫過的 Java 做個(gè)舉例,例如 Java 中的 ThreadLocal 類,可以為使用相同變量的不同線程創(chuàng)建一個(gè)各自的副本,避免共享資源產(chǎn)生的沖突,在一個(gè)線程請求之內(nèi)通過 get()/set() 方法獲取或設(shè)置這個(gè)變量在當(dāng)前線程中對應(yīng)的副本值,在多線程并發(fā)訪問時(shí)線程之間各自創(chuàng)建的副本互不影響。
在 Node.js 中我們的業(yè)務(wù)通常都工作在主線程(使用 work_threads 除外),是沒有 ThreadLocal 類的。并且以事件驅(qū)動(dòng)的方式來處理所有的 HTTP 請求,每個(gè)請求過來之后又都是異步的,異步之間還很難去追蹤上下文信息,我們想做的是在這個(gè)異步事件開始,例如從接收 HTTP 請求到響應(yīng),能夠有一種機(jī)可以讓我們隨時(shí)隨地去獲取在這期間的一些共享數(shù)據(jù),也就是我們本節(jié)所提的異步本地存儲(chǔ)技術(shù)。
在接下來我會(huì)講解實(shí)現(xiàn) AsyncLocalStorage 的四種方式,從最開始的手動(dòng)實(shí)現(xiàn)到官方 v14.x 支持的 AsyncLocalStorage 類,你也可以從中學(xué)習(xí)到其實(shí)現(xiàn)原理。
現(xiàn)有業(yè)務(wù)問題
假設(shè),現(xiàn)在有一個(gè)需求對現(xiàn)有日志系統(tǒng)做改造,所有記錄日志的地方增加 traceId 實(shí)現(xiàn)全鏈路日志追蹤。
一種情況是假設(shè)你使用一些類似 Egg.js 這樣的企業(yè)級(jí)框架,可以依賴于框架提供的中間件能力在請求上掛載 traceId,可以看看之前的一篇文章 基于 Egg.js 框架的日志鏈路追蹤實(shí)踐 也是可以實(shí)現(xiàn)的,不過當(dāng)時(shí)是基于 egg 的一個(gè)插件自己做了繼承實(shí)現(xiàn),現(xiàn)在已經(jīng)不需要這么麻煩,可以通過配置自定義日志格式來實(shí)現(xiàn) https://eggjs.org/zh-cn/core/logger.html#自定義日志格式。
另一種情況假設(shè)你是用的 Express、Koa 這些基礎(chǔ)框架,所有業(yè)務(wù)都是模塊加載函數(shù)式調(diào)用,如果每次把請求的 traceId 手動(dòng)在 Controller -> Service -> Model 之間傳遞,這樣對業(yè)務(wù)代碼的侵入太大了,日志與業(yè)務(wù)的耦合度就太高了。
如下代碼,是我精簡后的一個(gè)例子,現(xiàn)在有一個(gè)需求,在不更改業(yè)務(wù)代碼的情況下每次日志打印都輸出當(dāng)前 HTTP 請求處理 Headers 中攜帶的 traceId 字段,如果是你會(huì)怎么做呢?
- // 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 [];
- }
方式一:動(dòng)手實(shí)現(xiàn)異步本地存儲(chǔ)
解決方案是實(shí)現(xiàn)請求上下文本地存儲(chǔ),在當(dāng)前作用域代碼中能夠獲取上下文信息,待處理完畢清除保存的上下文信息,這些需求可以通過 Async Hooks 提供的 API 實(shí)現(xiàn)。
創(chuàng)建 AsyncLocalStorage 類
- 行 {1} 創(chuàng)建一個(gè) Map 集合存儲(chǔ)上下文信息。
- 行 {2} 里面的 init 回調(diào)是重點(diǎn),當(dāng)一個(gè)異步事件被觸發(fā)前會(huì)先收到 init 回調(diào),其中 triggerAsyncId 是當(dāng)前異步資源的觸發(fā)者,我們則可以在這里獲取上個(gè)異步資源的信息存儲(chǔ)至當(dāng)前異步資源中。當(dāng) asyncId 對應(yīng)的異步資源被銷毀時(shí)會(huì)收到 destroy 回調(diào),所以最后要記得在 destroy 回調(diào)里清除當(dāng)前 asyncId 里存儲(chǔ)的信息。
- 行 {3} 拿到當(dāng)前請求上下文的 asyncId 做為 Map 集合的 Key 存入傳入的上下文信息。
- 行 {4} 拿到 asyncId 獲取當(dāng)前代碼的上下文信息。
- // 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(); 這樣一段代碼,這是因?yàn)?Promise 默認(rèn)是沒有開啟的,通過顯示的調(diào)用可以開啟 Promise 的異步追蹤。
改造 logger.js 文件
在我們需要打印日志的地方拿到當(dāng)前代碼所對應(yīng)的上下文信息,取出我們存儲(chǔ)的 traceId, 這種方式只需要改造我們?nèi)罩局虚g即可,不需要去更改我們的業(yè)務(wù)代碼。
- 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 文件
注冊一個(gè)中間件,傳遞請求信息。
- app.use((req, res, next) => logger.run(req, next));
運(yùn)行后輸出結(jié)果
- 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 來實(shí)現(xiàn),不理解其實(shí)現(xiàn)原理的可以在動(dòng)手實(shí)踐下,這種方式需要我們額外維護(hù)維護(hù)一個(gè) Map 對象,還要處理銷毀操作。
方式二:executionAsyncResource() 返回當(dāng)前執(zhí)行的異步資源
executionAsyncResource() 返回當(dāng)前執(zhí)行的異步資源,這對于實(shí)現(xiàn)連續(xù)的本地存儲(chǔ)很有幫助,無需像 “方式一” 再創(chuàng)建一個(gè) Map 對象來存儲(chǔ)元數(shù)據(jù)。
- 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 創(chuàng)建 AsyncLocalStorage 類
ResourceAysnc 可以用來自定義異步資源,此處的介紹也是參考 Node.js 源碼對 AsyncLocalStorage 的實(shí)現(xiàn)。
一個(gè)顯著的改變是 run() 方法,每一次的調(diào)用都會(huì)創(chuàng)建一個(gè)資源,調(diào)用其 runInAsyncScope() 方法,這樣在這個(gè)資源的異步作用域下,所執(zhí)行的代碼(傳入的 callback)都是可追蹤我們設(shè)置的 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 類,實(shí)例化一個(gè)對象調(diào)用 run() 方法實(shí)現(xiàn)本地存儲(chǔ),也是推薦的方式,不需要自己去再額外維護(hù)一個(gè) AsyncLocalStorage 類。
AsyncLocalStorage 類的實(shí)現(xiàn)也就是上面講解的方式三,所以也不需要我們在外部顯示的調(diào)用 hooks.enable() 來啟用 Promise 異步追蹤,因?yàn)槠鋬?nèi)部已經(jīng)實(shí)現(xiàn)了。
- const { AsyncLocalStorage } = require('async_hooks');
Async Hooks 的性能開銷
這一點(diǎn)是大家最關(guān)心的問題,如果開啟了 Async Hooks(Promise 需要調(diào)用 Async Hooks 實(shí)例的 enable() 方法開啟)每一次異步操作或 Promise 類型的操作,包括 console 只要是異步的都會(huì)觸發(fā) hooks,也必然是有性能開銷的。
參考 Kuzzle 的性能基準(zhǔn)測試,使用了 AsyncLocalStorage 與未使用之間相差 ~8%。
---- | Log with AsyncLocalStorage | Log classic | difference |
---|---|---|---|
req/s | 2613 | 2842 | 〜8% |
當(dāng)然不同的業(yè)務(wù)也有著不同的差異,如果你擔(dān)心會(huì)有很大的性能開銷,可以基于自己的業(yè)務(wù)做一些基準(zhǔn)測試。
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 實(shí)現(xiàn)請求作用域: https://mp.weixin.qq.com/s/I22TvmTqCKFClsp0YLDoZw
[4]
Async Hooks 性能影響: https://github.com/nodejs/benchmarking/issues/181
[5]
kuzzle 基準(zhǔn)測試: https://github.com/kuzzleio/kuzzle/pull/1604
本文轉(zhuǎn)載自微信公眾號(hào)「Nodejs技術(shù)?!?,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系Nodejs技術(shù)棧公眾號(hào)。