0%

《Node.js,来一打 Cpp 拓展》学习笔记- Node 模块原理解析 && Cpp 模块优点

为什么要写C++拓展模块

C++ 比 JS 解释器高效

相同意思的代码,在 JavaScript 解析器中执行 JavaScript 代码的效率通常比直接执行一个 C++ 编译好后的二进制文件要低。(这里指那些非并行、计算密集型的代码,因为模型不用,单线程下实现 C++ 的 Web 请求处理和有着异步 I/O 优势的 Node.js 下实现的 Web 请求处理也是不能相提并论的 —— Node.js底层使用了别的线程)

有个 NBody的例子,相同的代码,使用C++ 大约只需要6-7s,但使用Node.JS却需要20s左右,具体代码可以参看书中例子。

C++效率虽然高,但是所需要的维护成本和开发效率和 Node.js 也不在一个层次上。因此偶尔在一个整体使用 Node.js 开发的项目中使用 C++ 写一两个拓展也是一种很奇妙的体验。

已有的Cpp轮子

还有一种比较常见的使用Cpp拓展的原因是市面上或者手头上已经有一套C++的轮子,而且使用Node 再次实现一遍非常麻烦且不现实,这时就可以基于这个轮子包裹一层C++的拓展了,当然前提是项目的主体本身就是 Node

综合以上两点,使用C++来写Node.js原生拓展的两大理由——性能和开发成本

对于有显著性能提升的情况,使用CPP来完成是很爽的,而对于已经存在的C++类库,那些难以迁移或者无法迁移的项目又何苦迁移,使用 Cpp 拓展也是件很有意思的事情。

什么是 C++ 拓展

因为 Node.js 本身是基于 Chrome V8 引擎和 libuv,使用 C++ 进行开发的,因此自然能够轻松的对使用了特定 API 进行开发的 C++ 代码进行拓展,使其能够在 Node.js 中被 require 之后能够像调用 JavaScript 函数一样被调用。

C++ 模块本质

Node是基于C++开发的,因此所有底层头文件暴露的 API 也都是适用于 C++ 的。

当我们在Node.js中 require 一个模块的时候,其运行时会依次枚举后缀名进行一个寻址,其中就有后缀名为*.node的模块,这是一个 C++ 模块的二进制文件。

实际上编译好的C++模块除了后缀名是*.node之外,它其实就是一个系统的动态链接库。说得直白一点,这就相当于 Windows 下的 *.dll、Linux下的*.so以及 macOS 下的*.dylib

在 Node.js 中引入一个 C++ 模块的过程,实际上就是 Node.js 在运行时引入一个动态链接库的过程。运行的时候接受 js 代码中的调用,解析出来具体是拓展中的哪个函数需要被调用,在调用完成之后获得结果再通过运行时返回给 js 代码。

image-20200521232522393

调用 Node 的原生 C++ 函数和调用 C++ 拓展函数的区别就在于前者的代码会直接编译进 Node 可执行文件中,而后者的代码则位于一个动态链接库中。

Node 模块加载原理

在开发 node 的经验中,Node.js 载入一个源码文件或者一个 C++ 拓展文件是通过 Node 中的 require() 函数实现的。这些被在于的文件单位或者粒度就是模块(modules)了。C++模块也被称为C++拓展了。

该函数既能载入 Node 的内部模块,也能载入 JS 模块以及 C++ 拓展。

node.js 入口

Node.js在 C++ 代码层面的入口在源码src/node_main.cc中。

1
2
3
4
5
6
7
int main (int argc, char* argv[]) {
// Disable stdio buffering, it interacts poorly with printf()
// calls elsewhere in the program (e.g., any logging from V8.)
setvbuf(stdout, nullptr, _IONBF, 0);
setvbuf(stderr, nullptr, _IONBF, 0);
return node::Start(argc, argv);
}

上面代码说明进入 C++ 主函数之后直接调用了 node 这个命名空间中的 Start 函数,而这个函数位于 src/node.cc

通过 Start 函数去层层查找,里面会有个 LoadEnviroment 函数:

image-20200521234826877

由于 Node 现在的代码和作者当时版本的源码有挺多的差别,因此我们直接按照书中的分析。

Node 执行 lib/internal/bootstrap_node.js文件来进行初始化启动,这里还没有require的概念。文件中的源码没有经过 require() 函数进行闭包操作,所以执行该文件之后得到的 f_value 就是 bootsrap_node.js文件中所实现的那个函数对象。

由于 V8 的值(包括对象、函数等)均继承自 Value 基类,所以在得到函数的 Value 实例之后需要将其转换成能用的 Function 对象,然后以 env->process_object() 为参数执行这个从bootstap_node.js中得到的函数。

那么我们就大致知道了Node的入口启动流程。

image-20200521235513947

process对象

前面提到执行 node 初始化函数时会传入 env->process_object(),而对应的 lib/internal/bootstrap_node.js文件中这个参数的含义其实就是 process 对象。

这里的这个process对象就是Node中我们经常使用的全局对象 process。这个 env->process_object()一些内容就是在src/node.cc中实现的。我们很容易查找到这个文件中的 SetupProcessObject函数。image-20200522000636091

这些列举的方法以及属性其实都基本是 Node文档中原本列出的 process 对象中暴露的 API 内容。

几种模块的具体加载过程

前面说了一些 node 的入口相关文件之后,接下来将模块分为四个类型,分别介绍加载过程:

  • C++ 核心模块
  • Node.js 内置模块
  • 用户源码模块
  • C++拓展
  1. C++ 核心模块

    该模块在 node 源码中其实就是采用纯 Cpp 编写的,是并没有经过任何 js 代码封装的原生模块,有点类似于 C++ 拓展,区别在于之前说过的前者位于 node 的源码中并且编译进 node 的可执行二进制文件中,后者则通过动态链接库的形式存在。

在介绍 C++ 核心模块的加载过程之前,先提一下前面出现过的 process.binding函数。它对应 src/node.cc文件中的Binding函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
static void Binding(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);

Local<String> module = args[0]->ToString(env->isolate());
node::Utf8Value module_v(env->isolate(), module);

Local<Object> cache = env->binding_cache_object();
Local<Object> exports;

if (cache->Has(env->context(), module).FromJust()) {
exports = cache->Get(module)->ToObject(env->isolate());
args.GetReturnValue().Set(exports);
return;
}

// Append a string to process.moduleLoadList
// 将一个字符串附加在 process.moduleLoadList 后面
char buf[1024];
snprintf(buf, sizeof(buf), "Binding %s", *module_v);

Local<Array> modules = env->module_load_list_array();
uint32_t l = modules->Length();
modules->Set(l, OneByteString(env->isolate(), buf));

node_module* mod = get_builtin_module(*module_v);
if (mod != nullptr) {
exports = Object::New(env->isolate());
// Internal bindings don't have a "module" object, only exports.
// 内置模块没有 module 对象,只有 exports
CHECK_EQ(mod->nm_register_func, nullptr);
CHECK_NE(mod->nm_context_register_func, nullptr);
Local<Value> unused = Undefined(env->isolate());
mod->nm_context_register_func(exports, unused,
env->context(), mod->nm_priv);
cache->Set(module, exports);
} else if (!strcmp(*module_v, "constants")) {
exports = Object::New(env->isolate());
DefineConstants(env->isolate(), exports);
cache->Set(module, exports);
} else if (!strcmp(*module_v, "natives")) {
exports = Object::New(env->isolate());
DefineJavaScript(env, exports);
cache->Set(module, exports);
} else {
char errmsg[1024];
snprintf(errmsg,
sizeof(errmsg),
"No such module: %s",
*module_v);
return env->ThrowError(errmsg);
}

args.GetReturnValue().Set(exports);
}

其中 Local<String> module = args[0]->Tostring(env -> isolate());node::Utf8Value module_v(env->isolate(), module); 表示从参数中获得文件标识符(或者文件名)的字符串并赋值给module-v

在得到标识符字符串之后,node.js通过node_module* mod = get_builtin_module(*module_v); 这句代码获取C++核心模块,例如未经源码lib 目录下的 js 文件封装的file模块。我们注意到这里获取核心模块用的是一个get_builtin_module函数,这个函数的内部工作就是在一个名为modlist_buildinC++核心模块链表上对比文件标识,从而返回相对应的模块。

追根溯源,这些 C++ 核心模块则是在node_module_register函数中被逐一注册进链表的,可以参考下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
extern "C" void node_module_register(void* m) {
struct node_module* mp = reinterpret_cast<struct node_module*>(m);

if (mp->nm_flags & NM_F_BUILTIN) {
mp->nm_link = modlist_builtin;
modlist_builtin = mp;
} else if (!node_is_initialized) {
// "Linked" modules are included as part of the node project.
// Like builtins they are registered *before* node::Init runs.
mp->nm_flags = NM_F_LINKED;
mp->nm_link = modlist_linked;
modlist_linked = mp;
} else {
modpending = mp;
}
}

struct node_module* get_builtin_module(const char* name) {
struct node_module* mp;

for (mp = modlist_builtin; mp != nullptr; mp = mp->nm_link) {
if (strcmp(mp->nm_modname, name) == 0)
break;
}

CHECK(mp == nullptr || (mp->nm_flags & NM_F_BUILTIN) != 0);
return (mp);
}

这个node_module_register函数清晰表达了,如果传入待注册模块标示位是内置模块(mp->nm_flags & NM_F_BUILTIN),就将其加入 C++核心模块的链表中;否则认为是其他模块。

src/node.h中有一个宏是用于注册 C++核心模块的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#define NODE_MODULE_CONTEXT_AWARE_X(modname, regfunc, priv, flags)    \
extern "C" { \
static node::node_module _module = \
{ \
NODE_MODULE_VERSION, \
flags, \
NULL, \
__FILE__, \
NULL, \
(node::addon_context_register_func) (regfunc), \
NODE_STRINGIFY(modname), \
priv, \
NULL \
}; \
NODE_C_CTOR(_register_ ## modname) { \
node_module_register(&_module); \
} \
}

#define NODE_MODULE(modname, regfunc) \
NODE_MODULE_X(modname, regfunc, NULL, 0)

#define NODE_MODULE_CONTEXT_AWARE(modname, regfunc) \
NODE_MODULE_CONTEXT_AWARE_X(modname, regfunc, NULL, 0)

#define NODE_MODULE_CONTEXT_AWARE_BUILTIN(modname, regfunc) \
NODE_MODULE_CONTEXT_AWARE_X(modname, regfunc, NULL, NM_F_BUILTIN) \

结合之前node_module_register函数和这个src/node.h中的宏定义,我们发现只要Node.jsC++源码中调用NODE_MODULE_CONTEXT_AWARE_BUILTIN这个宏,就有一个模块会被注册进 Node.jsC++ 核心模块链表。

那么什么时候会调用这个宏呢?在 src/node.cc中会给出结果:

1
NODE_MODULE_CONTEXT_AWARE_BUILTIN(fs, node::InitFs)

这个宏展开后的结果就是 NODE_MODULE_CONTEXT_AWARE_X

至此我们就能明白,基本上在每个 C++ 核心模块的源码末尾都会有一个宏调用将该模块注册进 C++ 核心模块的链表中,以供执行 process.binding 时获取。

  1. Node.js 内置模块

node 内置模块基本上等同于官方文档中放出的那些文档。这些模块大多是在源码 lib 目录下以同名 JavaScript 代码的形式被实现,而且很多 node.js 内置模块实际上都是对 C++ 核心模块的一个封装。

例如 lib/crypto.js 中就有一段 const binding = process.binding('crypto'); 这样的代码,它里面很多内容都是基于 C++ 核心模块中的 crypto 进行实现的。

接下来先看Node.js的启动脚本,lib/internal/bootstrap_node.js中。代码最下面有一个 NativeModule 类的声明。

image-20200523130136554

这个NativeModule类就是Node.js内置模块的相关处理类了,他有一个叫做require 的静态函数,当其参数 id 值为 ‘native_module’ 时返回的是它本身,否则进入 nativeModule.compile 进行编译。

进而目光转向 compile函数,第一行代码就是获取该模块的源码:

image-20200523130535026

源码是通过NativeModule.getSource获取的,NativeModule.getSource函数返回的是NativeModule._source 数组中的相应内容。

image-20200523130647750

image-20200523130727713

_source则是通过process.binging这个函数获取(Binding 这个函数在node.cc中)过来的。

image-20200523130900236

执行process.binging('natives')会返回DefineJavaScript函数中的处理内容。

而这个DefineJavaScript函数会遍历一遍natives数组中的内容,然后加入到一个对象中,对象名的 key 为源码文件标识符,value是源码本体字符串。这里我们会发现整个项目都找不到natives 数组。

这里其实就可以知道,node中的内置模块本来在 lib 目录下,但是加载的时候却在 C++ 源码中以 natives 变量的形式存在,那么可以证明中间多了一个编译层的过程。

打开node-gyp文件夹下面的node.gyp配置文件。

image-20200523132014859

其中有一步的目标配置是node_js2c,然后去看js2c.py文件。

这是一个 py脚本,主要作用是把lib下的js文件转成src/node_natives.h文件。

这个src/node_natives.h文件会在node编译之前完成,这样在编译到src/node_javascript.cc时他需要的src/node_natives.h有头文件就有了。

src/node_natives.h源文件经过js2c.py转换后,会以一种之前说过的 natives对象存在。

在node中调用NativeModule.require的时候,会根据传入的文件标识来返回响应的 JavaScript 源文件内容,例如dgram 对应lib/dgram.js中的js代码字符串

解决了 编译进 node 二进制文件中的 js 代码的问题之后,重新回到NativeModule.compile函数中来,image-20200523132837343

他会在刚获取到的内置模块js源码字符串前后用(function (exports, require, module, __filename, _dirname)) {}); 包裹 从而形成一段闭包代码,放在vm(vm模块用于创建独立运行的沙箱体制,通过vm,js源码可以被编译后立即执行或者编译保存下来稍后执行。是node中的核心模块,支撑了 require方法和node的运行机制。)中执行,并传入事先准备好的moduleexports对象供其导出。

如此一来,内置模块就完成了加载。

  1. 用户源码模块

用户源码模块指用户在项目中的 node 源码,以及所使用的第三方包中的模块。非node内置模块的js源码模块话就是用户源码模块

这些模块被使用时需要被require()函数加载。

与内置模块类似,每个用户源码模块都会被加上一个闭包的头尾,然后node.js执行这个闭包产生的结果。

其载入流程大概是:

  • 开发者调用require()(某种意义上等同于调用Module._load)
  • 闭包化对应文件的源码,并传入相关参数执行(有缓存就直接返回)
  • 通常在执行过程中module.exports或者exports会被赋值
  • Module.prototype._load在最后返回这个模块的exports给上游
  1. C++拓展

​ To be continued…