今天听了 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 callback
fs.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 中获取当前 ctx
const 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.js
c7e6695a-0c15-4330-bc10-ad6aafec73f3 https://taobao.com/ 249
cc662bee-9894-468e-9311-37d5d9c90692 https://taobao.com/ 249
b911ccdb-3686-4844-93da-2d412c43537c https://taobao.com/ 280
5ad2c48f-ac03-4ae9-be57-8a6aca2932f9 https://taobao.com/ 257
cc662bee-9894-468e-9311-37d5d9c90692 https://baidu.com/ 328
c7e6695a-0c15-4330-bc10-ad6aafec73f3 https://baidu.com/ 333
5ad2c48f-ac03-4ae9-be57-8a6aca2932f9 https://baidu.com/ 333
b911ccdb-3686-4844-93da-2d412c43537c https://baidu.com/ 341
5ad2c48f-ac03-4ae9-be57-8a6aca2932f9 http://www.qq.com/ 436
5ad2c48f-ac03-4ae9-be57-8a6aca2932f9 437
b911ccdb-3686-4844-93da-2d412c43537c http://www.qq.com/ 478
b911ccdb-3686-4844-93da-2d412c43537c 504
cc662bee-9894-468e-9311-37d5d9c90692 http://www.qq.com/ 513
cc662bee-9894-468e-9311-37d5d9c90692 514
c7e6695a-0c15-4330-bc10-ad6aafec73f3 http://www.qq.com/ 542
c7e6695a-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 中获取当前 ctx
const 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,线上还是不推荐使用。另外在性能、稳定性方面暂时还没有相关的测试数据。