从源码角度,解读nodejs模块

1,239 阅读9分钟

本文正在参与技术专题征文Node.js进阶之路,点击查看详情

简略介绍

平时开发中,在不用某些模块解析框架的情况下,我们很少或者只用到(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模块:
os cpp原生模块 现在你可能会想,我为什么要了解nodejs底层,平时就直接用node.js开发不好吗?这里笔者给出一些理由:

  1. 如果你现在用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版本支持列表 可以见得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)

结果:

数据量1e81e91e10
js0.5134.67945.629
c++0.0290.2760.547

可见在效率上cpp已经把js碾过去了...但这里并不是鼓励在任何场合都用cpp编写代码,而是在这种大计算量场合使用cpp才比较合理。

快速扩展

如果你手头上已经有一个开发好的cpp项目要和现在的js项目对接,你会怎么处理?常见的处理手段有:

  1. 双方应用互相起一个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成为新的头节点
  }
 ...
}