一.概念
本质上,Node.js 扩展就是 C++动态链接库:
Addons are dynamically-linked shared objects written in C++.
相当于JS 通往 C/C++世界的一扇门:
Addons provide an interface between JavaScript and C/C++ libraries.
这些 C++扩展(xxx.node
文件)也能像 JS 模块一样直接require
使用,因为Node 模块加载机制提供了原生支持
P.S.所谓动态链接库,就是能在运行时动态加载的库(.so
文件,或者 Windows 下的.dll
文件):
A shared library(.so) is a library that is linked but not embedded in the final executable, so will be loaded when the executable is launched and need to be present in the system where the executable is deployed.
与之相对的是静态库(.a
文件),编译时链接到可执行文件中,无需从外部加载:
A static library(.a) is a library that can be linked directly into the final executable produced by the linker,it is contained in it and there is no need to have the library into the system where the executable will be deployed.
二.实现方式
在 Node.js 中,编写一个 C++扩展有 3 种方式:
直接手搓:基于 Node、V8、libuv 提供的 C++ API 直接写,但要手动处理这些 API 在不同 Node 版本下的兼容性问题(尤其是 V8 API 经常发生变化)
基于nan:即 Native Abstractions for Node.js,为了屏蔽不同 Node/V8 版本间 C++ API 差异而加的一层抽象,期望将下层 API 兼容性问题的处理都收拢到这一层
基于N-API(推荐方式):Node.js 提供的原生扩展支持 API,与下层的 JS 运行时(V8)完全独立,保证ABI跨 Node 版本保持不变,因此不用重新编译就能在不同的 Node 版本上运行
P.S.实际上,有了 N-API 这层独立抽象之后,C++扩展还能跨 JavaScript 引擎、跨 Electron 等运行时,具体见The Future of Native Modules in Node.js
其中,N-API 是首选方式,除非用 N-API 搞不定才考虑其它方式:
Unless there is a need for direct access to functionality which is not exposed by N-API, use N-API.
跨 Node 版本(无需重编)直接运行无疑是决定性的优势,但只有专门提供的 N-API 才保证 ABI 稳定。也就是说,只用 N-API(不同时混用下层的 Node、V8、libuv API)才能保证 C++扩展在不同的 Node 版本下可以直接运行,具体见Implications of ABI Stability
不用 N-API 的话,手搓一个有些复杂,涉及好几层的知识:
V8:Node.js 依赖的 JavaScript 引擎,对象创建、函数调用等机制都是 V8 提供的,具体 C++ API 见头文件node/deps/v8/include/v8.h
libuv:事件循环、Worker 线程以及所有平台相关的异步行为都是 libuv 提供的,并对文件系统、socket、定时器、系统事件等提供了跨平台抽象,C++扩展中可以通过 libuv 以非阻塞的方式实现各种操作,从而避免 I/O 或者其它耗时任务阻塞事件循环
Node 内部类库:Node.js 自身也暴露了一些 C++ API,例如
node::ObjectWrap
类Node 依赖库:Node.js 依赖的一些静态链接库在 C++扩展中也可以使用,例如 OpenSSL(更多依赖库,见node/deps/)
P.S.关于 Node.js 源码依赖、运行机制的更多信息,见Node.js 架构剖析
三.Hello World
清晰起见,这里采用最原始的方式,手搓一个最简单的 C++扩展:
// hoho.cc
// 见 https://github.com/nodejs/node/blob/master/src/node.h
#include <node.h>
// 见 https://github.com/nodejs/node/blob/master/deps/v8/include/v8.h
using namespace v8;
namespace demo {
void Method(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
args.GetReturnValue().Set(String::NewFromUtf8(
isolate, "hoho, there.", NewStringType::kNormal).ToLocalChecked());
}
void Initialize(Local<Object> exports) {
NODE_SET_METHOD(exports, "hoho", Method);
}
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
}
注意到其中关键的两行:
// 实现初始化方法
void Initialize(Local<Object> exports) { /* ... */ }
// 注册模块名对应的初始化逻辑
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
C++扩展通过 Node.js 提供的NODE_MODULE
宏将初始化方法(Initialize
)暴露出来,其中NODE_GYP_MODULE_NAME
是个宏(macro),在编译前的预处理阶段会被展开成node-gyp
命令传入的模块名
P.S.宏展开可以理解为字符串替换,具体见Macros
编译运行
在对 C++源码进行编译之前,先要有一份编译配置:
{
"targets": [
{
"target_name": "hoho",
"sources": [ "hoho.cc" ]
}
]
}
配置文件名为binding.gyp
,放在项目根目录下(类似于package.json
),供node-gyp
编译使用
P.S.binding.gyp
具体格式及各字段含义见Input Format Reference
先要安装node-gyp
命令:
npm install -g node-gyp
P.S.当然,也可以npm install node-gyp
将其安装到当前项目,并通过npx node-gyp
调用
接着通过node-gyp configure
命令,生成当前平台构建过程所需的配置文件(Unix 系统下生成 Makefile,Windows 下是 vcxproj 文件),例如(Mac OSX):
$ node-gyp configure
gyp info it worked if it ends with ok
...
gyp info ok
# 生成的文件位于 build 目录下
$ tree build/
build/
├── Makefile
├── binding.Makefile
├── config.gypi
├── gyp-mac-tool
└── hoho.target.mk
编译得到.node
二进制文件:
$ node-gyp build
gyp info it worked if it ends with ok
gyp info using node-gyp@6.1.0
gyp info using node@10.18.0 | darwin | x64
gyp info spawn make
gyp info spawn args [ 'BUILDTYPE=Release', '-C', 'build' ]
CXX(target) Release/obj.target/hoho/hoho.o
SOLINK_MODULE(target) Release/hoho.node
gyp info ok
编译产物位于Release/hoho.node
,试玩一下:
// index.js
// 省略后缀名,自动找到hoho.node并加载、初始化
const hoho = require('./build/Release/hoho');
console.log(hoho.hoho());
运行结果:
$ node index.js
hoho, there.
上例直接使用了 Node、V8 提供的 C++ API,可能存在跨版本兼容性问题(过几个版本可能就编译报错了),并且在不同版本的 Node 环境下都需要重新编译,否则会产生运行时报错:
$ node -v
v10.18.0
# 切换到8.17.0
$ n 8.17.0
# 不重编直接执行
$ node index.js
module.js:682
return process.dlopen(module, path._makeLong(filename));
^
Error: The module '/path/to/hoho/build/Release/hoho.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 64. This version of Node.js requires
NODE_MODULE_VERSION 57. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).
必须重新编译、执行:
$ node-gyp rebuild
gyp info it worked if it ends with ok
...
gyp info ok
$ node index.js
hoho, there.
那么,有没有一劳永逸的方式?
有。N-API
四.N-API
不直接用 Node、V8 等下层 C/C++模块暴露出来的 API,全都换用 N-API:
// hoho-anywhere.cc
#include <node_api.h>
namespace demo {
napi_value Method(napi_env env, napi_callback_info args) {
napi_value greeting;
napi_status status;
status = napi_create_string_utf8(env, "hoho, anywhere.", NAPI_AUTO_LENGTH, &greeting);
if (status != napi_ok) return nullptr;
return greeting;
}
napi_value init(napi_env env, napi_value exports) {
napi_status status;
napi_value fn;
status = napi_create_function(env, nullptr, 0, Method, nullptr, &fn);
if (status != napi_ok) return nullptr;
status = napi_set_named_property(env, exports, "hoho", fn);
if (status != napi_ok) return nullptr;
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, init)
}
只引一个头文件node_api.h
,值类型等也不再直接使用v8::String
修改编译配置binding.gyp
:
{
"targets": [
{
"target_name": "hoho",
"sources": [ "hoho-anywhere.cc" ]
}
]
}
编译运行:
$ node-gyp rebuild
$ node index.js
hoho, anywhere.
# 切换Node版本
$ n 8.17.0
# 无需编译,可直接运行!
$ node index.js
hoho, anywhere.
P.S.更复杂的用法,以及关于 N-API 的更多信息,见N-API
P.S.另外,N-API 提供的都是 C 接口,对于 C++环境,可采用node-addon-api
五.应用场景
有些场景下,用 C++扩展来实现尤为合适:
计算密集型模块,C++的执行性能一般要高于 JS
将现有的 C++类库低成本地封装成 Node.js 扩展,供 Node 生态使用
Node.js 提供的原生能力无法满足需要,比如fsevents
JS 语言在一些方面存在先天不足(例如数值精度、位运算等),可以通过 C++来补足
P.S.注意,运行时初始化 C++模板本就存在一些开销,在苛求性能的场景要把这个因素考虑进来,并且 C++并不总是比 JS 快(比如正则匹配的某些场景)