node使用async_hooks模塊進行請求追蹤
async_hooks 模塊是在 v8.0.0 版本正式加入 Node.js 的實驗性 API。我們也是在 v8.x.x 版本下投入生產(chǎn)環(huán)境進行使用。
那么什么是 async_hooks 呢?
async_hooks 提供了追蹤異步資源的 API,這種異步資源是具有關聯(lián)回調(diào)的對象。
簡而言之,async_hooks 模塊可以用來追蹤異步回調(diào)。那么如何使用這種追蹤能力,使用的過程中又有什么問題呢?
認識 async_hooks
v8.x.x 版本下的 async_hooks 主要有兩部分組成,一個是 createHook 用以追蹤生命周期,一個是 AsyncResource 用于創(chuàng)建異步資源。
const { createHook, AsyncResource, executionAsyncId } = require('async_hooks') const hook = createHook({ init (asyncId, type, triggerAsyncId, resource) {}, before (asyncId) {}, after (asyncId) {}, destroy (asyncId) {} }) hook.enable() function fn () { console.log(executionAsyncId()) } const asyncResource = new AsyncResource('demo') asyncResource.run(fn) asyncResource.run(fn) asyncResource.emitDestroy()
上面這段代碼的含義和執(zhí)行結(jié)果是:
- 創(chuàng)建一個包含在每個異步操作的 init、before、after、destroy 聲明周期執(zhí)行的鉤子函數(shù)的 hooks 實例。
- 啟用這個 hooks 實例。
- 手動創(chuàng)建一個類型為 demo 的異步資源。此時觸發(fā)了 init 鉤子,異步資源 id 為 asyncId,類型為 type(即 demo),異步資源的創(chuàng)建上下文 id 為 triggerAsyncId,異步資源為 resource。
- 使用此異步資源執(zhí)行 fn 函數(shù)兩次,此時會觸發(fā) before 兩次、after 兩次,異步資源 id 為 asyncId,此 asyncId 與 fn 函數(shù)內(nèi)通過 executionAsyncId 取到的值相同。
- 手動觸發(fā) destroy 生命周期鉤子。
像我們常用的 async、await、promise 語法或請求這些異步操作的背后都是一個個的異步資源,也會觸發(fā)這些生命周期鉤子函數(shù)。
那么,我們就可以在 init 鉤子函數(shù)中,通過異步資源創(chuàng)建上下文 triggerAsyncId(父)到當前異步資源 asyncId(子)這種指向關系,將異步調(diào)用串聯(lián)起來,拿到一棵完整的調(diào)用樹,通過回調(diào)函數(shù)(即上述代碼的 fn)中 executionAsyncId() 獲取到執(zhí)行當前回調(diào)的異步資源的 asyncId,從調(diào)用鏈上追查到調(diào)用的源頭。
同時,我們也需要注意到一點,init 是異步資源創(chuàng)建的鉤子,不是異步回調(diào)函數(shù)創(chuàng)建的鉤子,只會在異步資源創(chuàng)建的時候執(zhí)行一次,這會在實際使用的時候帶來什么問題呢?
請求追蹤
出于異常排查和數(shù)據(jù)分析的目的,希望在我們 Ada 架構(gòu)的 Node.js 服務中,將服務器收到的由客戶端發(fā)來請求的請求頭中的 request-id 自動添加到發(fā)往中后臺服務的每個請求的請求頭中。
功能實現(xiàn)的簡單設計如下:
- 通過 init 鉤子使得在同一條調(diào)用鏈上的異步資源共用一個存儲對象。
- 解析請求頭中 request-id,添加到當前異步調(diào)用鏈對應的存儲上。
- 改寫 http、https 模塊的 request 方法,在請求執(zhí)行時獲取當前當前的調(diào)用鏈對應存儲中的 request-id。
示例代碼如下:
const http = require('http') const { createHook, executionAsyncId } = require('async_hooks') const fs = require('fs') // 追蹤調(diào)用鏈并創(chuàng)建調(diào)用鏈存儲對象 const cache = {} const hook = createHook({ init (asyncId, type, triggerAsyncId, resource) { if (type === 'TickObject') return // 由于在 Node.js 中 console.log 也是異步行為,會導致觸發(fā) init 鉤子,所以我們只能通過同步方法記錄日志 fs.appendFileSync('log.out', `init ${type}(${asyncId}: trigger: ${triggerAsyncId})\n`); // 判斷調(diào)用鏈存儲對象是否已經(jīng)初始化 if (!cache[triggerAsyncId]) { cache[triggerAsyncId] = {} } // 將父節(jié)點的存儲與當前異步資源通過引用共享 cache[asyncId] = cache[triggerAsyncId] } }) hook.enable() // 改寫 http const httpRequest = http.request http.request = (options, callback) => { const client = httpRequest(options, callback) // 獲取當前請求所屬異步資源對應存儲的 request-id 寫入 header const requestId = cache[executionAsyncId()].requestId console.log('cache', cache[executionAsyncId()]) client.setHeader('request-id', requestId) return client } function timeout () { return new Promise((resolve, reject) => { setTimeout(resolve, Math.random() * 1000) }) } // 創(chuàng)建服務 http .createServer(async (req, res) => { // 獲取當前請求的 request-id 寫入存儲 cache[executionAsyncId()].requestId = req.headers['request-id'] // 模擬一些其他耗時操作 await timeout() // 發(fā)送一個請求 http.request('http://www.baidu.com', (res) => {}) res.write('hello\n') res.end() }) .listen(3000)
執(zhí)行代碼并進行一次發(fā)送測試,發(fā)現(xiàn)已經(jīng)可以正確獲取到 request-id。
陷阱
同時,我們也需要注意到一點,init 是異步資源創(chuàng)建的鉤子,不是異步回調(diào)函數(shù)創(chuàng)建的鉤子,只會在異步資源創(chuàng)建的時候執(zhí)行一次。
但是上面的代碼是有問題的,像前面介紹 async_hooks 模塊時的代碼演示的那樣,一個異步資源可以不斷的執(zhí)行不同的函數(shù),即異步資源有復用的可能。特別是對類似于 TCP 這種由 C/C++ 部分創(chuàng)建的異步資源,多次請求可能會使用同一個 TCP 異步資源,從而使得這種情況下,多次請求到達服務器時初始的 init 鉤子函數(shù)只會執(zhí)行一次,導致多次請求的調(diào)用鏈追蹤會追蹤到同一個 triggerAsyncId,從而引用同一個存儲。
我們將前面的代碼做如下修改,來進行一次驗證。 存儲初始化部分將 triggerAsyncId 保存下來,方便觀察異步調(diào)用的追蹤關系:
if (!cache[triggerAsyncId]) { cache[triggerAsyncId] = { id: triggerAsyncId } }
timeout 函數(shù)改為先進行一次長耗時再進行一次短耗時操作:
function timeout () { return new Promise((resolve, reject) => { setTimeout(resolve, [1000, 5000].pop()) }) }
重啟服務后,使用 postman (不用 curl 是因為 curl 每次請求結(jié)束會關閉連接,導致不能復現(xiàn))連續(xù)的發(fā)送兩次請求,可以觀察到以下輸出:
{ id: 1, requestId: '第二次請求的id' }
{ id: 1, requestId: '第二次請求的id' }
即可發(fā)現(xiàn)在多并發(fā)且寫讀存儲的操作之間有耗時不固定的其他操作情況下,先到達服務器的請求存儲的值會被后到達服務器的請求執(zhí)行復寫掉,使得前一次請求讀取到錯誤的值。當然,你可以保證在寫和讀之間不插入其他的耗時操作,但在復雜的服務中這種靠腦力維護的保障方式明顯是不可靠的。此時,我們就需要使每次讀寫前,JS 都能進入一個全新的異步資源上下文,即獲得一個全新的 asyncId,避免這種復用。需要將調(diào)用鏈存儲的部分做以下幾方面修改:
const http = require('http') const { createHook, executionAsyncId } = require('async_hooks') const fs = require('fs') const cache = {} const httpRequest = http.request http.request = (options, callback) => { const client = httpRequest(options, callback) const requestId = cache[executionAsyncId()].requestId console.log('cache', cache[executionAsyncId()]) client.setHeader('request-id', requestId) return client } // 將存儲的初始化提取為一個獨立的方法 async function cacheInit (callback) { // 利用 await 操作使得 await 后的代碼進入一個全新的異步上下文 await Promise.resolve() cache[executionAsyncId()] = {} // 使用 callback 執(zhí)行的方式,使得后續(xù)操作都屬于這個新的異步上下文 return callback() } const hook = createHook({ init (asyncId, type, triggerAsyncId, resource) { if (!cache[triggerAsyncId]) { // init hook 不再進行初始化 return fs.appendFileSync('log.out', `未使用 cacheInit 方法進行初始化`) } cache[asyncId] = cache[triggerAsyncId] } }) hook.enable() function timeout () { return new Promise((resolve, reject) => { setTimeout(resolve, [1000, 5000].pop()) }) } http .createServer(async (req, res) => { // 將后續(xù)操作作為 callback 傳入 cacheInit await cacheInit(async function fn() { cache[executionAsyncId()].requestId = req.headers['request-id'] await timeout() http.request('http://www.baidu.com', (res) => {}) res.write('hello\n') res.end() }) }) .listen(3000)
值得一提的是,這種使用 callback 的組織方式與 koajs 的中間件的模式十分一致。
async function middleware (ctx, next) { await Promise.resolve() cache[executionAsyncId()] = {} return next() }
NodeJs v14
這種使用 await Promise.resolve() 創(chuàng)建全新異步上下文的方式看起來總有些 “歪門邪道” 的感覺。好在 NodeJs v9.x.x 版本中提供了創(chuàng)建異步上下文的官方實現(xiàn)方式 asyncResource.runInAsyncScope。更好的是,NodeJs v14.x.x 版本直接提供了異步調(diào)用鏈數(shù)據(jù)存儲的官方實現(xiàn),它會直接幫你完成異步調(diào)用關系追蹤、創(chuàng)建新的異步上線文、管理數(shù)據(jù)這三項工作!API 就不再詳細介紹,我們直接使用新 API 改造之前的實現(xiàn)
const { AsyncLocalStorage } = require('async_hooks') // 直接創(chuàng)建一個 asyncLocalStorage 存儲實例,不再需要管理 async 生命周期鉤子 const asyncLocalStorage = new AsyncLocalStorage() const storage = { enable (callback) { // 使用 run 方法創(chuàng)建全新的存儲,且需要讓后續(xù)操作作為 run 方法的回調(diào)執(zhí)行,以使用全新的異步資源上下文 asyncLocalStorage.run({}, callback) }, get (key) { return asyncLocalStorage.getStore()[key] }, set (key, value) { asyncLocalStorage.getStore()[key] = value } } // 改寫 http const httpRequest = http.request http.request = (options, callback) => { const client = httpRequest(options, callback) // 獲取異步資源存儲的 request-id 寫入 header client.setHeader('request-id', storage.get('requestId')) return client } // 使用 http .createServer((req, res) => { storage.enable(async function () { // 獲取當前請求的 request-id 寫入存儲 storage.set('requestId', req.headers['request-id']) http.request('http://www.baidu.com', (res) => {}) res.write('hello\n') res.end() }) }) .listen(3000)
可以看到,官方實現(xiàn)的 asyncLocalStorage.run API 和我們的第二版實現(xiàn)在結(jié)構(gòu)上也很一致。
于是,在 Node.js v14.x.x 版本下,使用 async_hooks 模塊進行請求追蹤的功能很輕易的就實現(xiàn)了。
到此這篇關于node使用async_hooks模塊進行請求追蹤的文章就介紹到這了,更多相關node async_hooks請求追蹤內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
NodeJS如何優(yōu)雅的實現(xiàn)Sleep休眠
這篇文章主要介紹了NodeJS如何優(yōu)雅的實現(xiàn)Sleep休眠問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-09-09node.js實現(xiàn)端口轉(zhuǎn)發(fā)
這篇文章主要為大家詳細介紹了node.js實現(xiàn)端口轉(zhuǎn)發(fā)的關鍵代碼,感興趣的小伙伴們可以參考一下2016-04-04nodejs+koa2 實現(xiàn)模仿springMVC框架
這篇文章主要介紹了nodejs+koa2 實現(xiàn)模仿springMVC框架,本文通過實例圖文相結(jié)合給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-10-10