历史
封建时代:直接使用 v8 C++ 头文件
早期开发者直接使用 v8/Node C++ 头文件构建 Node.js 原生插件。
Handle<Value> Echo(const Arguments& args)
{
HandleScope scope;
if(args.Length() < 1)
{
ThrowException(
Exception::TypeError(
String::New("Wrong number of arguments.")));
return scope.Close(Undefined());
}
return scope.Close(args[0]);
}
void Init(Handle<Object> exports)
{
exports->Set(String::NewSymbol("echo"),
FunctionTemplate::New(Echo)->GetFunction());
}
这段代码定义了一个简单的 Node.js 函数 echo,始终返回传入的第一个参数。它等价于以下 Node.js 代码:
exports.echo = function () {
if (arguments.length < 1) throw new Error('Wrong number of arguments.')
return arguments[0]
}
如果把这段代码发布为 npm 包,它只能在 node 0.10.x 中运行。
为什么?简单来说,v8 和 Node.js API 变化很快。 例如在 Node.js 6.x 中,定义 JsFunction 的方式发生了变化:
Handle<Value> Echo(const Arguments& args); // 0.10.x
void Echo(FunctionCallbackInfo<Value>& args); // 6.x
因此,以这种方式开发的原生包只能支持少量 Node.js 版本。v8 或 Node.js API 一旦变化,这些包就无法继续编译;而维护者将 API 更新到最新 Node.js 和 v8 后,包又无法在旧版 Node.js 下编译。
城堡时代:Native Abstractions for Node.js
回到 2013 年,随着 Node.js 和 v8 快速迭代,使用旧方式构建原生插件的包越来越痛苦。于是 NAN 出现了,其全称是 Native Abstractions for Node.js。
NAN 最初由 Rod Vagg 创建,后来由 Benjamin Byholm 维护。NAN 一开始属于 Rod Vagg 的 GitHub 账户;在
Node.js分裂为io.js与Node.js的时期转移到io.js组织;两者重新合并后,NAN 最终转入Node.js组织。
NAN 出现后,原生插件包的开发体验进入了城堡时代,并延续了很长时间。
“Native abstractions for Node.js”仍不足以完整描述 NAN。更具体地说,它是一组 C 宏。例如,可以这样定义 JavaScript 函数:
NAN_METHOD(Echo)
{
}
编译时,NAN 宏会根据不同 Node.js 版本展开为不同 C++ 代码:
Handle<Value> Echo(const Arguments& args); // 0.10.x
void Echo(FunctionCallbackInfo<Value>& args); // 6.x
也就是说,NAN_METHOD 会由 NAN 展开为相应的代码片段。
除了 NAN_METHOD,NAN 还提供大量其他宏,开发者几乎可以用它们完成所有操作。
例如,Nan::HandleScope 用于声明 handle scope,Nan::AsyncWorker 用于在 libuv 上派发任务。
因此在城堡时代,一个 C++ 原生插件大致如下:
NAN_METHOD(Echo)
{
if(info.Length() < 1)
{
Nan::ThrowError("Wrong number of arguments.");
return info.GetReturnValue().Set(Nan::Undefined());
}
info.GetReturnValue().Set(info[0]);
}
NAN_MODULE_INIT(InitAll)
{
Nan::Set(
target,
Nan::New<String>("echo").ToLocalChecked(),
Nan::GetFunction(Nan::New<v8::FunctionTemplate>(Echo)).ToLocalChecked());
}
这种写法的优点是代码可以随 NAN 升级而自动适配,从而兼容不同版本的 Node.js。
即使是 NAN 这样的优秀工具也有明确使命;不属于该使命的内容会逐步移除。例如 0.10.x 和 0.12.x 等版本终将退役,NAN 也会逐渐停止对它们的兼容与支持。
帝国时代:ABI 兼容的 N-API
从 Node.js v8.0.0 开始,Node.js 引入了一个用于开发 C++ 原生模块的全新接口:N-API。
根据官方文档,它读作一个 N 加 API,也就是四个英文字母分别发音。
它与之前的时代有何不同?为何称为进一步的帝国时代?
即便使用 NAN,一份代码仍需针对不同 Node.js 版本重新编译;版本不匹配时,Node.js 无法正确加载 C++ 扩展。换句话说,就是一次编写、到处编译。
与 NAN 相比,N-API 把 Node.js 的底层数据结构全部隐藏起来,抽象为 N-API 接口。
不同 Node.js 版本使用同一套接口,并保持稳定的 ABI(Application Binary Interface,应用程序二进制接口)兼容性。只要不同 Node.js 版本之间的 ABI 版本号相同,编译后的 C++ 扩展就可以直接使用,无需重新编译。实际上,支持 N-API 接口的 Node.js 会声明当前使用的 ABI 版本。
为实现上述目标,N-API 采用以下方式:
- 提供头文件
node_api.h。 - 所有 N-API 调用都返回
napi_status枚举,表示调用是否成功。 - 因为 N-API 的返回值位置被
napi_status占用,真正的返回值通过传入参数返回。 - 所有 JavaScript 数据类型都包装为黑盒类型
napi_value,不再暴露v8::Object、v8::Number等类型。 - 如果函数调用失败,可以使用
napi_get_last_error_info获取最近一次错误的信息。
有关 N-API 函数的更多细节,请阅读其文档。下面先看一些不那么抽象的例子,建立对 N-API 的初步印象。
模块初始化
在封建时代和 NAN 时代,模块初始化由 Node.js 提供的宏完成。
NODE_MODULE(addon, Init)
到了 N-API,它变为 N-API 的宏。
NODE_MODULE(addon, Init)
对应的初始化函数 Init 也需要换一种写法。例如,封建时代和 NAN 时代分别这样写:
// Feudal style
void Init(Local<Object> exports) {
NODE_SET_METHOD(exports, "echo", Echo);
}
// NAN style
NAN_MODULE_INIT(Init)
{
Nan::Set(
target,
Nan::New<String>("echo").ToLocalChecked(),
Nan::GetFunction(Nan::New<v8::FunctionTemplate>(Echo)).ToLocalChecked());
}
使用 N-API 时,Init 函数如下:
void Init(napi_env env, napi_value exports, napi_value module, void* priv)
{
napi_status status;
// Description constructs for setting exports
napi_property_descriptor desc =
{ "echo", 0, Echo, 0, 0, 0, napi_default, 0 };
// set "echo" into `module.exports`
status = napi_define_properties(env, exports, 1, &desc);
}
napi_property_descriptor是设置对象属性时使用的描述结构,其声明如下:cpptypedef struct { const char* utf8name; napi_value name; napi_callback method; napi_callback getter; napi_callback setter; napi_value value; napi_property_attributes attributes; void* data; } napi_property_descriptor;因此,上面
Init函数中的 desc 表示:在目标对象上设置名为 “echo” 的内容,其函数为Echo,其他getter、setter等均为空指针,属性为napi_default。
声明函数
还记得前两种函数声明吗?现在第三次改写:
Handle<Value> Echo(const Arguments& args); // 0.10.x
void Echo(FunctionCallbackInfo<Value>& args); // 6.x
使用 N-API 时不再需要 C++ 背景,掌握 C 即可,因为 Echo 的声明如下:
napi_value Echo(napi_env env, napi_callback_info info)
{
napi_status status;
size_t argc = 1;
napi_value argv[1];
status = napi_get_cb_info(env, info, &argc, argv, 0, 0);
if(status != napi_ok || argc < 1)
{
napi_throw_type_error(env, "Wrong number of arguments");
return 0; // `napi_value` is actually a pointer, returning a null pointer means no return value.
}
return argv[0];
}
逐步分析上述代码:
napi_get_cb_info获取当前函数调用的参数信息,包括参数数量与参数本身(以 napi_value 数组表示)。- 检查调用是否出错(status 不等于 napi_ok)或参数数量是否小于 1。
- 如果调用出错或参数少于 1,通过
napi_throw_type_error在 JavaScript 层抛出错误对象并返回。 - 没有错误则继续执行。
- 如果调用出错或参数少于 1,通过
- 返回第一个参数
argv[0]。
总结
本章介绍了 Node.js 原生 C++ 模块开发方式的演变:
- 从 node-waf 到 node-gyp,是构建工具的变化;未来也可能变为 GN 或其他工具。
- 从频繁破坏的代码到 NAN,再到为原生 C++ 模块开发带来新生命的 N-API,Node.js 社区经历了漫长演进。
希望这些内容能帮助你理解 Node.js 原生模块开发的曲折历史,以及 N-API 出现的原因与背景。