今天听了 Pandora.js 的分享,其中一个比较有意思的点是他们利用 Nodejs 8 后的新特性 Async Hooks 来「隐式」的传递上下文,于是抽空稍微研究了一下。

问题

异步代码里共享数据一直以来是 JS 语言的一个问题,Java 里面可以通过 ThreadLocal 来实现,而在 JS 里我们一般是通过「显示」的传递上下文对象,比如:约定方法的第一个参数都是 ctx。

  1. async function callService(ctx, url) {
  2. // ...
  3. }
  4. callService(ctx, url);

这样的最大的问题是:对于业务开发来说不友好、不直观,因为 ctx 可能在大多数场景下是没用的,但是一旦一个环节不按照约定传递,那么上下文信息就断了。后面我们又有通过动态实例化 Service 类的方式来尝试优化这块的体验(目前 egg 里面大量采用的方式),但是本质上还是需要显示传递 ctx 的。

  1. class TestService {
  2. constructor(ctx) {
  3. this.ctx = ctx;
  4. }
  5. async foo(bar) {
  6. // ...
  7. }
  8. }

Async Hooks 方案

学习了官方文档以后,我尝试用 Async Hooks 方式来解决上下文传递的问题。先直接看代码:

  1. 'use strict';
  2. const fs = require('fs');
  3. const util = require('util');
  4. const assert = require('assert');
  5. const httpclient = require('urllib').create();
  6. const uuidv4 = require('uuid/v4');
  7. function createContext() {
  8. return {
  9. traceId: uuidv4(),
  10. };
  11. }
  12. function debug(...args) {
  13. // use a function like this one when debugging inside an AsyncHooks callback
  14. fs.writeSync(1, `${util.format(...args)}\n`);
  15. }
  16. const stack = new Map();
  17. function init(id, type, triggerId, resource) {
  18. const ctx = stack.get(triggerId);
  19. if (ctx) {
  20. stack.set(id, ctx);
  21. }
  22. }
  23. function destroy(uid) {
  24. stack.delete(uid);
  25. }
  26. const async_hooks = require('async_hooks');
  27. const hook = async_hooks.createHook({
  28. init,
  29. destroy
  30. });
  31. hook.enable();
  32. async function callService(url) {
  33. await httpclient.request(url);
  34. }
  35. // 通过排监听 response 事件来 trace http 请求
  36. httpclient.on('response', info => {
  37. // 从全局 stack 中获取当前 ctx
  38. const currentUid = async_hooks.executionAsyncId();
  39. const ctx = stack.get(currentUid);
  40. console.log(ctx.traceId, info.req.url, info.res.rt);
  41. });
  42. async function controller(ctx) {
  43. const start = Date.now();
  44. const currentUid = async_hooks.executionAsyncId();
  45. stack.set(currentUid, ctx);
  46. await Promise.all([
  47. callService('https://taobao.com'),
  48. callService('https://baidu.com'),
  49. callService('http://www.qq.com'),
  50. ]);
  51. console.log(ctx.traceId, Date.now() - start);
  52. }
  53. controller(createContext());
  54. controller(createContext());
  55. controller(createContext());
  56. controller(createContext());

运行结果

  1. $ node async_hooks.js
  2. c7e6695a-0c15-4330-bc10-ad6aafec73f3 https://taobao.com/ 249
  3. cc662bee-9894-468e-9311-37d5d9c90692 https://taobao.com/ 249
  4. b911ccdb-3686-4844-93da-2d412c43537c https://taobao.com/ 280
  5. 5ad2c48f-ac03-4ae9-be57-8a6aca2932f9 https://taobao.com/ 257
  6. cc662bee-9894-468e-9311-37d5d9c90692 https://baidu.com/ 328
  7. c7e6695a-0c15-4330-bc10-ad6aafec73f3 https://baidu.com/ 333
  8. 5ad2c48f-ac03-4ae9-be57-8a6aca2932f9 https://baidu.com/ 333
  9. b911ccdb-3686-4844-93da-2d412c43537c https://baidu.com/ 341
  10. 5ad2c48f-ac03-4ae9-be57-8a6aca2932f9 http://www.qq.com/ 436
  11. 5ad2c48f-ac03-4ae9-be57-8a6aca2932f9 437
  12. b911ccdb-3686-4844-93da-2d412c43537c http://www.qq.com/ 478
  13. b911ccdb-3686-4844-93da-2d412c43537c 504
  14. cc662bee-9894-468e-9311-37d5d9c90692 http://www.qq.com/ 513
  15. cc662bee-9894-468e-9311-37d5d9c90692 514
  16. c7e6695a-0c15-4330-bc10-ad6aafec73f3 http://www.qq.com/ 542
  17. c7e6695a-0c15-4330-bc10-ad6aafec73f3 543

这段代码大概意思是:一个业务(controller)会调用三个异步接口,而我希望监控每个接口的调用情况,并且将它们和上下文「串」起来

我们一步一步看,通过 async_hooks 模块提供的 createHook API 我们可以添加 4 种异步回调 init()/before()/after()/destroy(),它们代表了异步调用生命周期里面四个状态,当然你可以只添加某一个或多个回调,例如这里我们只 hook 了 init 和 destroy 两个状态

  1. const async_hooks = require('async_hooks');
  2. const hook = async_hooks.createHook({
  3. init,
  4. destroy
  5. });
  6. hook.enable();

我们添加的两个回调逻辑分别是:

  • 在异步调用初始化时(init),我们尝试将父级(triggerId)的上下文传递到当前异步 id 上

  • 在异步调用将被摧毁时(destroy),尝试清理当前上下文

  1. const stack = new Map();
  2. function init(id, type, triggerId, resource) {
  3. const ctx = stack.get(triggerId);
  4. if (ctx) {
  5. stack.set(id, ctx);
  6. }
  7. }
  8. function destroy(uid) {
  9. stack.delete(uid);
  10. }

在进入 controller 以后将 ctx 保存到当前的异步 id 上

  1. async function controller(ctx) {
  2. const start = Date.now();
  3. const currentUid = async_hooks.executionAsyncId();
  4. stack.set(currentUid, ctx);
  5. await Promise.all([
  6. callService('https://taobao.com'),
  7. callService('https://baidu.com'),
  8. callService('http://www.qq.com'),
  9. ]);
  10. console.log(ctx.traceId, Date.now() - start);
  11. }

监听 httpclient 的 response 事件来获取 http 请求的数据,并且尝试从全局的 stack 中获取当前的 ctx 对象

  1. // 通过排监听 response 事件来 trace http 请求
  2. httpclient.on('response', info => {
  3. // 从全局 stack 中获取当前 ctx
  4. const currentUid = async_hooks.executionAsyncId();
  5. const ctx = stack.get(currentUid);
  6. console.log(ctx.traceId, info.req.url, info.res.rt);
  7. });

其他

在 Hook 函数里打印日志

这里有一个「坑」,在 Async Hooks 的 Hook 函数里 console.log 也是基于异步实现的,所以如果在 Hook 中使用 console.log 来打印信息就会出现死循环。只能使用非常原始的方式在 Hook 中输出信息,即类似上文中大家看到的直接 fs.writeSync 的方式,详细可以参考官方文档

  1. 'use strict';
  2. function init(id, type, triggerId, resource) {
  3. console.log(id);
  4. }
  5. function before(uid) {
  6. currentUid = uid;
  7. }
  8. function after(uid) {
  9. currentUid = -1;
  10. }
  11. function destroy(uid) {
  12. stack.delete(uid);
  13. }
  14. const async_hooks = require('async_hooks');
  15. const hook = async_hooks.createHook({ init, before, after, destroy });
  16. hook.enable();
  17. setTimeout(function() {
  18. console.log('executed!!');
  19. }, 1000);

Async Hooks 初探 - 图1

其他案例

其实孝达很早以前分享 Nodejs 8 的新特性时就提到过利用 Async Hooks 来解决异步异常调用栈不完整的问题,文章地址

API 状态

目前 Async Hooks API 的状态还是 Experimental,线上还是不推荐使用。另外在性能、稳定性方面暂时还没有相关的测试数据。

Async Hooks 初探 - 图2