精华 端午节后福利:Node.js 8
发布于 7 年前 作者 pmq20 13269 次浏览 来自 分享

端午节结束了。虽然接下来的四个月都没有节假日,但笔者一点都不烦恼。因为 Node.js 8 在端午后第一个工作日就正式发布,这足以让我与 Node.js 的激情燃烧一个夏天!本文挑选了笔者认为 Node.js 8 最令人兴奋的四大新功能,与大家分享。

async/await 与 util.promisify

Node.js 一直以来的关键设计就是把用户关在一个“异步编程的监狱”里,以换取非阻塞 I/O 的高性能,让用户轻易开发出高度可扩展的网络服务器。这从 Node.js 的 API 设计上就可见一斑,很多API——如 fs.open(path, flags[, mode], callback)——要求用户必须把该操作执行成功后的逻辑放在最后参数里,作为函数传递进去;而 fs.open 本身是立即返回的,用户不能把依赖于 fs.open 结果的逻辑与 fs.open 本身线性地串联起来。

在这座“异步编程的监狱”里,不掌握异步编程就寸步难行。而我们习惯性地使用线性思维去思考业务问题,却在实现的时候,被迫把业务逻辑被切成很多小片段去书写,就是一件很痛苦的事情了。为了减轻异步编程的痛苦,几年间我们见证了数个解决方案的出现:从深度嵌套的回调金字塔,到带有长长的 then() 链条的 Promise 设计模式,再到 Generator 函数,到如今 Node.js 8 的 async/await 操作符。笔者认为,所有这些解决方案中,async/await 操作符是最接近命令式编程风格的,使用起来最为自然的。

brains.jpg

例如我们想先创建一个文件,再读取、输出它的大小,只需三行代码:

await writeFile('a_new_file.txt', 'hello');
let result = await stat('a_new_file.txt');
console.log(result.size);

这简直是最简单的异步编程了!我们用自然、流畅的代码表达了线性业务逻辑,同时还得到了 Node.js 非阻塞 I/O 带来的高性能,简直是兼得了鱼和熊掌。

但别着急,这段代码不是立即就可以执行的,细心的读者肯定会问:例子中的 writeFile 和 stat 分别是什么?其实它们就是标准库的 fs.writeFile 和 fs.stat,但又不完全是。这是因为 async 和 await 本质上是对 Promise 设计模式的封装,一般情况下 await 的参数应是一个返回 Promise 对象的函数。而 fs.writeFile 和 fs.stat 这些标准库 API 没有返回值(返回 undefined),需要一个方法把他们包装成返回 Promise 对象的函数。

但总不能一个一个包装去吧,这样工作量堪比重写标准库了。幸好,我们观察到所有这些标准库 API 基本都满足一个共同特征:它们都是用最后一个参数来接受一个类似“ (err, value) => … ”的回调函数。于是我们就可以用一个 API 把几乎所有标准库 API 都转换为返回 Promise 对象的函数。这就是 util.promisify。利用 util.promisify,我们可以添加以下代码:

const util = require('util');
const fs = require('fs');
const writeFile = util.promisify(fs.writeFile);
const stat = util.promisify(fs.stat);

若没有 util.promisify,async/await 是很难用的,因为它们需要配合 Promise 一起使用,而之前很多库函数又不返回 Promise。笔者认为 async/await 运算符和 util.promisify 的绝配,是 Node.js 8 最大的亮点。

以上示例的完整代码如下:

const util = require('util');
const fs = require('fs');
const writeFile = util.promisify(fs.writeFile);
const stat = util.promisify(fs.stat);

(async function () {
  await writeFile('a_new_file.txt', 'hello');
  let result = await stat('a_new_file.txt');
  console.log(result.size);
})();

Async Hooks

调试过 Node.js 的小伙伴都知道,Node.js 一个很大的弱点就是——出错时调用栈不完整。这也是“异步编程的监狱”的设计带来的另一个缺点,因为在异步编程下,我们的代码被切成了无数个小片段,报错时只能得到一个小片段的调用栈,而全局的来龙去脉却看不到,用户只能推测是何处代码触发了何种事件导致执行了小片段,再不断往前推演。

举一个简单的例子:

function IWantFullCallbacks() {
  setTimeout(function() {
    const localStack = new Error();
    console.log(localStack.stack);
  }, 1000);
}

IWantFullCallbacks();

在这个例子中,我们模拟了 setTimeout 内出错时打印调用栈的情景。将它存为 1.js 并执行,我们期望,如果调用栈能包含外层的 IWantFullCallbacks(),并打印其行号 8,定是极好的,因为那样对我们排查错误很有帮助。但现实中却并非如此,调用栈只有四行,行号顶多打印到了第 3 行的报错本身,我们根本看不出来是第 8 行触发了这个错误。因为第 8 行作为异步调用成功地结束了,它才不关心“后事如何”。

Error
    at Timeout._onTimeout (/Users/pmq20/1.js:3:24)
    at ontimeout (timers.js:488:11)
    at tryOnTimeout (timers.js:323:5)
    at Timer.listOnTimeout (timers.js:283:5)

而 Node.js 8 中新增的 Async Hooks 功能就可以解决这个问题。Node.js 8 中添加了四种 Async Hooks 回调,它们可以跟踪 Node.js 的所有异步资源的生命周期。这里所谓的资源是指 Node.js 底层 libuv 中的各类短期请求和长期句柄,如本例中的定时器,就是这样一个异步资源。这四种回调分别涵盖了这些异步资源的创建、回调前、回调后、销毁这四个生命阶段。

通过自定义这四种回调函数,我们就可以跨调用栈来做事件追踪,我们可以先做一个 Map 容器放在回调函数的闭包里,用来作为异步资源 ID 到调试信息的映射,并在异步资源的创建时进行调试信息的累积。闭包里再声明一个 currentUid 表示目前正在执行的异步资源 ID,于回调前、回调后两个生命阶段的时机进行记录。这样下来,第 8 行执行 IWantFullCallbacks() 的时候创建的异步资源的 ID,与后期定时器到期自行回调的异步资源的 ID,是同一个 ID,因而可以起到跨调用栈累积调试信息的作用。我们通过 Node.js 8 的 async_hooks 的 createHook API 来创建回调,并通过 enable() 方法注册并执行,代码如下:

const stack = new Map();
stack.set(-1, '');
let currentUid = -1;

function init(id, type, triggerId, resource) {
  const localStack = (new Error()).stack.split('\n').slice(1).join('\n');
  const extraStack = stack.get(triggerId || currentUid);
  stack.set(id, localStack + '\n' + extraStack);
}
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();

最后我们修改定时器的回调内容,让它输出 Map 中累积的调试信息:

function IWantFullCallbacks() {
  setTimeout(function() {
    const localStack = new Error();
    console.log(localStack.stack);
    console.log('---');
    console.log(stack.get(currentUid));
  }, 1000);
}

这次的效果如下:

Error
    at Timeout._onTimeout (/Users/pmq20/2.js:26:24)
    at ontimeout (timers.js:488:11)
    at tryOnTimeout (timers.js:323:5)
    at Timer.listOnTimeout (timers.js:283:5)
---
    at init (/Users/pmq20/2.js:6:23)
    at runInitCallback (async_hooks.js:459:5)
    at emitInitS (async_hooks.js:327:7)
    at new Timeout (timers.js:592:5)
    at createSingleTimeout (timers.js:472:15)
    at setTimeout (timers.js:456:10)
    at IWantFullCallbacks (/Users/pmq20/2.js:25:3)
    at Object.<anonymous> (/Users/pmq20/2.js:33:1)
    at Module._compile (module.js:569:30)
    at Object.Module._extensions..js (module.js:580:10)

可见,我们以同一个异步资源的 ID 为线索,把两次的调用栈都完整保留了。

但这只是 Node.js 8 的 Async Hooks 的用途之一,有了这个功能,我们甚至可以来测量一些事件各个阶段所花费的时间。只要我们有异步资源 ID 这枚钥匙,配合回调函数,就可以在事件循环的多个周期那看似毫无头绪的执行过程中,筛选出有用的信息。

Node.js API (N-API)

经历过 Node.js 大版本升级的同学肯定会发现,每次升级后我们都得重新编译像 node-sass 这种用 C++ 写的扩展模块,否则会遇到下面这样的报错,

Error: The module 'node-sass'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 51. This version of Node.js requires
NODE_MODULE_VERSION 55. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).

NODE_MODULE_VERSION 是每一个 Node.js 版本内人为设定的数值,意思为 ABI 的版本号。一旦这个号码与已经编译好的二进制模块的号码不符,便判断为 ABI 不兼容,需要用户重新编译。

这其实是一个工程难题,亦即 Node.js 上游的代码变化如何最小地降低对 C++ 模块的影响,从而维持一个良好的向下兼容的模块生态系统。最坏的情况下,每次发布 Node.js 新版本,因为 API 的变化,C++ 模块的作者都要修改它们的源代码,而那些不再有人维护或作者失联的老模块就会无法继续使用,在作者修改代码之前社区就失去了这些模块的可用性。其次坏的情况是,每次发布 Node.js 新版本,虽然 API 保持兼容使得 C++ 模块的作者不需要修改他们的代码,但 ABI 的变化导致必须这些模块必须重新编译。而最好的情况就是,Node.js 新版本发布后,所有已编译的 C++ 模块可以继续正常工作,完全不需要任何人工干预。

Node.js Compiler 也面临同样的问题,之前 nodec 强制用户编译环境中的 Node.js 版本与编译器的内置 Node.js 版本一致,就是为了消除编译时与运行时 C++ 模块的版本不兼容问题,但这也给用户带来了使用的不便。见: https://github.com/pmq20/node-compiler/issues/27 如果能做到上述最好的情况,那么这个问题也就完美解决了。

Node.js 8 的 Node.js API (N-API) 就是为了解决这个问题,做到上述最好的情况,为 Node.js 模块生态系统的长期发展铺平道路。N-API 追求以下目标:

  1. 有稳定的 ABI
  2. 抽象消除 Node.js 版本之间的接口差异
  3. 抽象消除 V8 版本之间的接口差异
  4. 抽象消除 V8 与其他 JS 引擎(如 ChakraCore)之间的接口差异

笔者观察到,N-API 采取以下手段达到上述目标:

  1. 采用 C 语言头文件而不是 C++,消除 Name Mangling 以便最小化一个稳定的 ABI 接口
  2. 不使用 V8 的任何数据类型,所有 JavaScript 数据类型变成了不透明的 napi_value
  3. 重新设计了异常管理 API,所有 N-API 都返回 napi_status,通过统一的手段处理异常
  4. 重新了设计对象的生命周期 API,通过 napi_open_handle_scope 等 API 替代了 v8 的 Scope 设计

N-API 目前在 Node.js 8 仍是实验阶段的功能,需要配合命令行参数 --napi-modules 使用。

TurboFan 与 Ignition (TF+I)

Node.js 8 得益于将 v8 升级到了 5.8,引入了 TurboFan 与 Ignition 的支持。关于 Ignition 的详细介绍,请见 https://cnodejs.org/topic/59084a9cbbaf2f3f569be482

前面已经提到,如今借助 Node.js 8 我们可以用 await/async 书写程序,但并未提到异常处理,其实 await/async 的异常处理多借助 try/catch 配合使用。而在以前的 Node.js 版本中,try/catch 是个昂贵的操作,性能并不高。这主要是由于 v8 内老的 Crankshaft 不易优化这些 ES5 的新语法。但随着 TF+I 新架构的引入,try/catch 的写法也可以得到优化,作为用户就可以高枕无忧的使用 await/async + try/catch 了。

目前 Node.js 8 内的 v8 版本仅更新到了 5.8,TF+I 需要配合命令行参数 --turbo --ignition 使用。一旦升级到 v8 5.9,TF+I 将会默认开启。

16 回复

好消息,顶一个!

Async Hooks这个trace机制挺有意思的,加精

配图满分 .

mark一下,async hooks特性之后肯定会用到,现在记录报错没有完整输出太蛋疼了

早点解决了import啊

嗯。async hooks特性 也是不错的东西。

都到8了,真的快

感谢分享

@pmq20 受这篇文章启发,开发了异步调用错误堆栈不完整的解决方案 ,以库的形式默默地提供这个功能 是否会比显示地hook要好一点呢?

啥时候上import啊

介绍得很好,赞一下

@i5ting 置顶是需要花钱是吗?

回到顶部