上一小节讲解了 koa-await-breakpoint 的用法,但 koa-await-breakpoint 仍然有一个很大的缺憾,即无法记录除 routes/controllers 外的函数的执行时间(因为获取不到当前请求的 ctx)。举个通俗的例子:在一个路由的 controller 里面调用了 A ,A 调用了其他文件的 B ,B 又调用了其他文件的 C…这是非常常见的用法,但之前使用 koa-await-breakpoint 只能获取 A 的执行时间,无法获取 B 和 C 的执行时间。

根本原因在于:无法知道函数之间的调用关系,即 B 不知道是 A 调用的它,即便知道也不知道是哪次请求到来时执行的 A 调用的它。

但是,node@8.1 引入了一个黑魔法——Async Hooks。

6.2.1 Async Hooks

我们先看看 async_hooks 是什么。Node.js 官网对 async_hooks 的介绍为:

The async_hooks module provides an API to register callbacks tracking the lifetime of asynchronous resources created inside a Node.js application.

一句话概括:async_hooks 用来追踪 Node.js 中异步资源的生命周期。

我们来看段测试代码:

  1. const fs = require('fs')
  2. const async_hooks = require('async_hooks')
  3. async_hooks.createHook({
  4. init (asyncId, type, triggerAsyncId, resource) {
  5. fs.writeSync(1, `${type}(${asyncId}): trigger: ${triggerAsyncId}\n`)
  6. },
  7. destroy (asyncId) {
  8. fs.writeSync(1, `destroy: ${asyncId}\n`);
  9. }
  10. }).enable()
  11. async function A () {
  12. fs.writeSync(1, `A -> ${async_hooks.executionAsyncId()}\n`)
  13. setTimeout(() => {
  14. fs.writeSync(1, `A in setTimeout -> ${async_hooks.executionAsyncId()}\n`)
  15. B()
  16. })
  17. }
  18. async function B () {
  19. fs.writeSync(1, `B -> ${async_hooks.executionAsyncId()}\n`)
  20. process.nextTick(() => {
  21. fs.writeSync(1, `B in process.nextTick -> ${async_hooks.executionAsyncId()}\n`)
  22. C()
  23. C()
  24. })
  25. }
  26. function C () {
  27. fs.writeSync(1, `C -> ${async_hooks.executionAsyncId()}\n`)
  28. Promise.resolve().then(() => {
  29. fs.writeSync(1, `C in promise.then -> ${async_hooks.executionAsyncId()}\n`)
  30. })
  31. }
  32. fs.writeSync(1, `top level -> ${async_hooks.executionAsyncId()}\n`)
  33. A()

async_hooks.createHook 可以注册 4 个方法来跟踪所有异步资源的初始化(init)、回调之前(before)、回调之后(after)、销毁后(destroy)事件,并通过调用 .enable() 启用,调用 .disable() 关闭。

这里我们只关心异步资源的初始化和销毁的事件,并使用 fs.writeSync(1, msg) 打印到标准输出,writeSync 的第 1 个参数接收文件描述符,1 表示标准输出。为什么不使用 console.log 呢?因为 console.log 是一个异步操作,如果在 init、before、after 和 destroy 事件处理函数中出现,就会导致无限循环,同理也不能使用任何其他的异步操作。

运行该程序,打印如下:

  1. top level -> 1
  2. PROMISE(6): trigger: 1
  3. A -> 1
  4. Timeout(7): trigger: 1
  5. TIMERWRAP(8): trigger: 1
  6. A in setTimeout -> 7
  7. PROMISE(9): trigger: 7
  8. B -> 7
  9. TickObject(10): trigger: 7
  10. B in process.nextTick -> 10
  11. C -> 10
  12. PROMISE(11): trigger: 10
  13. PROMISE(12): trigger: 11
  14. C -> 10
  15. PROMISE(13): trigger: 10
  16. PROMISE(14): trigger: 13
  17. C in promise.then -> 12
  18. C in promise.then -> 14
  19. destroy: 7
  20. destroy: 10
  21. destroy: 8

这段程序的打印结果包含了很多信息,下面逐一进行解释:

  1. 为了实现对异步资源的跟踪,Node.js 对每一个函数(不论异步还是同步)提供了一个 async scope,我们可以通过调用 async_hooks.executionAsyncId() 来获取函数当前的 async scope 的 id(称为 asyncId),通过调用 async_hooks.triggerAsyncId() 来获取当前函数调用者的 asyncId。
  2. 异步资源在创建时触发 init 事件函数,init 函数中的第 1 个参数代表该异步资源的 asyncId,type 表示异步资源的类型(例如 TCPWRAP、PROMISE、Timeout、Immediate、TickObject 等等),triggerAsyncId 表示该异步资源的调用者的 asyncId。异步资源在销毁时触发 destroy 事件函数,该函数只接收一个参数,即该异步资源的 asyncId。
  3. 函数调用关系明确。我们通过上面的打印结果可以很容易地看出(从下往上看) :C(asyncId: 10)被 B(asyncId: 7)调用,B(asyncId: 7)被 A(asyncId: 1)调用。而且 C 的 promise.then 里面的 asyncId(值为 12/14)也可以通过 12/14 -> 11/13 -> 10 定位到 C 的 asyncId(值为 10)。
  4. 同步函数每次调用的 asyncId 都一样,如上所示,C 调用了两次,都打印了 C -> 10,与调用方的作用域的 asyncId 一致,即如上所示打印的 B in process.nextTick -> 10。异步函数每次调用的 asyncId 都不一样,即如上所示打印的 C in promise.then -> 12 和 C in promise.then -> 14。
  5. 最外层作用域的 asyncId 总是 1,每个异步资源在创建时 asyncId 全局递增。

上面 5 条结论非常重要。接下来我们看看如何使用 async_hooks 改造 koa-await-breakpoint。

6.2.2 改造 koa-await-breakpoint

我们通过前面的结论已经知道,使用 async_hooks 时可以通过 asyncId 串起函数的调用关系,但是如何将这些函数的调用链与 koa 接收的每个请求关联起来呢?

首先,定义一个全局 Map,存储函数的调用关系:

  1. const async_hooks = require('async_hooks')
  2. const asyncIdMap = new Map()
  3. async_hooks.createHook({
  4. init (asyncId, type, triggerAsyncId) {
  5. const ctx = getCtx(triggerAsyncId)
  6. if (ctx) {
  7. asyncIdMap.set(asyncId, ctx)
  8. } else {
  9. asyncIdMap.set(asyncId, triggerAsyncId)
  10. }
  11. },
  12. destroy (asyncId) {
  13. asyncIdMap.delete(asyncId)
  14. }
  15. }).enable()
  16. function getCtx (asyncId) {
  17. if (!asyncId) {
  18. return
  19. }
  20. if (typeof asyncId === 'object' && asyncId.app) {
  21. return asyncId
  22. }
  23. return getCtx(asyncIdMap.get(asyncId))
  24. }

有以下三点需要解释:

  1. 定义了一个全局 Map 来存储函数的调用关系,在适当的地方(下面会讲到)将当前请求的 ctx 存储到 Map 中,key 是 asyncId。
  2. 每个异步资源在初始化时,会尝试通过 asyncId 向上寻找祖先的 value 是否是 ctx(koa 应用中每个请求的 ctx),如果有,则直接将 value 设置为 ctx,否则将 value 设置为调用者的 asyncId(即 triggerAsyncId)。
  3. 在 destroy 事件函数里直接删除调用关系,保证了不会引起内存泄漏,即杜绝引用了 ctx 但没有释放的情况。

然后,修改 global[loggerName] 如下:

  1. global[loggerName] = async function (ctx, fn, fnStr, filename) {
  2. const originalContext = ctx
  3. let requestId = _getRequestId()
  4. const asyncId = async_hooks.executionAsyncId()
  5. if (!requestId) {
  6. const _ctx = getCtx(asyncId)
  7. if (_ctx) {
  8. ctx = _ctx
  9. requestId = _getRequestId()
  10. }
  11. } else {
  12. asyncIdMap.set(asyncId, ctx)
  13. }
  14. if (requestId) {
  15. _logger('beforeAwait')
  16. }
  17. const result = await fn.call(originalContext)
  18. if (requestId) {
  19. _logger('afterAwait', result)
  20. }
  21. return result
  22. function _getRequestId () {
  23. return ctx && ctx.app && _.get(ctx, requestIdPath)
  24. }
  25. function _logger (type, result) {
  26. ...
  27. }
  28. }

有以下两点需要解释:

  1. logger 函数传入的第 1 个参数 ctx,之前是每个请求的 ctx,现在可能是当前执行上下文的 this,所以先将 ctx 赋值给 originalContext,然后通过 await fn.call(originalContext) 让函数在执行时有正确的上下文。
  2. 如果传入的 ctx 是来自请求的 ctx 且能拿到 requestId,那么将当前 asyncId 和 ctx 写入 Map,如果不是来自请求的 ctx,则尝试从 Map 里向上寻找祖先的 value 是否是 ctx,如果找到,则覆盖当前的 ctx 并拿到 requestId。

至此,koa-await-breakpoint 全部改造完毕。接下来我们通过一个例子验证下升级后的 koa-await-breakpoint:

app.js

  1. const koaAwaitBreakpoint = require('koa-await-breakpoint')({
  2. files: ['./routes/*.js']
  3. })
  4. const Paloma = require('paloma')
  5. const app = new Paloma()
  6. app.use(koaAwaitBreakpoint)
  7. app.route({ method: 'POST', path: '/users', controller: require('./routes/user').createUser })
  8. app.listen(3000)

routes/users.js

  1. const Mongolass = require('mongolass')
  2. const mongolass = new Mongolass('mongodb://localhost:27017/test')
  3. const User = mongolass.model('User')
  4. const Post = mongolass.model('Post')
  5. const Comment = mongolass.model('Comment')
  6. exports.createUser = async function (ctx) {
  7. const name = ctx.query.name || 'default'
  8. const age = +ctx.query.age || 18
  9. await createUser(name, age)
  10. ctx.status = 204
  11. }
  12. async function createUser (name, age) {
  13. const user = (await User.create({
  14. name,
  15. age
  16. })).ops[0]
  17. await createPost(user)
  18. }
  19. async function createPost (user) {
  20. const post = (await Post.create({
  21. uid: user._id,
  22. title: 'post',
  23. content: 'post'
  24. })).ops[0]
  25. await createComment(user, post)
  26. }
  27. async function createComment (user, post) {
  28. await Comment.create({
  29. userId: user._id,
  30. postId: post._id,
  31. content: 'comment'
  32. })
  33. }

这段代码的意思是:在访问创建用户接口时,调用 createUser,createUser 里面又调用了 createPost,createPost 里面又调用了 createComment。运行:

  1. $ curl -XPOST localhost:3000/users

打印如下:

  1. { type: 'start',
  2. step: 1,
  3. take: 0 ... }
  4. { type: 'beforeAwait',
  5. step: 2,
  6. fn: 'createUser(name, age)',
  7. take: 1 ... }
  8. { type: 'beforeAwait',
  9. step: 3,
  10. fn: 'User.create(...)',
  11. take: 1 ... }
  12. { type: 'afterAwait',
  13. step: 4,
  14. fn: 'User.create(...)',
  15. take: 36 ... }
  16. { type: 'beforeAwait',
  17. step: 5,
  18. fn: 'createPost(user)',
  19. take: 1 ... }
  20. { type: 'beforeAwait',
  21. step: 6,
  22. fn: 'Post.create(...)',
  23. take: 0 ... }
  24. { type: 'afterAwait',
  25. step: 7,
  26. fn: 'Post.create(...)',
  27. take: 3 ... }
  28. { type: 'beforeAwait',
  29. step: 8,
  30. fn: 'createComment(user, post)',
  31. take: 1 ... }
  32. { type: 'beforeAwait',
  33. step: 9,
  34. fn: 'Comment.create(...)',
  35. take: 0 ... }
  36. { type: 'afterAwait',
  37. step: 10,
  38. fn: 'Comment.create(...)',
  39. take: 1 ... }
  40. { type: 'afterAwait',
  41. step: 11,
  42. fn: 'createComment(user, post)',
  43. take: 1 ... }
  44. { type: 'afterAwait',
  45. step: 12,
  46. fn: 'createPost(user)',
  47. take: 6 ... }
  48. { type: 'afterAwait',
  49. step: 13,
  50. fn: 'createUser(name, age)',
  51. take: 44 ... }
  52. { type: 'end',
  53. step: 14,
  54. take: 0 ... }

至此,一个全链路、无侵入、强大的日志打点工具就完成了。

注意:使用 async_hooks 在目前有较严重的性能损耗,见 https://github.com/bmeurer/async-hooks-performance-impact,请慎重在生产环境中使用。

6.2.3 参考链接

上一节:6.1 koa-await-breakpoint

下一节:6.3 ELK