async_hooks 模块是在 v8.0.0 版本正式加入 Node.js 的实验性 API。我们也是在 v8.x.x 版本下投入生产环境进行使用。
那么什么是 async_hooks 呢?
async_hooks 提供了追踪异步资源的 API,这种异步资源是具有关联回调的对象。
简而言之,async_hooks 模块可以用来追踪异步回调。那么如何使用这种追踪能力,使用的过程中又有什么问题呢?
认识 async_hooksv8.x.x 版本下的 async_hooks 主要有两部分组成,一个是 createHook 用以追踪生命周期,一个是 AsyncResource 用于创建异步资源。
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()
上面这段代码的含义和执行结果是:
创建一个包含在每个异步操作的 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。
示例代码如下:
const http = require('http') const { createHook, executionAsyncId } = require('async_hooks') const fs = require('fs') // 追踪调用链并创建调用链存储对象 const cache = {} const hook = 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 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) }) } // 创建服务 http .createServer(async (req, res) => { // 获取当前请求的 request-id 写入存储 cache[executionAsyncId()].requestId = req.headers['request-id'] // 模拟一些其他耗时操作 await timeout() // 发送一个请求 http.request('http://www.baidu.com', (res) => {}) res.write('hello\n') res.end() }) .listen(3000)
执行代码并进行一次发送测试,发现已经可以正确获取到 request-id。
陷阱同时,我们也需要注意到一点,init 是异步资源创建的钩子,不是异步回调函数创建的钩子,只会在异步资源创建的时候执行一次。