你听说过 Node.js 的`async hooks` [1]模块吗?如果没有,那你应该了解一下。
尽管它是与 Node.js 9 一起发布的新特性,但是因为该模块仍处于测试阶段,我并不建议将其用于生产环境,不过你仍然应该对它有所了解。
简而言之,Node.js 中的异步挂钩,具体来说是 async_hooks 模块,提供了一个清晰易用的 API 去追踪 Node.js 中的异步资源。
该 API 最简单的使用方式就是用 JS 中的 require import:
- const async_hooks = require('async_hooks');
我们在这里讨论的异步特性指的是Node.js创建的具有关联回调的对象,与回调可能被调用多少次没有关系。这就有很多种类了例如:Promises、创建服务的操作、超时等。
请记住,大多数语言都可以关闭资源。其中一些通过容器关闭,其他的则是通过语言本身关闭。所以你的回调函数可能自始至终都没有被调用过。但是没有关系,AsyncHook 不会区分这些不同的情况。
这篇文章的目的是为了更深入的探讨hooks,并且尝试通过一些示例帮助你更深入的理解。准备好了吗?
👋 在探索异步挂钩时,你可能还希望了解 AppSignal forNode.js[2]。我们为你提供对Node.js Core,Express,Next.js,Apollo Server,node-postgres和node-redis的现成支持[3]。
API使用
我总是觉得官方文档过于复杂以及苛刻。这就是为什么我通常会选择传统、友好的博客文章。
让我们首先了解一下Async Hooks API提供的 5 个可用事件函数:
- init: 顾名思义,当特定的异步资源初始化时会调用它。仅作记录,此时,我们已经将钩子与异步资源相关联。
- before 和 after: 这与普通语言中的函数的执行前和执行后非常相似。在资源执行之前和之后分别调用它们。
- destroy: 很明显,无论资源的回调函数发生了什么,只要资源被销毁就会调用它。
- promiseResolve: promiseResolve与Promise有关,当你的Promise调用它的 resolve 函数时,挂钩就会触发此函数。
非常的简单直接,接下来让我们看一个基本的例子:
- const myFirstAsyncHook = async_hooks.createHook({ init, before, after, destroy, promiseResolve });
是的,你必须先创建每个事件函数,然后再将其分配给createHook函数。另外,必须显式启用该挂钩:
- myFirstAsyncHook.enable();
让我们继续看一个更加完整的例子:
- const fs = require("fs");
- const async_hooks = require("async_hooks");
- // Sync write to the console
- const writeSomething = (phase, more) => {
- fs.writeSync(
- 1,
- `Phase: "${phase}", Exec. Id: ${async_hooks.executionAsyncId()} ${
- more ? ", " + more : ""
- }\n`
- );
- };
- // Create and enable the hook
- const timeoutHook = async_hooks.createHook({
- init(asyncId, type, triggerAsyncId) {
- writeSomething(
- "Init",
- `asyncId: ${asyncId}, type: "${type}", triggerAsyncId: ${triggerAsyncId}`
- );
- },
- before(asyncId) {
- writeSomething("Before", `asyncId: ${asyncId}`);
- },
- destroy(asyncId) {
- writeSomething("Destroy", `asyncId: ${asyncId}`);
- },
- after(asyncId) {
- writeSomething("After", `asyncId: ${asyncId}`);
- },
- });
- timeoutHook.enable();
- writeSomething("Before call");
- // Set the timeout
- setTimeout(() => {
- writeSomething("Exec. Timeout");
- }, 1000);
这个例子通过众所周知的原生函数 setTimeout 去追踪超时的异步执行过程。
在我们深入研究之前,先快速浏览一下第一个函数 writeSomething 。你也许很好奇为什么在我们已经有函数可以在控制台输出的情况下仍然创建了一个新的函数去完成相同的功能。
原因是你不能使用任何 console 函数去测试异步钩子,因为它们本身就是异步的。因此当我们在下面提供了一个 init 函数时,它会产生一个无限循环。该函数会调用 console 的 log ,此日志又会再次触发初始化,以此类推,陷入死循环。
这就是为什么我们需要重新写一个“同步”日志功能。
好了,现在我们回过头去看代码。我们的异步钩子提供了四个功能:init、 before、 after 以及 destory。而且,我们还在超时之前和执行期间打印一条消息,所以你可以看到整个过程是如何线性进行的。
在你的命令行执行 node index.js,你会得到如下图所示的结果:
观察下钩子是如何一步一步执行追踪的。看起来是一种很有趣的跟踪方式,尤其是当你考虑将数据输入到监视工具中或者是你已经使用的日志追踪工具。
一个Promise例子
让我们看看我们的示例在Promise下的执行效果。思考下面这些代码片段:
- const calcPow = async(n, exp) => {
- writeSomething("Exec. Promise");
- return Math.pow(n, exp);
- };
- (async() => {
- await calcPow(3, 4);
- })();
你也可以用之前的 setTimeout 示例来替代这个例子。在这段代码中,我们有一个异步函数用来进行幂运算。同时也有一个相同的函数在异步块中被调用。到目前为止,Node.js创建了两个Promise。
下图是日志记录的结果:
奇怪的是,我们有两个Promise,却调用了三次 init 函数。不用担心,这是因为Node.js团队在版本12中引入了异步执行性能方面的一些最新改进。你可以点击此处[4]了解更多信息。
尽管如此,执行过程依然符合我们的预期。
解析:钩子函数的性能与度量
Node.js提供的另一个非常有趣的API是性能评估API[5],既然我们在这里讨论度量,为什么不结合两者的功能来了解我们可以收获什么呢?
可以通过 perf_hooks 获得该API,该API让我们能够用与W3C Web Performance API[6]相似的方式来获得性能/用户时间轴指标。
将它与异步钩子相结合我们可以做一些事情,比如追踪异步函数执行完毕需要的时间。让我们看另外一个例子:
- const async_hooks = require("async_hooks");
- const {
- performance,
- PerformanceObserver
- } = require("perf_hooks");
- const hook = async_hooks.createHook({
- init(asyncId) {
- performance.mark(`init-${asyncId}`);
- },
- destroy(asyncId) {
- performance.mark(`destroy-${asyncId}`);
- performance.measure(
- `entry-${asyncId}`,
- `init-${asyncId}`,
- `destroy-${asyncId}`
- );
- },
- });
- hook.enable();
- const observer = new PerformanceObserver((data) =>
- console.log(data.getEntries())
- );
- observer.observe({
- entryTypes: ["measure"],
- buffered: true
- });
- setTimeout(() => {
- console.log("I'm a timeout");
- }, 1200);
既然我们只是追踪记录执行时间,就没有必要用之前用的中间事件函数。用 init 和 destroy 就足够了。
就像异步钩子那样,性能API通过创建观察者来工作。不过,无论什么时候开始或者结束,你都必须明确标记每个事件的id。这样,当我们调用API的 measure 函数时,它将汇总收集到的数据并将其立即发送给观察者,观察者将为我们记录全部的日志。
注意了,这里我们使用了两次 console.log 函数。第一次是无影响的因为它包含在观察者中执行。但是第二次它在 setTimeout 函数中执行,另一个异步中的异步,这意味着在最后它会产生不同的输出。
下图是日志记录:
本示例本并没有考虑事件类型之间的差异。在这里,我们在同一测量场景中发生了超时和异步日志操作。
但是,考虑到生产环境,建议你创建一个更强大的机制在每次调用 init 时存储事件类型,并在稍后调用 destroy 函数,倒霉的没有接收到参数类型时检查存储是否依然存在。
异步资源
Async Hooks中的另一个有用功能是 `AsyncResource`[7] 类。每当你为框架或库创建自己的资源时,它都会为你提供帮助。
只需输入以下代码即可使用:
- const AsyncResource = require('async_hooks').AsyncResource;
用这种方式,你可以使用它实例化一个新对象,并手动定义其每个阶段在整个代码中何时开始。举个例子:
- const resource = new AsyncResource('MyOwnResource');
- someFunction(function someCallback() {
- resource.emitBefore();
- // do your stuff...
- resource.emitAfter();
- });
- someOnClose() {
- resource.emitDestroy();
- }
这仍是资源生命周期的一个示例,如果要绑定本地的C++代码,我们更建议使用它。我将为你提供官方文档中的一个很好的例子[8]来简化它。
结论
就像我们讨论的那样,异步钩子仍处于实验阶段。因此,要谨慎使用它。
由于 hooks 仅在 Node.js 8 及更高版本中可用,因此你可以考虑迁移 Node.js 版本(很多时候这是不太合适的方法)或使用社区中的替代工具,例如 async-tracer[9]。