乐趣区

关于前端:使用-asynchooks-模块进行请求追踪

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')

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()

下面这段代码的含意和执行后果是:

  1. 创立一个蕴含在每个异步操作的 init、before、after、destroy 申明周期执行的钩子函数的 hooks 实例。
  2. 启用这个 hooks 实例。
  3. 手动创立一个类型为 demo 的异步资源。此时触发了 init 钩子,异步资源 id 为 asyncId,类型为 type(即 demo),异步资源的创立上下文 id 为 triggerAsyncId,异步资源为 resource
  4. 应用此异步资源执行 fn 函数两次,此时会触发 before 两次、after 两次,异步资源 id 为 asyncId,此 asyncIdfn 函数内通过 executionAsyncId 取到的值雷同。
  5. 手动触发 destroy 生命周期钩子。

像咱们罕用的 asyncawait、promise 语法或申请这些异步操作的背地都是一个个的异步资源,也会触发这些生命周期钩子函数。

那么,咱们就能够在 init 钩子函数中,通过异步资源创立上下文 triggerAsyncId(父)到以后异步资源 asyncId(子)这种指向关系,将异步调用串联起来,拿到一棵残缺的调用树,通过回调函数(即上述代码的 fn)中 executionAsyncId() 获取到执行以后回调的异步资源的 asyncId,从调用链上追究到调用的源头。

同时,咱们也须要留神到一点,init 是 异步资源创立 的钩子,不是 异步回调函数创立 的钩子,只会在异步资源创立的时候执行一次,这会在理论应用的时候带来什么问题呢?

申请追踪

出于异样排查和数据分析的目标,心愿在咱们 Ada 架构的 Node.js 服务中,将服务器收到的由客户端发来申请的申请头中的 request-id 主动增加到发往中后盾服务的每个申请的申请头中。

性能实现的简略设计如下:

  1. 通过 init 钩子使得在同一条调用链上的异步资源共用一个存储对象。
  2. 解析申请头中 request-id,增加到以后异步调用链对应的存储上。
  3. 改写 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 是 异步资源创立 的钩子,不是 异步回调函数创立 的钩子,只会在异步资源创立的时候执行一次。

然而下面的代码是有问题的,像后面介绍 async_hooks 模块时的代码演示的那样,一个异步资源能够一直的执行不同的函数,即异步资源有复用的可能。特地是对相似于 TCP 这种由 C/C++ 局部创立的异步资源,屡次申请可能会应用同一个 TCP 异步资源,从而使得这种状况下,屡次申请达到服务器时初始的 init 钩子函数只会执行一次,导致屡次申请的调用链追踪会追踪到同一个 triggerAsyncId,从而援用同一个存储。

咱们将后面的代码做如下批改,来进行一次验证。
存储初始化局部将 triggerAsyncId 保留下来,不便察看异步调用的追踪关系:

    if (!cache[triggerAsyncId]) {cache[triggerAsyncId] = {id: triggerAsyncId}
    }

timeout 函数改为先进行一次长耗时再进行一次短耗时操作:

function timeout () {return new Promise((resolve, reject) => {setTimeout(resolve, [1000, 5000].pop())
  })
}

重启服务后,应用 postman(不必 curl 是因为 curl 每次申请完结会敞开连贯,导致不能复现)间断的发送两次申请,能够察看到以下输入:

{id: 1, requestId: '第二次申请的 id'}
{id: 1, requestId: '第二次申请的 id'}

即可发现在多并发且写读存储的操作之间有耗时不固定的其余操作状况下,先达到服务器的申请存储的值会被后达到服务器的申请执行复写掉,使得前一次申请读取到谬误的值。当然,你能够保障在写和读之间不插入其余的耗时操作,但在简单的服务中这种靠脑力保护的保障形式显著是不牢靠的。此时,咱们就须要使每次读写前,JS 都能进入一个全新的异步资源上下文,即取得一个全新的 asyncId,防止这种复用。须要将调用链存储的局部做以下几方面批改:

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 执行的形式,使得后续操作都属于这个新的异步上下文
  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) => {
  // 将后续操作作为 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() 创立全新异步上下文的形式看起来总有些“旁门左道”的感觉。好在 NodeJs v9.x.x 版本中提供了创立异步上下文的官网实现形式 asyncResource.runInAsyncScope。更好的是,NodeJs v14.x.x 版本间接提供了异步调用链数据存储的官网实现,它会间接帮你实现异步调用关系追踪、创立新的异步上线文、治理数据这三项工作!API 就不再具体介绍,咱们间接应用新 API 革新之前的实现

const {AsyncLocalStorage} = require('async_hooks')
// 间接创立一个 asyncLocalStorage 存储实例,不再须要治理 async 生命周期钩子
const asyncLocalStorage = new AsyncLocalStorage()
const storage = {enable (callback) {
    // 应用 run 办法创立全新的存储,且须要让后续操作作为 run 办法的回调执行,以应用全新的异步资源上下文
    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)

能够看到,官网实现的 asyncLocalStorage.run API 和咱们的第二版实现在结构上也很统一。

于是,在 Node.js v14.x.x 版本下,应用 async_hooks 模块进行申请追踪的性能很轻易的就实现了。

退出移动版