nodejs拓展本质是一个动态链接库,写完编译后,生成一个.node文件。我们在nodejs里直接require使用,nodejs会为我们处理这一切。下面我们按照文档写一个拓展并通过nodejs14源码了解他的原理(ubuntu18.4)。
首先建立一个test.cc文件
// hello.cc using N-API
#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, "world", 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, "hello", fn);
if (status != napi_ok) return nullptr;
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, init)
} // namespace demo
我们不需要具体了解代码的意思,但是从代码中我们大致知道他做了什么事情。剩下的就是阅读n-api的api文档就可以。接着我们新建一个binding.gyp文件。gyp文件是node-gyp的配置文件。node-gyp可以帮助我们针对不同平台生产不同的编译配置文件。比如linux下的makefile。
{
"targets": [
{
"target_name": "test",
"sources": [ "./test.cc" ]
}
]
}
语法和makefile有点像,就是定义我们编译后的目前文件名,依赖哪些源文件。然后我们安装node-gyp。
npm install node-gyp -g
nodejs源码中也有一个node-gyp,他是帮助npm安装拓展模块时,就地编译用的。我们安装的node-gyp是帮助我们生成配置文件并编译用的,具体可以参考nodejs文档。一切准备就绪。我们开始编译。直接执行
node-gyp rebuild
在路径./build/Release/下生成了test.node文件。这就是我们的拓展模块。我们编写测试程序。
var addon = require("./build/Release/test");
console.log(addon.hello());
执行
nodejs app.js
我们看到输出world。我们已经学会了如何编写一个nodejs的拓展模块。剩下的就是阅读n-api文档,根据自己的需求编写不同的模块。
写完了一个拓展模块,当然要去分析他的机制。一切的源头在于require函数。但是我们不必从这开始分析,我们只需要从加载.node模块的源码开始。
Module._extensions['.node'] = function(module, filename) {
// ...
return process.dlopen(module, path.toNamespacedPath(filename));
};
直接调了process.dlopen,该函数在node.js里定义。
const rawMethods = internalBinding('process_methods');
process.dlopen = rawMethods.dlopen;
找到process_methods模块对应的是node_process_methods.cc。
env->SetMethod(target, "dlopen", binding::DLOpen);
之前说过,node的拓展模块其实是动态链接库,那么我们先看看一个动态链接库我们是如何使用的。以下是示例代码。
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
int main(){
// 打开一个动态链接库,拿到一个handler
handler = dlopen("xxx.so",RTLD_LAZY);
// 取出动态链接库里的函数add
add = dlsym(handler,"add");
// 执行
printf("%d",add (1,1));
dlclose(handler);
return 0;
}
了解动态链接库的使用,我们继续分析刚才看到的DLOpen函数。
void DLOpen(const FunctionCallbackInfo<Value>& args) {
int32_t flags = DLib::kDefaultFlags;
node::Utf8Value filename(env->isolate(), args[1]); // Cast
env->TryLoadAddon(*filename, flags, [&](DLib* dlib) {
const bool is_opened = dlib->Open();
node_module* mp = thread_local_modpending;
thread_local_modpending = nullptr;
// 省略部分代码
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);
}
return true;
});
}
我们看到重点是TryLoadAddon函数,该函数的逻辑就是执行他的第三个参数。我们发现第三个参数是一个函数,入参是DLib对象。所以我们先看看这个类。
class DLib {
public:
static const int kDefaultFlags = RTLD_LAZY;
DLib(const char* filename, int flags);
bool Open();
void Close();
const std::string filename_;
const int flags_;
std::string errmsg_;
void* handle_;
uv_lib_t lib_;
};
再看一下实现。
bool DLib::Open() {
handle_ = dlopen(filename_.c_str(), flags_);
if (handle_ != nullptr) return true;
errmsg_ = dlerror();
return false;
}
DLib就是对动态链接库的一个封装,他封装了动态链接库的文件名和操作。TryLoadAddon函数首先根据require传入的文件名,构造一个DLib,然后执行
const bool is_opened = dlib->Open();
Open函数打开了一个动态链接库,这时候我们要先了解一下打开一个动态链接库究竟发生了什么。首先我们看一个napi动态链接库的定义。我们回来文章开头的测试代码test.cc。最后一句是
NAPI_MODULE(NODE_GYP_MODULE_NAME, init)
这是个宏定义。
#define NAPI_MODULE(modname, regfunc) \
NAPI_MODULE_X(modname, regfunc, NULL, 0)
继续展开
#define NAPI_MODULE_X(modname, regfunc, priv, flags) \
static napi_module _module = \
{ \
NAPI_MODULE_VERSION, \
flags, \
__FILE__, \
regfunc, \
#modname, \
priv, \
{0}, \
}; \
static void _register_modname(void) __attribute__((constructor)); \
static void _register_modname(void) { \
napi_module_register(&_module); \
}
所以一个node扩展就是定义了一个napi_module 模块和一个_register_modname(modname是我们定义的)函数。我们貌似定义了两个函数,其实一个带__attribute__((constructor))。
attribute
((constructor))是代表该函数会先执行的意思,具体可以查阅文档。看到这里我们知道,当我们打开一个动态链接库的时候,会执行_register_modname函数,该函数执行的是
napi_module_register(&_module);
我们继续展开。
// Registers a NAPI module.
void napi_module_register(napi_module* mod) {
node::node_module* nm = new node::node_module {
-1,
mod->nm_flags | NM_F_DELETEME,
nullptr,
mod->nm_filename,
nullptr,
napi_module_register_cb,
mod->nm_modname,
mod, // priv
nullptr,
};
node::node_module_register(nm);
}
nodejs把napi模块转成node_module。最后调用node_module_register。
extern "C" void node_module_register(void* m) {
struct node_module* mp = reinterpret_cast<struct node_module*>(m);
if (mp->nm_flags & NM_F_INTERNAL) {
mp->nm_link = modlist_internal;
modlist_internal = mp;
} else if (!node_is_initialized) {
mp->nm_flags = NM_F_LINKED;
mp->nm_link = modlist_linked;
modlist_linked = mp;
} else {
thread_local_modpending = mp;
}
}
napi模块不是NM_F_INTERNAL模块,node_is_initialized是在nodejs初始化时设置的变量,这时候已经是true。所以注册napi模块时,会执行thread_local_modpending = mp。thread_local_modpending 类似一个全局变量,保存当前加载的模块。分析到这,我们回到DLOpen函数。
node_module* mp = thread_local_modpending;
thread_local_modpending = nullptr;
这时候我们就知道刚才那个变量thread_local_modpending的作用了。node_module* mp = thread_local_modpending后我们拿到了我们刚才定义的napi模块的信息。接着执行node_module的函数nm_register_func。
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);
}
从刚才的node_module定义中我们看到函数是napi_module_register_cb。
static void napi_module_register_cb(v8::Local<v8::Object> exports,
v8::Local<v8::Value> module,
v8::Local<v8::Context> context,
void* priv) {
napi_module_register_by_symbol(exports, module, context,
static_cast<napi_module*>(priv)->nm_register_func);
}
该函数调用napi_module_register_by_symbol函数,并传入napi_module的nm_register_func函数,即我们test.cc代码里定义的函数。
void napi_module_register_by_symbol(v8::Local<v8::Object> exports,
v8::Local<v8::Value> module,
v8::Local<v8::Context> context,
napi_addon_register_func init) {
// Create a new napi_env for this specific module.
napi_env env = v8impl::NewEnv(context);
napi_value _exports;
env->CallIntoModuleThrow([&](napi_env env) {
_exports = init(env, v8impl::JsValueFromV8LocalValue(exports));
});
if (_exports != nullptr &&
_exports != v8impl::JsValueFromV8LocalValue(exports)) {
napi_value _module = v8impl::JsValueFromV8LocalValue(module);
napi_set_named_property(env, _module, "exports", _exports);
}
}
init就是我们在test.cc里定义的函数。入参是env和exports,可以对比我们定义的函数的入参。最后我们修改exports变量。即设置导出的内容。最后在js里,我们就拿到了c++层定义的内容。