为什么要写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 代码。
调用 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 | int main (int argc, char* argv[]) { |
上面代码说明进入 C++ 主函数之后直接调用了 node 这个命名空间中的 Start 函数,而这个函数位于 src/node.cc
通过 Start 函数去层层查找,里面会有个 LoadEnviroment 函数:
由于 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的入口启动流程。
process对象
前面提到执行 node 初始化函数时会传入 env->process_object()
,而对应的 lib/internal/bootstrap_node.js
文件中这个参数的含义其实就是 process 对象。
这里的这个process对象就是Node中我们经常使用的全局对象 process。这个 env->process_object()
一些内容就是在src/node.cc
中实现的。我们很容易查找到这个文件中的 SetupProcessObject
函数。
这些列举的方法以及属性其实都基本是 Node
文档中原本列出的 process 对象中暴露的 API 内容。
几种模块的具体加载过程
前面说了一些 node 的入口相关文件之后,接下来将模块分为四个类型,分别介绍加载过程:
- C++ 核心模块
- Node.js 内置模块
- 用户源码模块
- C++拓展
C++ 核心模块
该模块在 node 源码中其实就是采用纯 Cpp 编写的,是并没有经过任何 js 代码封装的原生模块,有点类似于 C++ 拓展,区别在于之前说过的前者位于 node 的源码中并且编译进 node 的可执行二进制文件中,后者则通过动态链接库的形式存在。
在介绍 C++ 核心模块的加载过程之前,先提一下前面出现过的 process.binding
函数。它对应 src/node.cc
文件中的Binding
函数
1 | static void Binding(const FunctionCallbackInfo<Value>& args) { |
其中 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_buildin
的C++
核心模块链表上对比文件标识,从而返回相对应的模块。
追根溯源,这些 C++
核心模块则是在node_module_register
函数中被逐一注册进链表的,可以参考下面代码:
1 | extern "C" void node_module_register(void* m) { |
这个node_module_register
函数清晰表达了,如果传入待注册模块标示位是内置模块(mp->nm_flags & NM_F_BUILTIN)
,就将其加入 C++
核心模块的链表中;否则认为是其他模块。
在src/node.h
中有一个宏是用于注册 C++
核心模块的。
1 |
|
结合之前node_module_register
函数和这个src/node.h
中的宏定义,我们发现只要Node.js
在C++
源码中调用NODE_MODULE_CONTEXT_AWARE_BUILTIN
这个宏,就有一个模块会被注册进 Node.js
的 C++
核心模块链表。
那么什么时候会调用这个宏呢?在 src/node.cc
中会给出结果:
1 | NODE_MODULE_CONTEXT_AWARE_BUILTIN(fs, node::InitFs) |
这个宏展开后的结果就是 NODE_MODULE_CONTEXT_AWARE_X
至此我们就能明白,基本上在每个 C++
核心模块的源码末尾都会有一个宏调用将该模块注册进 C++
核心模块的链表中,以供执行 process.binding
时获取。
- 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
类的声明。
这个NativeModule
类就是Node.js
内置模块的相关处理类了,他有一个叫做require
的静态函数,当其参数 id 值为 ‘native_module’ 时返回的是它本身,否则进入 nativeModule.compile
进行编译。
进而目光转向 compile
函数,第一行代码就是获取该模块的源码:
源码是通过NativeModule.getSource
获取的,NativeModule.getSource
函数返回的是NativeModule._source
数组中的相应内容。
而_source
则是通过process.binging
这个函数获取(Binding
这个函数在node.cc
中)过来的。
执行process.binging('natives')
会返回DefineJavaScript
函数中的处理内容。
而这个DefineJavaScript
函数会遍历一遍natives
数组中的内容,然后加入到一个对象中,对象名的 key
为源码文件标识符,value
是源码本体字符串。这里我们会发现整个项目都找不到natives
数组。
这里其实就可以知道,node中的内置模块本来在 lib 目录下,但是加载的时候却在 C++ 源码中以 natives 变量的形式存在,那么可以证明中间多了一个编译层的过程。
打开node-gyp
文件夹下面的node.gyp
配置文件。
其中有一步的目标配置是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
函数中来,
他会在刚获取到的内置模块js源码字符串前后用(function (exports, require, module, __filename, _dirname)) {
和 });
包裹 从而形成一段闭包代码,放在vm
(vm
模块用于创建独立运行的沙箱体制,通过vm
,js源码可以被编译后立即执行或者编译保存下来稍后执行。是node
中的核心模块,支撑了 require
方法和node
的运行机制。)中执行,并传入事先准备好的module
和exports
对象供其导出。
如此一来,内置模块就完成了加载。
- 用户源码模块
用户源码模块指用户在项目中的 node 源码,以及所使用的第三方包中的模块。非node内置模块的js源码模块话就是用户源码模块
这些模块被使用时需要被require()
函数加载。
与内置模块类似,每个用户源码模块都会被加上一个闭包的头尾,然后node.js
执行这个闭包产生的结果。
其载入流程大概是:
- 开发者调用
require()
(某种意义上等同于调用Module._load
) - 闭包化对应文件的源码,并传入相关参数执行(有缓存就直接返回)
- 通常在执行过程中
module.exports
或者exports
会被赋值 Module.prototype._load
在最后返回这个模块的exports
给上游
- C++拓展
To be continued…