本篇为 深入学习 NodeJs 系列 最新发布的 c++ 插件运行, 因为包含了基础介绍、如何使用、源码实现以及最后的实践例子, 自我感觉还算讲明白了 👀 所以单独分享了出来,更多可以持续关注 🌟 github | blog 🌟
前言
node 中主要有 c++ 模块,lib 目录下的 js 模块,用户的 js 模块以及用户的 c++ 插件模块。其注册,运行,加载的机制都有所不同,下面让我们逐一讲解它们的实现
涉及的知识点
简单介绍
插件是用 C++ 编写的动态链接共享对象。 require() 函数可以将插件加载为普通的 Node.js 模块。 插件提供了 JavaScript 和 C/C++ 库之间的接口。
实现插件有三种选择:Node-API、nan 或直接使用内部 V8、libuv 和 Node.js 库。 除非需要直接访问 Node-API 未暴露的功能,否则请使用 Node-API。 有关 Node-API 的更多信息,请参阅使用 Node-API 的 C/C++ 插件。
其实无论是 Node-API 还是 node-addon-api,都是最基本的 c++ 插件写法的封装,本篇让我们去了解一下 基本的 c++ 插件实现
例子
- 例子代码放在了该 git 仓库 addons 目录
- 运行时的 node 版本为: 16.5.0
- 运行的系统为: MacOS
如下就是一个 c++ 插件的写法,module.exports 上导出了一个 hello 方法,其中 NODE_MODULE 宏主要是把该插件模块给注册到了内部的 modlist_internal 链表中,以便用户 require 的时候找到该模块
// hello.cc
#include <node.h>
namespace demo
{
using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::String;
using v8::Value;
void Method(const FunctionCallbackInfo<Value> &args)
{
Isolate *isolate = args.GetIsolate();
args.GetReturnValue().Set(String::NewFromUtf8(
isolate, "world")
.ToLocalChecked());
}
void Initialize(Local<Object> exports)
{
NODE_SET_METHOD(exports, "hello", Method);
}
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
}
构建
构建运行 hello.cc 主要需要如下几步
下载 node-gyp
npm install -g node-gyp
node-gyp 是一个用 Node.js 编写的跨平台命令行工具,用于为 Node.js 编译本机插件模块。它包含 Chromium 团队以前使用的 gyp-next 项目的供应商副本,扩展为支持 Node.js 原生插件的开发。
新增 binding.gyp 文件
文件内容如下
{
"targets": [
{
"target_name": "addon",
"sources": [ "hello.cc" ]
}
]
}
编写源代码后,必须将其编译为二进制 addon.node 文件。 为此,请在项目的顶层创建名为 binding.gyp 的文件,使用类似 JSON 的格式描述模块的构建配置。 该文件由 node-gyp 使用,这是一个专门为编译 Node.js 插件而编写的工具。
运行 node-gyp configure 命令
node-gyp configure
创建 binding.gyp 文件后,使用 node-gyp configure 为当前平台生成适当的项目构建文件。 这将在 build/ 目录中生成 Makefile(在 Unix 平台上)或 vcxproj 文件(在 Windows 上)。
运行 node-gyp build 命令
node-gyp build
接下来,调用 node-gyp build 命令生成编译后的 addon.node 文件。 这将被放入 build/Release/ 目录。
使用
构建完成后,可以通过将 require() 指向构建的 addon.node 模块在 Node.js 中使用二进制插件
// main.js
const addon = require('./build/Release/addon');
console.log(addon.hello());
// 打印: 'world'
实现
当代码中 require 的是编译后的 c++ 插件 .node 文件时的处理函数如下
- 其主要的实现为 process.dlopen 函数
- 代码开始的 policy 为 Node.js 包含对创建加载代码的策略的实验性支持, 比如运行 node --experimental-policy=policy.json app.js, 其具体用处可以参考 policy | Node.js API 文档
// lib/internal/modules/cjs/loader.js
Module._extensions['.node'] = function(module, filename) {
if (policy?.manifest) {
const content = fs.readFileSync(filename);
const moduleURL = pathToFileURL(filename);
policy.manifest.assertIntegrity(moduleURL, content);
}
// Be aware this doesn't use `content`
return process.dlopen(module, path.toNamespacedPath(filename));
};
动态链接
dlopen 主要用于动态打开一个 c++ 插件文件,运行其代码, 说 dlopen 之前我们先说一下动态链接。
js 里面的动态
如果拿 js 来举例的话, 正常运行一个 js 文件直接通过 script 直接引用交给浏览器运行即可,不需要额外的干预。比如你想动态加载运行一个 js, 就需要封装一个 dynamicImport 方法,等到实际会调用的时候才去动态加载运行,这个 dynamicImport 程序可能就是通过 document.createElement(‘script’) 去实现
C语言的静态链接与动态链接
内容来自 C语言的静态链接与动态链接
什么是链接?
对于初学C语言的朋友,可能对链接这个概念有点陌生,这里简单介绍一下。我们的C代码编译生成可执行程序会经过如下过程:
1、什么是静态链接?
静态链接是由链接器在链接时将库的内容加入到可执行程序中的做法。链接器是一个独立程序,将一个或多个库或目标文件(先前由编译器或汇编器生成)链接到一块生成可执行程序。这里的库指的是静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀。
2、什么是动态链接?
动态链接(Dynamic Linking),把链接这个过程推迟到了运行时再进行,在可执行文件装载时或运行时,由操作系统的装载程序加载库。这里的库指的是动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。值得一提的是,在Windows下的动态链接也可以用到.lib为后缀的文件,但这里的.lib文件叫做导入库,是由.dll文件生成的。
DLOpen
DLOpen 的实现过程主要为如下几步,后面我们在详细分析一下其中重要的步骤
- 对传入的参数进行了一些校验,保存 js 传入的 module 对象,并检查需要存在 exports 属性
- 调用 env->TryLoadAddon 方法来试图加载插件, TryLoadAddon 函数主要是调用了传入的回调函数
- 回调函数一开始就进行了上锁操作 Mutex::ScopedLock lock(dlib_load_mutex);
- 然后调用 dlib->Open() 方法获取动态链接库文件的句柄
- 上一步骤的 dlib->Open() 一运行,c++ 插件的代码其实就已经运行完毕,回顾我们上面例子的 c++ 插件代码,其 NODE_MODULE 宏就会注册该模块到内存中了
- if (mp != nullptr) 判断是否已经通过 NODE_MODULE 主动注册,这样一说其实可以自动动注册
- 如果注册成功调用 dlib->SaveInGlobalHandleMap 保存 dlib->Open() 返回的句柄到内存中
- 否则通过 auto callback = GetInitializerCallback(dlib) 返回值查看是否可以自动帮助用户注册
- dlib->Open() 打开过程结束,通过 Mutex::ScopedUnlock unlock(lock) 解锁
- 调用 mp->nm_register_func 函数其实是上面 c++ 插件例子中用户传入的 Initialize 函数
// src/node_binding.cc
void DLOpen(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
auto context = env->context();
CHECK_NULL(thread_local_modpending);
if (args.Length() < 2) {
return THROW_ERR_MISSING_ARGS(
env, "process.dlopen needs at least 2 arguments");
}
int32_t flags = DLib::kDefaultFlags;
if (args.Length() > 2 && !args[2]->Int32Value(context).To(&flags)) {
return THROW_ERR_INVALID_ARG_TYPE(env, "flag argument must be an integer.");
}
Local<Object> module;
Local<Object> exports;
Local<Value> exports_v;
if (!args[0]->ToObject(context).ToLocal(&module) ||
!module->Get(context, env->exports_string()).ToLocal(&exports_v) ||
!exports_v->ToObject(context).ToLocal(&exports)) {
return; // Exception pending.
}
node::Utf8Value filename(env->isolate(), args[1]); // Cast
env->TryLoadAddon(*filename, flags, [&](DLib* dlib) {
static Mutex dlib_load_mutex;
Mutex::ScopedLock lock(dlib_load_mutex);
const bool is_opened = dlib->Open();
// Objects containing v14 or later modules will have registered themselves
// on the pending list. Activate all of them now. At present, only one
// module per object is supported.
node_module* mp = thread_local_modpending;
thread_local_modpending = nullptr;
if (!is_opened) {
std::string errmsg = dlib->errmsg_.c_str();
dlib->Close();
#ifdef _WIN32
// Windows needs to add the filename into the error message
errmsg += *filename;
#endif // _WIN32
THROW_ERR_DLOPEN_FAILED(env, errmsg.c_str());
return false;
}
if (mp != nullptr) {
if (mp->nm_context_register_func == nullptr) {
if (env->force_context_aware()) {
dlib->Close();
THROW_ERR_NON_CONTEXT_AWARE_DISABLED(env);
return false;
}
}
mp->nm_dso_handle = dlib->handle_;
dlib->SaveInGlobalHandleMap(mp);
} else {
if (auto callback = GetInitializerCallback(dlib)) {
callback(exports, module, context);
return true;
} else if (auto napi_callback = GetNapiInitializerCallback(dlib)) {
napi_module_register_by_symbol(exports, module, context, napi_callback);
return true;
} else {
mp = dlib->GetSavedModuleFromGlobalHandleMap();
if (mp == nullptr || mp->nm_context_register_func == nullptr) {
dlib->Close();
char errmsg[1024];
snprintf(errmsg,
sizeof(errmsg),
"Module did not self-register: '%s'.",
*filename);
THROW_ERR_DLOPEN_FAILED(env, errmsg);
return false;
}
}
}
// -1 is used for N-API modules
if ((mp->nm_version != -1) && (mp->nm_version != NODE_MODULE_VERSION)) {
// Even if the module did self-register, it may have done so with the
// wrong version. We must only give up after having checked to see if it
// has an appropriate initializer callback.
if (auto callback = GetInitializerCallback(dlib)) {
callback(exports, module, context);
return true;
}
char errmsg[1024];
snprintf(errmsg,
sizeof(errmsg),
"The module '%s'"
"\nwas compiled against a different Node.js version using"
"\nNODE_MODULE_VERSION %d. This version of Node.js requires"
"\nNODE_MODULE_VERSION %d. Please try re-compiling or "
"re-installing\nthe module (for instance, using `npm rebuild` "
"or `npm install`).",
*filename,
mp->nm_version,
NODE_MODULE_VERSION);
// NOTE: `mp` is allocated inside of the shared library's memory, calling
// `dlclose` will deallocate it
dlib->Close();
THROW_ERR_DLOPEN_FAILED(env, errmsg);
return false;
}
CHECK_EQ(mp->nm_flags & NM_F_BUILTIN, 0);
// Do not keep the lock while running userland addon loading code.
Mutex::ScopedUnlock unlock(lock);
if (mp->nm_context_register_func != nullptr) {
mp->nm_context_register_func(exports, module, context, mp->nm_priv);
} else if (mp->nm_register_func != nullptr) {
mp->nm_register_func(exports, module, mp->nm_priv);
} else {
dlib->Close();
THROW_ERR_DLOPEN_FAILED(env, "Module has no declared entry point.");
return false;
}
return true;
});
// Tell coverity that 'handle' should not be freed when we return.
// coverity[leaked_storage]
}
env->TryLoadAddon
- loaded_addons_.emplace_back 向 loaded_addons_ 队列尾部添加一个元素
- 调用传入的回调函数 was_loaded
- 如果 was_loaded 失败则 loaded_addons_.pop_back 移除尾部的一个元素
// src/env-inl.h
inline void Environment::TryLoadAddon(
const char* filename,
int flags,
const std::function<bool(binding::DLib*)>& was_loaded) {
loaded_addons_.emplace_back(filename, flags);
if (!was_loaded(&loaded_addons_.back())) {
loaded_addons_.pop_back();
}
}
dlib->Open
- dlopen 以指定模式打开指定的动态连接库文件,并返回一个句柄给调用进程
- dlerror 获取可能会出现的错误
bool DLib::Open() {
handle_ = dlopen(filename_.c_str(), flags_);
if (handle_ != nullptr) return true;
errmsg_ = dlerror();
return false;
}
插件的注册
运行完上面的 dlib->Open 函数,用户的代码也被加载且运行了,如同上面的 c++ 插件例子中调用 NODE_MODULE 宏主要用于注册模块
插件的注册的实现主要是 node_module_register 函数,其实就是向链表 modlist_internal 中插入了一项数据,但是下面 NODE_C_CTOR 宏能让我们学到不少知识
// src/node.h
#define NODE_MODULE(modname, regfunc) \
NODE_MODULE_X(modname, regfunc, NULL, 0) // NOLINT (readability/null_usage)
#define NODE_MODULE_X(modname, regfunc, priv, flags) \
extern "C" { \
static node::node_module _module = \
{ \
NODE_MODULE_VERSION, \
flags, \
NULL, /* NOLINT (readability/null_usage) */ \
__FILE__, \
(node::addon_register_func) (regfunc), \
NULL, /* NOLINT (readability/null_usage) */ \
NODE_STRINGIFY(modname), \
priv, \
NULL /* NOLINT (readability/null_usage) */ \
}; \
NODE_C_CTOR(_register_ ## modname) { \
node_module_register(&_module); \
} \
}
#define NODE_C_CTOR(fn) \
NODE_CTOR_PREFIX void fn(void) __attribute__((constructor)); \
NODE_CTOR_PREFIX void fn(void)
# define NODE_CTOR_PREFIX static
NODE_C_CTOR
NODE_C_CTOR 宏这段实现可有点优秀,其主要是声明了一个动态函数 register ## modname,并且立马调用了 register ## modname 函数,其作用类似于下面的例子, 例子放在了该 git 仓库 demo_NODE_C_CTOR.cpp
通过 NODE_C_CTOR 宏声明了 register 函数,且通过 attribute((constructor)) 使得 register 函数 运行会在 main 函数之前
#include <iostream>
# define NODE_CTOR_PREFIX static
#define NODE_C_CTOR(fn) \
NODE_CTOR_PREFIX void fn(void) __attribute__((constructor)); \
NODE_CTOR_PREFIX void fn(void)
using namespace std;
NODE_C_CTOR(_register_) { \
std::cout << "before main function" << std::endl; \
}
int main()
{
char site[] = "Hello, world!";
cout << site << endl;
return 0;
}
如果不注册
GetInitializerCallback
如果用户没有调用 NODE_MODULE 宏注册, 发现会进入 auto callback = GetInitializerCallback(dlib) 这个代码逻辑
- 下面的代码 “node_register_module_v” STRINGIFY(NODE_MODULE_VERSION) 在 c 中其实是相当于两个字符串拼接的意思, 如果 NODE_MODULE_VERSION 的值是 95, 变量 name 即等于 node_register_module_v95
- dlsym 函数相当于获取 c++ 插件中的指定 name 的地址引用,即通过句柄和连接符名称获取函数名或者变量名。
// src/node_binding.cc
inline InitializerCallback GetInitializerCallback(DLib* dlib) {
const char* name = "node_register_module_v" STRINGIFY(NODE_MODULE_VERSION);
return reinterpret_cast<InitializerCallback>(dlib->GetSymbolAddress(name));
}
void* DLib::GetSymbolAddress(const char* name) {
return dlsym(handle_, name);
}
所以说如果用户没有显示调用 NODE_MODULE 宏注册,会查看 c++ 插件中是否有 node_register_module_v95 函数,如果有的话则主动调用该函数,后面找到了这个 node 实现的 commit 3828fc62
如果不用 NODE_MODULE 宏注册则可以通过下面的例子去实现, 例子放在了该 git 仓库 demo_NODE_MODULE_INITIALIZER.cc
#include <node.h>
#include <v8.h>
static void Method(const v8::FunctionCallbackInfo<v8::Value>& args) {
v8::Isolate* isolate = args.GetIsolate();
args.GetReturnValue().Set(v8::String::NewFromUtf8(
isolate, "world").ToLocalChecked());
}
// NODE_MODULE_EXPORT 宏等同于 __attribute__((visibility("default")))
// NODE_MODULE_INITIALIZER 宏等同于 "node_register_module_v" STRINGIFY(NODE_MODULE_VERSION)
extern "C" NODE_MODULE_EXPORT void
NODE_MODULE_INITIALIZER(v8::Local<v8::Object> exports,
v8::Local<v8::Value> module,
v8::Local<v8::Context> context) {
NODE_SET_METHOD(exports, "hello", Method);
}
其出现这种情况的原因为 dlopen 多次运行后面就不会再运行 c++ 插件里面的代码,类比于 node 的 require 机制,所以 NODE_MODULE 宏第二次直接就没有注册,而通过 NODE_MODULE_INITIALIZER 声明了一个函数,后面每次通过 dlsym 获取到该函数引用地址去主动调用一次该函数就好了,下面是官网 | 上下文感知的插件 的解释
在某些环境中,可能需要在多个上下文中多次加载 Node.js 插件。 例如,Electron 运行时在单个进程中运行多个 Node.js 实例。 每个实例都有自己的 require() 缓存,因此当通过 require() 加载时,每个实例都需要原生插件才能正确运行。 这意味着插件必须支持多个初始化。 可以使用宏 NODE_MODULE_INITIALIZER 构建上下文感知插件,该宏扩展为 Node.js 在加载插件时期望找到的函数的名称。
GetNapiInitializerCallback
在 DLOpen 函数中 GetInitializerCallback 后还有另一个分支逻辑 GetNapiInitializerCallback 函数的调用,其实两个函数的作用是类似的, 这里的看函数名字应该是为 napi 留下的口子
// src/node_binding.cc
inline napi_addon_register_func GetNapiInitializerCallback(DLib* dlib) {
const char* name =
STRINGIFY(NAPI_MODULE_INITIALIZER_BASE) STRINGIFY(NAPI_MODULE_VERSION);
return reinterpret_cast<napi_addon_register_func>(
dlib->GetSymbolAddress(name));
}
napi 插件的例子, 代码放在了该 git 仓库 demo_NAPI_MODULE_INIT.cc
#include <assert.h>
#include <node_api.h>
// 调用 NAPI_MODULE_INIT 注册插件的例子
static int32_t increment = 0;
static napi_value Hello(napi_env env, napi_callback_info info) {
napi_value result;
napi_status status = napi_create_int32(env, increment++, &result);
assert(status == napi_ok);
return result;
}
NAPI_MODULE_INIT() {
napi_value hello;
napi_status status =
napi_create_function(env,
"hello",
NAPI_AUTO_LENGTH,
Hello,
NULL,
&hello);
assert(status == napi_ok);
status = napi_set_named_property(env, exports, "hello", hello);
assert(status == napi_ok);
return exports;
}
优秀的插件推荐
起因是自己写的一个 FaaS 接口,当前 qps 在 30 左右,在监控上发现系统内存是随着时间缓慢上升 📈, 这个接口 qps 预期全量会达到 1000 左右,现在就有内存泄漏可要赶紧排查出来 !
一开始我的想法是在接口上开个口子,比如查询的时候 url 拼上参数 get_v8_heapSnapshot 就返回当前进程 v8 堆的快照,隔一段时间拉取几次来去分析
后面发现自动接入了 Easy-Monitor 里面就能非常快速的满足这个需求, 可以直接点击 devtools 在线分析也能下载到本地分析快照
后面对比了几个不同时间的快照发现无明显变化,看堆空间趋势图也无明显波动,确认为物理机其他进程程序所致是预期内的,我们的 node 进程没有内存泄漏
该 c++ 插件的实现在 X-Profiler/xprofiler 仓库, 更多介绍见文章 Easy-Monitor 3.0 开源 - 基于 Addon 的 Node.js 性能监控解决方案, 算是最佳实践了, 给大佬们打 call 👏
小结
node 中运行一个 c++ 插件的实现主要是在 DLOpen 函数中,DLOpen 核心是调用了下面几个函数
函数介绍来自于文章 采用dlopen、dlsym、dlclose加载动态链接库
#include <dlfcn.h>
// dlopen以指定模式打开指定的动态连接库文件
void *dlopen(const char *filename, int flag);
// dlerror返回出现的错误
char *dlerror(void);
// dlsym通过句柄和连接符名称获取函数名或者变量名
void *dlsym(void *handle, const char *symbol);
// dlclose来卸载打开的库
int dlclose(void *handle);
深夜肝完本篇文章,发现 6个未读邮件,某个机器 oom 告警 😢😢😢
👍👏🏼
欢迎加入钉钉群一起讨论 :)
已经入群,多向大佬们学习 ~
另外,我注意到你的截图里 xprofiler-console 代码不是最新的,可以更新下,最新版本支持了定制堆快照分析,会更加清晰展示可疑泄漏节点, 例子:http://www.devtoolx.com/easy-monitor#/app/1/file?snapshotTab=suspected&filterType=favor&page=1&show-snapshot=YES&snapshotData={"fileType"%3A"heapsnapshot","fileId"%3A1485,"fileBasename"%3A"x-heapdump-29339-20210508-599979.heapsnapshot"}
@hyj1991 优秀,多谢大佬提醒