今天听了 Pandora.js 的分享,其中一个比较有意思的点是他们利用 Nodejs 8 后的新特性 Async Hooks 来「隐式」的传递上下文,于是抽空稍微研究了一下。
问题
异步代码里共享数据一直以来是 JS 语言的一个问题,Java 里面可以通过 ThreadLocal 来实现,而在 JS 里我们一般是通过「显示」的传递上下文对象,比如:约定方法的第一个参数都是 ctx。
async function callService(ctx, url) {// ...}callService(ctx, url);
这样的最大的问题是:对于业务开发来说不友好、不直观,因为 ctx 可能在大多数场景下是没用的,但是一旦一个环节不按照约定传递,那么上下文信息就断了。后面我们又有通过动态实例化 Service 类的方式来尝试优化这块的体验(目前 egg 里面大量采用的方式),但是本质上还是需要显示传递 ctx 的。
class TestService {constructor(ctx) {this.ctx = ctx;}async foo(bar) {// ...}}
Async Hooks 方案
学习了官方文档以后,我尝试用 Async Hooks 方式来解决上下文传递的问题。先直接看代码:
'use strict';const fs = require('fs');const util = require('util');const assert = require('assert');const httpclient = require('urllib').create();const uuidv4 = require('uuid/v4');function createContext() {return {traceId: uuidv4(),};}function debug(...args) {// use a function like this one when debugging inside an AsyncHooks callbackfs.writeSync(1, `${util.format(...args)}\n`);}const stack = new Map();function init(id, type, triggerId, resource) {const ctx = stack.get(triggerId);if (ctx) {stack.set(id, ctx);}}function destroy(uid) {stack.delete(uid);}const async_hooks = require('async_hooks');const hook = async_hooks.createHook({init,destroy});hook.enable();async function callService(url) {await httpclient.request(url);}// 通过排监听 response 事件来 trace http 请求httpclient.on('response', info => {// 从全局 stack 中获取当前 ctxconst currentUid = async_hooks.executionAsyncId();const ctx = stack.get(currentUid);console.log(ctx.traceId, info.req.url, info.res.rt);});async function controller(ctx) {const start = Date.now();const currentUid = async_hooks.executionAsyncId();stack.set(currentUid, ctx);await Promise.all([callService('https://taobao.com'),callService('https://baidu.com'),callService('http://www.qq.com'),]);console.log(ctx.traceId, Date.now() - start);}controller(createContext());controller(createContext());controller(createContext());controller(createContext());
运行结果
$ node async_hooks.jsc7e6695a-0c15-4330-bc10-ad6aafec73f3 https://taobao.com/ 249cc662bee-9894-468e-9311-37d5d9c90692 https://taobao.com/ 249b911ccdb-3686-4844-93da-2d412c43537c https://taobao.com/ 2805ad2c48f-ac03-4ae9-be57-8a6aca2932f9 https://taobao.com/ 257cc662bee-9894-468e-9311-37d5d9c90692 https://baidu.com/ 328c7e6695a-0c15-4330-bc10-ad6aafec73f3 https://baidu.com/ 3335ad2c48f-ac03-4ae9-be57-8a6aca2932f9 https://baidu.com/ 333b911ccdb-3686-4844-93da-2d412c43537c https://baidu.com/ 3415ad2c48f-ac03-4ae9-be57-8a6aca2932f9 http://www.qq.com/ 4365ad2c48f-ac03-4ae9-be57-8a6aca2932f9 437b911ccdb-3686-4844-93da-2d412c43537c http://www.qq.com/ 478b911ccdb-3686-4844-93da-2d412c43537c 504cc662bee-9894-468e-9311-37d5d9c90692 http://www.qq.com/ 513cc662bee-9894-468e-9311-37d5d9c90692 514c7e6695a-0c15-4330-bc10-ad6aafec73f3 http://www.qq.com/ 542c7e6695a-0c15-4330-bc10-ad6aafec73f3 543
这段代码大概意思是:一个业务(controller)会调用三个异步接口,而我希望监控每个接口的调用情况,并且将它们和上下文「串」起来
我们一步一步看,通过 async_hooks 模块提供的 createHook API 我们可以添加 4 种异步回调 init()/before()/after()/destroy(),它们代表了异步调用生命周期里面四个状态,当然你可以只添加某一个或多个回调,例如这里我们只 hook 了 init 和 destroy 两个状态
const async_hooks = require('async_hooks');const hook = async_hooks.createHook({init,destroy});hook.enable();
我们添加的两个回调逻辑分别是:
在异步调用初始化时(init),我们尝试将父级(triggerId)的上下文传递到当前异步 id 上
在异步调用将被摧毁时(destroy),尝试清理当前上下文
const stack = new Map();function init(id, type, triggerId, resource) {const ctx = stack.get(triggerId);if (ctx) {stack.set(id, ctx);}}function destroy(uid) {stack.delete(uid);}
在进入 controller 以后将 ctx 保存到当前的异步 id 上
async function controller(ctx) {const start = Date.now();const currentUid = async_hooks.executionAsyncId();stack.set(currentUid, ctx);await Promise.all([callService('https://taobao.com'),callService('https://baidu.com'),callService('http://www.qq.com'),]);console.log(ctx.traceId, Date.now() - start);}
监听 httpclient 的 response 事件来获取 http 请求的数据,并且尝试从全局的 stack 中获取当前的 ctx 对象
// 通过排监听 response 事件来 trace http 请求httpclient.on('response', info => {// 从全局 stack 中获取当前 ctxconst currentUid = async_hooks.executionAsyncId();const ctx = stack.get(currentUid);console.log(ctx.traceId, info.req.url, info.res.rt);});
其他
在 Hook 函数里打印日志
这里有一个「坑」,在 Async Hooks 的 Hook 函数里 console.log 也是基于异步实现的,所以如果在 Hook 中使用 console.log 来打印信息就会出现死循环。只能使用非常原始的方式在 Hook 中输出信息,即类似上文中大家看到的直接 fs.writeSync 的方式,详细可以参考官方文档
'use strict';function init(id, type, triggerId, resource) {console.log(id);}function before(uid) {currentUid = uid;}function after(uid) {currentUid = -1;}function destroy(uid) {stack.delete(uid);}const async_hooks = require('async_hooks');const hook = async_hooks.createHook({ init, before, after, destroy });hook.enable();setTimeout(function() {console.log('executed!!');}, 1000);

其他案例
其实孝达很早以前分享 Nodejs 8 的新特性时就提到过利用 Async Hooks 来解决异步异常调用栈不完整的问题,文章地址。
API 状态
目前 Async Hooks API 的状态还是 Experimental,线上还是不推荐使用。另外在性能、稳定性方面暂时还没有相关的测试数据。

