简略介绍
平时开发中,在不用某些模块解析框架的情况下,我们很少或者只用到(require和modules.exports)这一组api来管理用户源码模块(xxx.js),但你知道吗,其实nodejs一共有4种模块,分别是c++原生模块,native本地js模块,用户js模块,用户c++扩展,本文以process.binding这个api入手,介绍nodejs底层的c++原生模块加载原理。
我们为什么要了解模块原理?
为深入了解nodejs底层打下基础
要理解nodejs底层,首先就得了解它的各个文件的加载入口,进而引出了模块的概念,之后对于nodejs的源码结构就会相对清晰很多,这有利于后续nodejs源码层面的学习。 一般来说cpp原生模块的注册一般是NODE_MODULE_CONTEXT_AWARE_BUILTIN这个宏 ,它的作用是在模块链表中添加一个描述模块的数据节点 如果你在看nodejs源码的过程中,发现了上面这个宏NODE_MODULE_CONTEXT_AWARE_BUILTIN,就能证明这是一个模块,以下取自os模块: 现在你可能会想,我为什么要了解nodejs底层,平时就直接用node.js开发不好吗?这里笔者给出一些理由:
- 如果你现在用nodejs构建桌面端应用(比如electron),如果只是做简单的开发,了解nodejs底层的帮助可能不大,但如果你想扩展自己桌面端应用的能力,你就可以手写一个nodejs原生模块扩展,在扩展里直接调用操作系统的api。
甚至,你还可以利用cpp扩展模块写一个浇水层,沟通node.js和已经开发好的cpp程序,使你可以通过nodejs直接调用别的已经开发好的cpp程序。 2. 有些nodejs的框架可能换一个nodejs版本就要重新构建(build)或者直接不能运行了,要理解为什么,有一个前置基础得需要读者了解。 nodejs每一个版本,内部所暴露的api都有可能改变,这是因为nodejs利用v8来解析javascript,v8底层api的变化频繁导致了nodejs api变动频繁 所以切换不同的nodejs版本,原先框架的调用可能就失效了,又因为这个变化频繁的特性,NAN横空出世,它封装了nodejs的一系列api(利用宏)暴露给用户使用,但是依然没有完全逃离nodejs底层api变化频繁的问题,因为应对不同的nodejs版本,NAN做的是预处理,所以扩展模块依然要重新构建,这里举个例子,比如node-sass开源框架(用NAN构建) 可以见得node-sass非常依赖于nodejs的版本
cpp扩展的能力
效率更高?
网上常见的说法是cpp扩展模块能应对一些计算密集型的任务,我们可以把这种任务交给扩展来完成,再把结果会调到js层来提高效率,但真的是这样吗?测试如下: 我们用cpp写一个计算斐波那契数列的算法,
long long int F(int n) //由于后面数值结果较大,可使用longlong类型
{
int fibOne = 0;
int fibTwo = 1;
int fibN = 0;
for (int i = 2; i <= n; i++)
{
fibN = fibOne + fibTwo;
fibOne = fibTwo;
fibTwo = fibN;
}
return fibN;
}
复制代码
再用JavaScript写一个
function F(n){
let fibOne = 0;
let fibTwo = 1;
let fibN = 0;
for (let i = 2; i <= n; i++)
{
fibN = fibOne + fibTwo;
fibOne = fibTwo;
fibTwo = fibN;
}
return fibN;
}
function test(i){
const now=new Date();
F(i);
console.log( (new Date()-now)/1000);
}
test(100)
复制代码
结果:
数据量 | 1e8 | 1e9 | 1e10 |
---|---|---|---|
js | 0.513 | 4.679 | 45.629 |
c++ | 0.029 | 0.276 | 0.547 |
可见在效率上cpp已经把js碾过去了...但这里并不是鼓励在任何场合都用cpp编写代码,而是在这种大计算量场合使用cpp才比较合理。
快速扩展
如果你手头上已经有一个开发好的cpp项目要和现在的js项目对接,你会怎么处理?常见的处理手段有:
- 双方应用互相起一个http服务来进行通信。
毕竟是通过http报文通信,这样就会受限于http本身的限制,且程序效率一般会比直接调用低,这种通信方式github开源项目mirai(一种qq机器人框架)就有使用,具体是为了方便别的语言的使用者进行mirai开发,在它的核心内置了一个http通信插件(mirai-http-api),其他语言通过http请求,来和核心通信,然后调用核心提供的功能。 2. 直接在js里把cpp项目翻译进去。 如果直接翻译,对程序员的要求就太高了,而且会有时间成本的风险,无法快速对接。 3. 为cpp项目做一层扩展,供js调用。 优于以上两种方式,直接调用效率更高,而且开发时间会更短。
从os模块入手
process.binding是nodejs全局对象process上的一个api,作用是加载cpp源码模块,本小节以os模块为例,它是nodejs原生cpp模块之一,用于查看系统的一些基本信息。 os cpp源码模块打印结果[nodejs v6.9.4环境下]
process.binding('os')
// BaseObject {
// getHostname: [Function: getHostname],
// getLoadAvg: [Function: getLoadAvg],
// getUptime: [Function: getUptime] {
// [Symbol(Symbol.toPrimitive)]: [Function (anonymous)]
// },
// getTotalMem: [Function: getTotalMem] {
// [Symbol(Symbol.toPrimitive)]: [Function (anonymous)]
// },
// getFreeMem: [Function: getFreeMem] {
// [Symbol(Symbol.toPrimitive)]: [Function (anonymous)]
// },
// getCPUs: [Function: getCPUs],
// getInterfaceAddresses: [Function: getInterfaceAddresses],
// getHomeDirectory: [Function: getHomeDirectory],
// getUserInfo: [Function: getUserInfo],
// setPriority: [Function: setPriority],
// getPriority: [Function: getPriority],
// getOSInformation: [Function: getOSInformation],
// isBigEndian: false
// }
复制代码
你或许曾经想过,os这个模块到底是什么,在哪里?下面就让我们直接揭开谜底,nodejs底层的cpp可以当作javascript的伪代码来看,能当作javascript伪代码看也是有原因的,因为nodejs利用v8的解释器来运行js脚本,实际上js的每一个数据类型,函数定义在底层v8都有对应关系,你或许完全没有看过v8代码,但是在操作js的时候,你已经相当于接触v8了。
nodejs的cpp源码模块os,[位置:"src/node_os.cc"]
#include "node.h"
#include "v8.h"
/...一大堆头文件定义
namespace node {
namespace os {
...
//一大堆函数定义 getHostname,GetUserInfo等等
...
void Initialize(Local<Object> target, //target就是外部传进来的exports
Local<Value> unused,
Local<Context> context) {
Environment* env = Environment::GetCurrent(context);
env->SetMethod(target, "getHostname", GetHostname); //target.getHostname=GetHostName,GetHostname.name="getHostName"
env->SetMethod(target, "getLoadAvg", GetLoadAvg);
env->SetMethod(target, "getUptime", GetUptime);
env->SetMethod(target, "getTotalMem", GetTotalMemory);
env->SetMethod(target, "getFreeMem", GetFreeMemory);
env->SetMethod(target, "getCPUs", GetCPUInfo);
env->SetMethod(target, "getOSType", GetOSType);
env->SetMethod(target, "getOSRelease", GetOSRelease);
env->SetMethod(target, "getInterfaceAddresses", GetInterfaceAddresses);
env->SetMethod(target, "getHomeDirectory", GetHomeDirectory);
env->SetMethod(target, "getUserInfo", GetUserInfo);
target->Set(FIXED_ONE_BYTE_STRING(env->isolate(), "isBigEndian"),
Boolean::New(env->isolate(), IsBigEndian()));
}
}
}
NODE_MODULE_CONTEXT_AWARE_BUILTIN(os, node::os::Initialize) //模块组册宏
复制代码
这里相当于在当前环境下的target对象上注册一个个函数,而后文中我们很快就能知道target实际上就是exports 即env->SetMethod(exports, "函数名", 函数); 而这个环境env就跟JavaScript的函数上下文环境一个道理,是Initialize的上下文环境。 根据上面JavaScript打印的process.binding('os')和下面nodejs的模块注册代码, 我们可以看出,二者无异 因此可以下结论:process.binding就是连接js和cpp的跨空间之门! 对了,看不出也没有关系,我还有解释上面的伪代码
function GetLoadAvg(){...}
...
function initialize(target,unused,context){
target.getLoadAvg=GetLoadAvg
target.getUptime=GetUptime
target.getTotalMem=GetTotalMemory
target.getFreeMem=GetFreeMemory
target.getCPUs=GetCPUInfo
target.getOSType=GetOSType
target.getOSRelease=GetOSRelease
target.getInterfaceAddresses=GetInterfaceAddresses
target.getHomeDirectory=GetHomeDirectory
target.isBigEndian=false;
}
os.initialize=initialize
NODE_MODULE_CONTEXT_AWARE_BUILTIN(os,os.initalize);
复制代码
如果要你自己设计一个模块,你会怎么设计?
首先,我们观察process.binding
接受一个'os'字符串作为模块标识
我们进一步想,光靠一个字符串当然不能找到模块,那么我们就需要设计一个'找'的过程,与'找'的目标 首先明确'找'的目标,应该是一个数据结构,这个数据结构光有名字name可不行,还得有它的输出对象exports。 现在我们就可以开始动手设计这个数据结构了。
'找'的目标node_module
struct node_module{
const char * name;
Object exports; //Object这里就不设计了,读者理解成一个定义对象的数据结构即可
}
复制代码
现在这个node_module数据结构已经设计好了,我们还得想办法存储这些模块,因为模块不止一个,这么多模块节点我们得统一管理,这里可以用链表,所以我们这里只需要预先全局定一个表头节点, 同时,在node_module新增一个成员link指向下一个节点
struct node_module{
const char * name;
Object exports;
struct node_module* link;
}
node_module *modlist_builtin;
复制代码
'找'的过程find
然后我们得设计'找'这个动作,node_module是一个节点对象,链表,我们就可以很自然的想象到遍历链表查找节点,实际上nodejs内部也是这么做的,这个'找'接受一个模块名字符串,然后输出模块
struct node_module* find(const char* name) {
struct node_module* mp
//mp是查找时的中间节点,modlist_builtin是全局已经注册好的模块列表头结点
for (mp = modlist_builtin; mp != nullptr; mp = mp->link) {
if (strcmp(mp->name, name) == 0)
break;
}
return (mp);
复制代码
在find的时候你可能会疑惑,我们的模块链表是怎么来的?链表中的模块又是怎么添加到模块链表中的? 那么接下来,我们就要设计注册的过程,将模块链表补充完整
'注册'
顺理成章,注册的时候自然就是补充modlist_builtin头结点的内容了,分两种情况,一种是头结点为空,一种是不为空 所以实现如下:
void node_module_register(node_module *m) {
if (modlist_builtin!=nullptr) {
m->link = modlist_builtin; //下一个节点指向modlist_buitin头节点
modlist_builtin = m;
}else{
modlist_builtin = m;
}
}
复制代码
注册和查找都做完了,最后还剩下便是加载,也就是上文所描述的时空之门process.binding,这个binding函数
加载
struct node_module* get_builtin_module(char* name) {
struct node_module* mp;
for (mp = modlist_builtin; mp != nullptr; mp = mp->link) {
//比较mp.name和name是否相同,strcmp是一个比较字符串的函数,它在相同的时候才会返回0
if (strcmp(mp->name, name) == 0) break;
}
return mp;
}
Object Binding(char *name) {
node_module *mod = get_builtin_module(name);
return mod->exports;
}
复制代码
这样就完成了一个最简单的模块,但是细心的小伙伴可能会发现了,nodejs中的模块应该是按需加载,而这里的node_module结构中包含了已经加载好的exports,这不符合按需的思想,那么接下来我们就要进行一些小改造, 首先,我们在struct node_module中不应该直接获取exports,而是获取一个注册exports的init函数,然后Binding改造成加载时运行注册函数结果即返回注册好的exports
struct node_module {
char * name;
Object (*init)(Object);
struct node_module* link;
};
Object Binding(char *name) {
node_module *mod = get_builtin_module(name);
Object exports;
return mod->init(exports);
}
复制代码
最终我们设计完了我们的模块,如果要注册一个模块,用户侧使用如下:
binding源码原理
这里的binding就是nodejs的process.binding,接下来就是直接讲述nodejs源码中的模块加载了。 nodejs源码Binding[位置:"src/node.cc"]
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; //声明exports对象
//如果缓存中有当前模块,就直接返回;
if (cache->Has(env->context(), module).FromJust()) {
exports = cache->Get(module)->ToObject(env->isolate());
args.GetReturnValue().Set(exports);
return;
}
...
//获得cpp核心源码模块,这里很重要,后面会细说。
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.
...
Local<Value> unused = Undefined(env->isolate());
mod->nm_context_register_func(exports, unused,
env->context(), mod->nm_priv); //很重要,mod->prive运行注册os模块时的initial函数
cache->Set(module, exports); //cache.module=exports
} else if (!strcmp(*module_v, "constants")) {
...
} else if (!strcmp(*module_v, "natives")) {
//获取native本地js模块,native模块的位置在“lib/”下
} else {
...
}
args.GetReturnValue().Set(exports); //将exports返回到nodejs本地环境,这时候process.binding('os')得到了返回值(一个对象)
}
复制代码
如果看不懂上面,也没关系,可以看看我写好的伪代码
function binding(args){
const env=Environment.getCurrent(args);
let module=args[0];
let module_v=module.toUtf8Value();
let cache=env.cache;//从全局获取缓存对象。
//查询cache,如果已经加载过模块了,就直接从缓存中返回
if(cache(module)){
return module.exports;
}
//重点逻辑,获取cpp核心模块
let mod=get_builtin_module(module_v);
if(mod){
let exports={};
mod.init(exports,undefined,env.context,mod.nm_priv); //exports被init初始化
}
return exports;//返回exports,到nodejs命令行环境中。
}
复制代码
接下来比较重要的是,get_builtin_module如何获取cpp核心源码模块?
get_builtin_module获取核心模块
这个函数内部的工作就是在一个名为modlist_builtin的c++核心模块链表上对比文件标识,从而返回相应的模块。
struct node_module* get_builtin_module(const char* name) {
struct node_module* mp;
//modlist_builtin是一个链表,链接着一个个已经注册好的模块,注册的过程后续会继续讲。
for (mp = modlist_builtin; mp != nullptr; mp = mp->nm_link) {
//比较nm_modname和name是否相同,strcmp是一个比较函数,比较诡异的是,它在相同的时候才会返回0
if (strcmp(mp->nm_modname, name) == 0)
break;
}
return (mp);
复制代码
最后,我们的疑惑应该只剩下,modlist_builtin是怎么来的了?请看下面分解: 还记得一开始os底层cpp源码里面最后有一句
NODE_MODULE_CONTEXT_AWARE_BUILTIN(os, node::os::Initialize)
复制代码
源码逻辑并不复杂,我们只需要知道这句话就是注册到链表上就好了
cpp源码模块注册
os源码模块最后调用了NODE_MODULE_CONTEXT_AWARE_BUILTIN注册模块
NODE_MODULE_CONTEXT_AWARE_BUILTIN(os, node::os::Initialize)
复制代码
上面这个宏调用了
#define NODE_MODULE_CONTEXT_AWARE_BUILTIN(modname, regfunc) \
NODE_MODULE_CONTEXT_AWARE_X(modname, regfunc, NULL, NM_F_BUILTIN) \
复制代码
而NODE_MODULE_CONTEXT_AWARE_X就是注册模块的关键宏, 实际上就是定义了一个模块结构体,然后把模块结构体通过node_module_register这个函数注册 定义如下:
#define NODE_MODULE_CONTEXT_AWARE_X(modname, regfunc, priv, flags) \
static node::node_module _module = \
{ \
NODE_MODULE_VERSION, \
flags, \
NULL, \
__FILE__, \
NULL, \
(node::addon_context_register_func) (regfunc), \
NODE_STRINGIFY(modname), \
priv, \
NULL \
}; \
node_module_register(&_module); \
}
//node_module的定义
struct node_module {
int nm_version;
unsigned int nm_flags;
void* nm_dso_handle;
const char* nm_filename;
node::addon_register_func nm_register_func;
node::addon_context_register_func nm_context_register_func;
const char* nm_modname;
void* nm_priv;
struct node_module* nm_link;
};
复制代码
上面定义了一个结构体_module包含了一个模块的基本信息,然后通过node_module_register注册到模块链表上
static node_module* modlist_builtin; //头结点
void node_module_register(void* m) {
if (m->nm_flags & NM_F_BUILTIN) {
//很经典的单链表指法
m->nm_link = modlist_builtin; //下一个节点指向modlist_buitin头节点
modlist_builtin = m; //mp成为新的头节点
}
...
}
复制代码