node使用async_hooks模块进行请求追踪
async_hooks模块是在v8.0.0版本正式加入Node.js的实验性API。我们也是在v8.x.x版本下投入生产环境进行使用。
那么什么是async_hooks呢?
async_hooks提供了追踪异步资源的API,这种异步资源是具有关联回调的对象。
简而言之,async_hooks模块可以用来追踪异步回调。那么如何使用这种追踪能力,使用的过程中又有什么问题呢?
认识async_hooks
v8.x.x版本下的async_hooks主要有两部分组成,一个是createHook用以追踪生命周期,一个是AsyncResource用于创建异步资源。
const{createHook,AsyncResource,executionAsyncId}=require('async_hooks') consthook=createHook({ init(asyncId,type,triggerAsyncId,resource){}, before(asyncId){}, after(asyncId){}, destroy(asyncId){} }) hook.enable() functionfn(){ console.log(executionAsyncId()) } constasyncResource=newAsyncResource('demo') asyncResource.run(fn) asyncResource.run(fn) asyncResource.emitDestroy()
上面这段代码的含义和执行结果是:
- 创建一个包含在每个异步操作的init、before、after、destroy声明周期执行的钩子函数的hooks实例。
- 启用这个hooks实例。
- 手动创建一个类型为demo的异步资源。此时触发了init钩子,异步资源id为asyncId,类型为type(即demo),异步资源的创建上下文id为triggerAsyncId,异步资源为resource。
- 使用此异步资源执行fn函数两次,此时会触发before两次、after两次,异步资源id为asyncId,此asyncId与fn函数内通过executionAsyncId取到的值相同。
- 手动触发destroy生命周期钩子。
像我们常用的async、await、promise语法或请求这些异步操作的背后都是一个个的异步资源,也会触发这些生命周期钩子函数。
那么,我们就可以在init钩子函数中,通过异步资源创建上下文triggerAsyncId(父)到当前异步资源asyncId(子)这种指向关系,将异步调用串联起来,拿到一棵完整的调用树,通过回调函数(即上述代码的fn)中executionAsyncId()获取到执行当前回调的异步资源的asyncId,从调用链上追查到调用的源头。
同时,我们也需要注意到一点,init是异步资源创建的钩子,不是异步回调函数创建的钩子,只会在异步资源创建的时候执行一次,这会在实际使用的时候带来什么问题呢?
请求追踪
出于异常排查和数据分析的目的,希望在我们Ada架构的Node.js服务中,将服务器收到的由客户端发来请求的请求头中的request-id自动添加到发往中后台服务的每个请求的请求头中。
功能实现的简单设计如下:
- 通过init钩子使得在同一条调用链上的异步资源共用一个存储对象。
- 解析请求头中request-id,添加到当前异步调用链对应的存储上。
- 改写http、https模块的request方法,在请求执行时获取当前当前的调用链对应存储中的request-id。
示例代码如下:
consthttp=require('http') const{createHook,executionAsyncId}=require('async_hooks') constfs=require('fs') //追踪调用链并创建调用链存储对象 constcache={} consthook=createHook({ init(asyncId,type,triggerAsyncId,resource){ if(type==='TickObject')return //由于在Node.js中console.log也是异步行为,会导致触发init钩子,所以我们只能通过同步方法记录日志 fs.appendFileSync('log.out',`init${type}(${asyncId}:trigger:${triggerAsyncId})\n`); //判断调用链存储对象是否已经初始化 if(!cache[triggerAsyncId]){ cache[triggerAsyncId]={} } //将父节点的存储与当前异步资源通过引用共享 cache[asyncId]=cache[triggerAsyncId] } }) hook.enable() //改写http consthttpRequest=http.request http.request=(options,callback)=>{ constclient=httpRequest(options,callback) //获取当前请求所属异步资源对应存储的request-id写入header constrequestId=cache[executionAsyncId()].requestId console.log('cache',cache[executionAsyncId()]) client.setHeader('request-id',requestId) returnclient } functiontimeout(){ returnnewPromise((resolve,reject)=>{ setTimeout(resolve,Math.random()*1000) }) } //创建服务 http .createServer(async(req,res)=>{ //获取当前请求的request-id写入存储 cache[executionAsyncId()].requestId=req.headers['request-id'] //模拟一些其他耗时操作 awaittimeout() //发送一个请求 http.request('http://www.baidu.com',(res)=>{}) res.write('hello\n') res.end() }) .listen(3000)
执行代码并进行一次发送测试,发现已经可以正确获取到request-id。
陷阱
同时,我们也需要注意到一点,init是异步资源创建的钩子,不是异步回调函数创建的钩子,只会在异步资源创建的时候执行一次。
但是上面的代码是有问题的,像前面介绍async_hooks模块时的代码演示的那样,一个异步资源可以不断的执行不同的函数,即异步资源有复用的可能。特别是对类似于TCP这种由C/C++部分创建的异步资源,多次请求可能会使用同一个TCP异步资源,从而使得这种情况下,多次请求到达服务器时初始的init钩子函数只会执行一次,导致多次请求的调用链追踪会追踪到同一个triggerAsyncId,从而引用同一个存储。
我们将前面的代码做如下修改,来进行一次验证。存储初始化部分将triggerAsyncId保存下来,方便观察异步调用的追踪关系:
if(!cache[triggerAsyncId]){ cache[triggerAsyncId]={ id:triggerAsyncId } }
timeout函数改为先进行一次长耗时再进行一次短耗时操作:
functiontimeout(){ returnnewPromise((resolve,reject)=>{ setTimeout(resolve,[1000,5000].pop()) }) }
重启服务后,使用postman(不用curl是因为curl每次请求结束会关闭连接,导致不能复现)连续的发送两次请求,可以观察到以下输出:
{id:1,requestId:'第二次请求的id'}
{id:1,requestId:'第二次请求的id'}
即可发现在多并发且写读存储的操作之间有耗时不固定的其他操作情况下,先到达服务器的请求存储的值会被后到达服务器的请求执行复写掉,使得前一次请求读取到错误的值。当然,你可以保证在写和读之间不插入其他的耗时操作,但在复杂的服务中这种靠脑力维护的保障方式明显是不可靠的。此时,我们就需要使每次读写前,JS都能进入一个全新的异步资源上下文,即获得一个全新的asyncId,避免这种复用。需要将调用链存储的部分做以下几方面修改:
consthttp=require('http') const{createHook,executionAsyncId}=require('async_hooks') constfs=require('fs') constcache={} consthttpRequest=http.request http.request=(options,callback)=>{ constclient=httpRequest(options,callback) constrequestId=cache[executionAsyncId()].requestId console.log('cache',cache[executionAsyncId()]) client.setHeader('request-id',requestId) returnclient } //将存储的初始化提取为一个独立的方法 asyncfunctioncacheInit(callback){ //利用await操作使得await后的代码进入一个全新的异步上下文 awaitPromise.resolve() cache[executionAsyncId()]={} //使用callback执行的方式,使得后续操作都属于这个新的异步上下文 returncallback() } consthook=createHook({ init(asyncId,type,triggerAsyncId,resource){ if(!cache[triggerAsyncId]){ //inithook不再进行初始化 returnfs.appendFileSync('log.out',`未使用cacheInit方法进行初始化`) } cache[asyncId]=cache[triggerAsyncId] } }) hook.enable() functiontimeout(){ returnnewPromise((resolve,reject)=>{ setTimeout(resolve,[1000,5000].pop()) }) } http .createServer(async(req,res)=>{ //将后续操作作为callback传入cacheInit awaitcacheInit(asyncfunctionfn(){ cache[executionAsyncId()].requestId=req.headers['request-id'] awaittimeout() http.request('http://www.baidu.com',(res)=>{}) res.write('hello\n') res.end() }) }) .listen(3000)
值得一提的是,这种使用callback的组织方式与koajs的中间件的模式十分一致。
asyncfunctionmiddleware(ctx,next){ awaitPromise.resolve() cache[executionAsyncId()]={} returnnext() }
NodeJsv14
这种使用awaitPromise.resolve()创建全新异步上下文的方式看起来总有些“歪门邪道”的感觉。好在NodeJsv9.x.x版本中提供了创建异步上下文的官方实现方式asyncResource.runInAsyncScope。更好的是,NodeJsv14.x.x版本直接提供了异步调用链数据存储的官方实现,它会直接帮你完成异步调用关系追踪、创建新的异步上线文、管理数据这三项工作!API就不再详细介绍,我们直接使用新API改造之前的实现
const{AsyncLocalStorage}=require('async_hooks') //直接创建一个asyncLocalStorage存储实例,不再需要管理async生命周期钩子 constasyncLocalStorage=newAsyncLocalStorage() conststorage={ enable(callback){ //使用run方法创建全新的存储,且需要让后续操作作为run方法的回调执行,以使用全新的异步资源上下文 asyncLocalStorage.run({},callback) }, get(key){ returnasyncLocalStorage.getStore()[key] }, set(key,value){ asyncLocalStorage.getStore()[key]=value } } //改写http consthttpRequest=http.request http.request=(options,callback)=>{ constclient=httpRequest(options,callback) //获取异步资源存储的request-id写入header client.setHeader('request-id',storage.get('requestId')) returnclient } //使用 http .createServer((req,res)=>{ storage.enable(asyncfunction(){ //获取当前请求的request-id写入存储 storage.set('requestId',req.headers['request-id']) http.request('http://www.baidu.com',(res)=>{}) res.write('hello\n') res.end() }) }) .listen(3000)
可以看到,官方实现的asyncLocalStorage.runAPI和我们的第二版实现在结构上也很一致。
于是,在Node.jsv14.x.x版本下,使用async_hooks模块进行请求追踪的功能很轻易的就实现了。
到此这篇关于node使用async_hooks模块进行请求追踪的文章就介绍到这了,更多相关nodeasync_hooks请求追踪内容请搜索毛票票以前的文章或继续浏览下面的相关文章希望大家以后多多支持毛票票!