![Node.js:来一打 C++ 扩展](https://wfqqreader-1252317822.image.myqcloud.com/cover/314/31186314/b_31186314.jpg)
2.2 什么是C++扩展
由于Node.js本身就是基于Chrome V8引擎和libuv,用C++进行开发的,因此自然能轻而易举对特定结构下使用了特定API进行开发的C++代码进行扩展,使得其在Node.js中被“require”之后能像调用JavaScript函数一样调用C++扩展里面的内容。
本节会从官方文档和Node.js的一些源码出发进行解析,逐步说明C++扩展在Node.js中的工作原理,让读者对其有一个整体的认知,从而对后续内容的阅读有帮助。
实际上,很大程度上我们可以将C++扩展和C++模块等同起来。
2.2.1 C++模块本质
众所周知,Node.js是基于C++开发的,所有底层头文件暴露的API也都是适用于C++的。
笔者在1.1节中曾提到过,当我们在Node.js中通过require()载入一个模块的时候,Node.js运行时会依次枚举后缀名进行寻径,其中就曾提到后缀名为*.node的模块,这是一个C++模块的二进制文件。
实际上,一个编译好的C++模块除了后缀名是*.node之外,它其实就是一个系统的动态链接库。说得直白一点,这相当于Windows下的*.dll、Linux下的*.so以及macOS下的*.dylib。
我们可以用一些十六进制的编辑器打开一个*.node的C++模块,看看其文件头的标识位。
如图2-2所示,这是笔者之前写的阿里云消息队列ONS的Node.js SDK,图中是该SDK的C++模块部分在Linux下编译出来的二进制文件的十六进制内容。我们发现它的标识位十六进制是0x7F454C46,其ASCII码所代表的内容是一个不可见空字符(ASCII码为0x7F)后面跟着"ELF",这就是一个Linux下动态链接库的标识。至于其他系统下编译好的C++模块,有兴趣的读者可自行验证。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/104_1.jpg?sign=1738833560-DUXarsRqLFevaw5iKp22DOJirc1ep88p-0-6c5646d662b27ddff9c2683cbff66545)
图2-2 ONS包中Linux下编译的C++模块的十六进制标识位
结合上述说法,我们大概能猜到,在Node.js中引入一个C++模块的过程,实际上就是Node.js在运行时引入了一个动态链接库的过程。运行时接受JavaScript代码中的调用,解析出来具体是扩展中的哪个函数需要被调用,在调用完获得结果之后再通过运行时返回给JavaScript代码,如图2-3所示。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/104_2.jpg?sign=1738833560-5fvlFRCPcQyilyZWIpYEFxfSbgFRTJZy-0-7f6dc7845f1758ae7a234efe3778abc1)
图2-3 原生函数和C++扩展函数
调用Node.js的原生C++函数和调用C++扩展函数的区别就在于前者的代码会直接编译进Node.js可执行文件中,而后者的代码则位于一个动态链接库中。
官方文档上有对于C++扩展的说明,我们也可以在后续章节中验证上面的这一猜想。
Node.js C++扩展是以C或者C++写的动态链接库,可以被Node.js以require()的形式载入,在使用的时候就好像它们就是Node.js模块一样。它们主要被用于在Node.js的JavaScript和C或者C++库之间建立起桥梁的关系。
2.2.2 Node.js模块加载原理
在读者以往写Node.js的经验中,Node.js载入一个源码文件或者一个C++扩展文件是通过Node.js中的require()函数实现的(这里不对ES6中的import做解析)。在第1章中笔者也介绍了,这些被载入的文件单位或者粒度就是模块(module)了。当然,C++模块也被称为C++扩展。
该函数既能载入Node.js的内部模块,又能载入开发者的JavaScript源码模块以及C++扩展。本节就对这3种类型模块的载入原理进行解读,让读者有一个更进一步的了解。
在阅读本节的时候,读者可以只看本书,也可以跟随本书的脚步打开Node.js的Git仓库6.9.4版本的源码,一起进行解读。
代码地址如下:https://github.com/nodejs/node/tree/v6.9.4。
1.Node.js入口
首先笔者先解析Node.js的入口。在Node.js 6.9.4版本中,Node.js在C++代码层面的入口在其源码的src/node_main.cc文件中。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/105_1.jpg?sign=1738833560-9hGeaVc2Q40GTqSBRrCExxZMgM64brkw-0-9f25f4309b360a0f5c22b2586cbdb249)
上述代码说明了进入C++主函数之后直接调用node这个命名空间中的Start函数,而这个函数则处于src/node.cc。
根据src/node.cc中Start函数的逐层深入,我们在LoadEnvironment函数中会发现如下这段代码。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/105_2.jpg?sign=1738833560-ZAs2NxWhjUa0iQ8Pb2fCRWgdpSEQfaiB-0-93d7edf364bf97e2eb578f48de22f97f)
这段代码的意思是Node.js执行lib/internal/bootstrap_node.js文件以进行初始化启动,这里还没有require的概念。文件中的源码没有经过require()函数进行闭包化操作,所以执行该文件之后得到的f_value就是这个bootstrap_node.js文件中所实现的那个函数对象。
由于V8的值(包括对象、函数等)均继承自Value基类,这里在得到函数的Value实例之后需要将其转化成能用的Function对象,然后以env->process_object()为参数执行这个从bootstrap_node.js中得到的函数。
分析了上面的代码,我们大概了解了Node.js入口启动的流程,如图2-4所示。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/106_2.jpg?sign=1738833560-dY3o8kejqtS8lOrLrkE8WOtYzb6jSFio-0-f1934e7f7e735bd66c14512be5cc47c1)
图2-4 Node.js入口启动的流程
2.process对象
前面笔者提到了执行Node.js初始化函数时会传入env->process_object(),而对应lib/internal/bootstrap_node.js文件中这个参数的含义其实就是process对象。
这个process对象就是Node.js中大家经常用到的全局对象process。具体的一些公共API可以在Node.js官方文档的process一节中查阅。
好的,现在抛开Node.js文档,回到process中来。这个env->process_object()的一些内容就是在src/node.cc中实现的。我们能很容易追踪到这个文件中的SetupProcessObject函数。
篇幅所限,这里就不列出这个函数的所有代码了。下面列出其部分设置让读者感受一下温暖,这是来自Node.js的关怀。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/107_1.jpg?sign=1738833560-0L15FGAGGpglZkf1XckbOxrAfxmhAKvA-0-08a30fb3bad9d50f29ba323a5839378d)
读者是不是觉得上面列举的方法、属性设置更熟悉了?除了binding和dlopen之外,其他几个都是Node.js文档中原原本本列出的process对象中暴露的API内容。关于这些函数的具体实现,有兴趣的读者可以自行翻阅Node.js的源码,毕竟本书不叫《Node.js源码解析》。
3.几种模块的加载过程
前面介绍了一些Node.js入口相关的内容之后,笔者接下来要将模块分4种类型,介绍其加载的过程。
这4种模块如下:
· C++核心模块;
· Node.js内置模块;
· 用户源码模块;
· C++扩展。
C++核心模块和Node.js内置模块属于1.1.2节中提到过的Node.js核心模块;而用户源码模块和C++扩展模块属于文件模块。
(1)C++核心模块
C++核心模块在Node.js源码中其实就是采用纯C++编写的,并未经过任何JavaScript代码封装过的原生模块,其有点类似于本书所介绍的C++扩展,而区别在于前者存在于Node.js源码中并且编译进Node.js的可执行二进制文件中,后者则以动态链接库的形式存在。
在介绍C++核心模块的加载过程之前,笔者先提一下前面出现过的process.binding函数。它对应的是src/node.cc文件中的Binding函数。姑且不管这个函数在哪里被用到,笔者先对源码进行一遍粗略的解析。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/108_1.jpg?sign=1738833560-0e6yQGv3DYs3Wb1yp5Qg5I9BFu07IFDj-0-db7b56ea73db01cbc5f44afc1ab1ac32)
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/109_1.jpg?sign=1738833560-jRvwZbwQ1SnEzb3y7dC3fXH9xjVK3xpT-0-e88be2320f591aad80cafbec229e9b58)
其中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目录下的JavaScript文件封装的file模块
。我们注意到这里获取核心模块用的是一个get_builtin_module函数,这个函数内部做的工作就是在一个名为modlist_builtin的C++核心模块链表上对比文件标识,从而返回相应的模块。
追根溯源,这些C++核心模块则是在node_module_register函数中被逐一注册进链表中的,我们可以阅读一下下面的代码。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/109_2.jpg?sign=1738833560-YO3yKPSGO1l7zCt7BnwUi7kBtoKlMbcP-0-1fc18f044e2e67698ba7475427294c98)
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/110_1.jpg?sign=1738833560-SukFeZznxQBmWQ0CBUfumiQPGymCOZkF-0-3d5c340e8bbe030e932f7e37fe7a94e7)
这个node_module_register函数清晰表达了,如果传入待注册模块的标识位是内置模块(mp->nm_flags&NM_F_BUILTIN),就将其加入C++核心模块的链表中;否则将认为它是其他模块,由于这个条件分支与本书关联性不大,笔者对后者就不进行深究了。
我们继续对C++核心模块分析下去。在src/node.h中有一个宏是用于注册C++核心模块的。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/110_2.jpg?sign=1738833560-gvuV88EW4VoX3bzbrGOSEr37kxwLDvON-0-5bdd98a77f79cca9f4e2ff5207cf7fcd)
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/111_1.jpg?sign=1738833560-kglVeU77lFIAE9UYJ8HBvHAdyeWx0Rx0-0-a4f927c270ed6186bfb4cbad0416d9eb)
结合之前看的node_module_register函数和这个src/node.h中的宏定义,我们发现只要Node.js在其C++源码中调用NODE_MODULE_CONTEXT_AWARE_BUILTIN这个宏,就有一个模块会被注册进Node.js的C++核心模块链表中。
那么问题来了:什么时候会有这样的注册呢?读者不妨自己动手,到之前提到过的file模块看看吧。它的源码是src/node_file.cc,这里的最后一行就是答案了。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/111_2.jpg?sign=1738833560-IUcHZQtLqYMETOm3j3uKeLJDblkm7yMp-0-a7b216b5df41df5d5456a0fe30dfd65f)
这个宏被展开后的结果将会是这样的:
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/111_3.jpg?sign=1738833560-rbruGeJKmVa1XCJmMhIdm1zENBtI6ict-0-c5f8c18f8c2a73d6d44cc89c4eabd142)
至此,真相大白。也就是说,基本上在每个C++核心模块的源码末尾都有一个宏调用将该模块注册进C++核心模块的链表中,以供执行process.binding时进行获取。
(2)Node.js内置模块
Node.js内置模块基本上等同于其官方文档中放出来的那些模块。这些模块大多是在源码lib目录下以同名JavaScript代码的形式被实现,而且很多Node.js内置模块实际上都是对C++核心模块的一个封装。
如lib/crypto.js中就有一段const binding=process.binding("crypto");这样的代码,它的很多内容都是基于C++核心模块中的crypto进行实现的。
说到这里,大家可能有一个疑问,为什么明明在Node.js源码下面有一个lib目录,并且里面有一堆堆的JavaScript代码,如net、fs等,为什么在通过下载、安装或者编译好后就只有一个单独的二进制可执行文件了呢,难道JavaScript代码也能被编译到Node.js的可执行文件吗?
说得一点儿也没错,这些lib下的JavaScript文件的确被编译进Node.js的可执行文件了。下面笔者会一一道来。
请把注意力转移至Node.js的启动脚本lib/internal/bootstrap_node.js中。其代码的最下面位置有一个NativeModule类的声明。为了更突出关键代码,本书中的该部分源码略去了特殊情况分支和缓存的处理。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/112_1.jpg?sign=1738833560-QR6adsten9q5fBTx8ko1ZRR2tWUPT5hx-0-7a6de3387a606a4f7358c722010fe59c)
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/113_1.jpg?sign=1738833560-j09JPXVSc5pTizPL0CodRcPA4USWtdKW-0-675ffdf24dcbfdf4aae8334e2e7b23f9)
这个NativeModule就是Node.js内置模块的相关处理类了。它有一个叫require的静态函数,当其参数id值为'native_module'时返回的是它本身,否则就进入nativeModule.compile进行编译。
进而把目光转向compile函数,它的第一行代码就是获取该模块的源码。
注意,接下来就是我们本节最开始提出的疑问的答案剖析。
源码是通过NativeModule.getSource获取的,NativeModule.getSource函数返回的是NativeModule._source数组中的相应内容。
那么,这个NativeModule._source是哪里来的呢?
NativeModule._source=process.binding('natives');这一行代码说明了NativeModule._source的出处。
前面笔者详细介绍的process.binding函数在这里派上用场了。
我们回过头去仔细看一下src/node.cc中Binding的源码,其中有一段判断是这样的。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/114_1.jpg?sign=1738833560-fbIkr0vBuEXr2Y2ZeBviXmMCTIUeoCax-0-2d5a85e701165ba0d471550c2b27e00a)
也就是说执行process.binding('natives')返回的结果是DefineJavaScript函数中处理的内容。
马不停蹄来到了src/node_javascript.cc中,让我们好好观察一下这个DefineJavaScript。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/114_2.jpg?sign=1738833560-sTZx6UEswZHxR6QIRGM2VZGY6SIcmtl0-0-fe5e1f614fce468119ac2aae484e6497)
从上述代码中我们了解到了它做的事情就是遍历一遍natives数组里面的内容,并将其一一加入要返回的对象中,其中对象名的键名为源码文件名标识,键值是源码本体的字符串。
现在能走到这一步已经很不容易了。细心的读者会发现逛遍整个项目都找不到这个natives在哪里。
让我们放开“脑洞”想一想,所有的Node.js内置模块本来“一个萝卜一个坑”地在lib目录下好好待着,但是到这边载入的时候却在Node.js的C++源码中以natives变量的形式存在——这中间发生了什么?
其实说来也简单——这一层是在编译时做的。
请打开Node.js的GYP配置文件node.gyp。
其中有一步(也就是有一个目标配置)是node_js2c,在这一步中做的事情就是用Python去调用一个名为tools/js2c.py的文件。而这个js2c.py就是问题的关键所在了。
这是一个Python脚本,主要的作用是将lib下的JavaScript文件转换成src/node_natives.h文件。
熟悉Python的读者可以自行挖掘一下该文件的具体实现,由于篇幅的原因本书就不展开详述了。
这个src/node_natives.h文件会在Node.js编译前完成,这样在编译到src/node_javascript.cc时它所需要的src/node_natives.h头文件就存在了。
src/node_natives.h源文件经过js2c.py转换后,会以类似于下述代码的形式存在。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/115_1.jpg?sign=1738833560-0dvcBWAc2eBr42abaMjiSI0hBcm22NTv-0-a43178aa53dfb95439966da3811bfcc1)
在此可以看出,这样的文件形式正好满足了src/node_javascript.cc中DefineJavaScript函数所需要的natives格式。
也就是说,在Node.js中调用NativeModule.require的时候,会根据传入的文件标识来返回相应的JavaScript源文件内容,如"dgram"对应的是lib/dgram.js中的JavaScript代码字符串。
把传说中编译进Node.js二进制文件的JavaScript代码的神秘面纱揭开以后,我们现在回到NativeModule.compile函数中来。它会在刚获取到的内置模块JavaScript源码字符串前后用(function(exports,require,module,__filename,__dirname){和});进行包裹,形成一段闭包代码。之后将其放入vm中运行,并传入事先准备好的module和exports对象供其导出。
如此一来,内置模块就完成了加载。
(3)用户源码模块
用户源码模块指的是用户在项目中的Node.js源码,以及所使用的第三方包中的模块。一句话概括,就是非Node.js内置模块的JavaScript源码模块。
这些模块是在程序运行时,在需要被使用的时候按需被require()函数加载的。
与内置模块类似,每个用户源码模块会被加上一个闭包的头尾,然后Node.js执行这个闭包产生结果。
我们打开lib/module.js这个内置模块可以找到其细节上的实现。我们平时在源码中执行的require()函数其实就是这个Module类实例对象的require()函数。
一个Module类的实例对象就是一个用户源码模块本体,用户通过require()所引入的文件代码及其在vm沙盒中的结果就是这个模块的核心。只不过我们日常稍微模糊化了这个Module和用户源码的概念,把它们都称作模块。
我们在平时写Node.js代码时经常用到的module.exports的module,指的就是Module类的实例对象,exports就是这个对象中的一个部分。当我们写module.exports=foo的时候就是对这个module对象的exports变量重新赋值。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/117_1.jpg?sign=1738833560-LLGztoR49tSirTWl0gsoNNQwGLxiehOZ-0-cf62bf449cc9eeb90879a6a0b74fa742)
require()直接调用了Module._load这个静态函数,并声明isMain(是否是入口模块)为false。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/117_2.jpg?sign=1738833560-p81zzTQ7qnvl5Iak2BDUvW6zXMJBFLbm-0-f7dd104fec3e582b107617a4d5d1abde)
Module._load中大致分了几步走,具体流程如图2-5所示。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/118_1.jpg?sign=1738833560-2HXiyYWlPsE7QVtUS2QJFJ2Eq6gzAIlH-0-126c484b5a58e0324e678da3c5380315)
图2-5 Module._load流程图
在流程图中笔者简化了“加载模块”这个步骤,因为在_load函数中,它是作为另一个函数被调用的——tryModuleLoad(module,filename)。顾名思义,tryModuleLoad是尝试载入模块的意思,其实它就是在执行module.load时多加了一些错误处理的过程,本体其实还是module对象的load函数。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/118_2.jpg?sign=1738833560-JqJggiO5n6EKLVbjTb3GtHy6QprmE7HW-0-2938cc7671181833aa0422ae8eca50c6)
load()函数的源码相当于一个适配器,其根据传进来文件名的后缀名不同,会使用不同的载入规则。默认情况下,有3种规则:
· Module._extensions[".js"]
· Module._extensions[".json"]
· Module._extensions[".node"]
本节将介绍Module._extensions[".js"]这种规则。该规则做的事情分两步:
① 同步读取源码(filename)的内容,使用fs.readFileSync;
② 调用module._compile()函数编译源码并执行。
这其中的第二步就很有讲究。下面先看看module._compile()代码。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/119_1.jpg?sign=1738833560-rZNSnnBeZFisP6rHv77dRWGitVxNmtqF-0-672913ecf8e77eaed9776624d228fbce)
其实这个函数与前面提到的NativeModule的_compile函数类似,都是生成闭包源码,然后传入相应的函数执行,流程如图2-6所示。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/120_1.jpg?sign=1738833560-P4E2EGh8aiakjqj3dognir0dYthYQpuh-0-b8c9264f8be02a5e783af976d6f48c26)
图2-6 Module.prototype._compile流程图
一个模块的源码经过闭包化之后,就形成了一个接收exports、require、module、__filename和__dirname的闭包函数。这就是我们平时编写代码的时候能直接使用exports、module、require等内容的原因了。在我们编写的源码模块被载入的时候,这些变量会随着闭包传进来而被使用。这个闭包会在第一次加载该模块的时候执行一次,之后就一直存在于模块缓存中(除非手动清除缓存),这就解开了我们在第1章中提到过的一个模块逻辑代码只会被执行一次的疑惑。
在1.1.2节中写道:实际上在Node.js运行中,通常情况下一个包一旦被加载了,那么在第二次执行require()的时候就会在缓存中获取暴露的API,而不会重新加载一遍该模块里面的代码再次返回。
值得注意的是,传进来的module就是笔者本节所讲的Module类的对象实例,所以我们对module.exports赋值实际上就是对这个传入的module对象进行赋值;传进来的require就是经包装后的Module.prototype.require,其在某种意义上等同于Module._load。
现在笔者再梳理一下用户源码模块的载入流程。
① 开发者调用require()(这在某种意义上等同于调用Module._load)。
② 闭包化对应文件的源码,并传入相关参数执行(若有缓存,则直接返回)。
③ 通常在执行过程中module.exports或者exports会被赋值。
④ Module.prototype._load在最后返回这个模块的exports给上游。
入口模块
在我们以非REPL形式执行Node.js的时候,通常会使用node <文件名>的命令来启动一个Node.js程序。而这个指定的文件就是一个入口模块了。入口模块其实也是用户源码模块的一种,只不过它将作为程序入口被执行而已。
在src/node_main.cc中,启动Node.js的一行代码是node::Start(argc,argv),上面提到的文件名就会在argv这个数组中被传进Start函数中。辗转之后,被处理后的argv会被传到NodeInstanceData类的构造函数中。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/121_1.jpg?sign=1738833560-Ahc4q3RizVkbStrhuv6DU1XStZfLqmwH-0-2a28d4c9e18b7241dd854fce97d05fcc)
在接下来的StartNodeInstance()函数中以及后面的几度转手中,里面的argv和exec_argv会被传到process对象中供lib/internal/bootstrap_node.js使用。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/121_2.jpg?sign=1738833560-cOV1nGxD9fm423VhiHcwzvzE8rqSdRtZ-0-df30520e825b7961ad1699778ab01f0e)
至于argv和exec_argv的区别,读者可以看看src/node_main.cc里面ParseArgs函数中的具体实现,这里不再赘述。
我们在lib/internal/bootstrap_node.js中可以看出来,整个闭包函数被执行的时候,会执行里面的startup()函数,这样才算进入了Node.js的JavaScript代码启动的流程。
该函数中提及了,如果是正常以node <文件名>的形式启动,会进入这么一段逻辑:
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/122_2.jpg?sign=1738833560-lnl8aIRi3KZfi7WgSD4VNOFFlMEPoKhk-0-4528125435eabe5d969d4dc1cb51ce5d)
也就是说取出文件名(即process.argv[1])并将其格式化成绝对路径,然后执行run()进行启动。通常情况下这个run()函数会立即执行以参数形式传进去的函数,也就是执行Module.runMain()。
Module就是lib/module.js这个内置模块。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/122_3.jpg?sign=1738833560-SB7FDfvJhUvF63M4pWGplS2FGbKemjQW-0-4112b9d7ac9c83f6e80d1b9bddd35c89)
我们可以看到这里调用Module._load时传的参数与通过require()传入的参数相比略有不同。第二个参数parent变为null,因为入口文件上面没有父模块了;第三个参数表示入口文件的布尔型参数也变成true,这个参数一旦为true,在module对象生成之后,它会被顺便赋值到process.mainModule。
这个函数直接加载执行了命令行中指定的文件,并且执行和清空第一次nextTick中的一些回调。以上就是入口模块的加载过程。
(4)C++扩展
根据前面所述,用户源码模块与C++扩展模块加载时的区别仅仅是在Module.prototype.load函数中进行区分的。我们可以再回过头来看规则,一个是Module._extensions[".js"],而另一个是Module._extensions[".node"](这里我们忽略*.json文件)。
也就是说,我们如果将一个C++扩展模块作为Node.js入口文件,理论上也是可以的。毕竟Node.js入口模块的执行函数Module.runMain是通过调用函数Module._load(process.argv[1],null,true)来完成的。
想用C++扩展模块作为入口文件进行尝试的读者也可以进入随书源码的“2.cpp entry”目录进行取证。
进入“2.cpp entry”目录,并依次执行下面的命令:
$ node-gyp configure
$ node-gyp build
在万事俱备之后,执行$ node build/Release/entry.node会有下面的结果:
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/123_1.jpg?sign=1738833560-VPL3GRiFt6lDbAL22a52UN5l8Rm9y7JG-0-071ea35c05bd41f705587bf7ec908638)
由上面的执行结果我们可以看出,node <C++扩展模块>的命令也可以正常执行,效果跟用户源码模块并无两样。该目录下C++源码的意思转义为Node.js源码大致如下:
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/123_2.jpg?sign=1738833560-T6XtfzJOeJCAwc51gaikQuYwzUS2sFIW-0-a509c0fdb8a68e686fa452c64a44a3f3)
所以输出如上命令行的结果也是在意料之中的。
笔者接下来剖析一下这个加载*.node扩展的函数——请打开lib/module.js。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/124_1.jpg?sign=1738833560-eo4Uvj8y6IS0Ivt15k9vdvukk6iDLJXN-0-ef68430841304003974dca12efbb52b1)
简而言之,载入*.node的C++扩展直接使用了process.dlopen函数。dlopen是在src/node.cc中的SetupProcessObject里面被挂载上去的,它实际上对应的函数是这个src/node.cc文件中的DLOpen函数。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/124_2.jpg?sign=1738833560-mZpEEzA7OB0mDLrESd1ANgdUEee2GHsh-0-49f92188f3a7b0e375407316fcac0a13)
DLOpen函数先使用uv_dlopen函数打开了*.node扩展(也就是动态链接库),将其载入到内存uv_lib_t中。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/125_2.jpg?sign=1738833560-TPqzsdCmTguOMrghINnTEmFKLJsNl5f2-0-1089cfab34f91ef2c725658105235d42)
然后通过mp->nm_dso_handle将使用uv_dlopen加载的动态链接库句柄转移到node_module结构体的实例对象上来。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/125_3.jpg?sign=1738833560-DFOtAYZEPsILzVUubX896SqtxUtIdvS1-0-3307b547d7aadc2759440251fb825746)
在这个DLOpen函数中的后续内容我们先放一下,这里看一下uv_dlopen加载*.node扩展的时候发生了什么事吧。
首先这个entry.node所对应的源码entry.cpp中有如下代码:
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/125_4.jpg?sign=1738833560-GyR95ivNaBQ4ZqKFrzSmkq18N5X4jACI-0-0281aa54e746ecfcb088442f17a507d8)
这是一个宏,类似的宏在前面C++核心模块相关内容中介绍过。这就是将一个模块注册进Node.js的模块列表。这个宏在展开后的逻辑是去执行src/node.cc里的node_module_register函数。为了方便阅读,这里再给出该函数相关的逻辑代码。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/125_5.jpg?sign=1738833560-Xnq365w7jPNsoaBhvfjgUQGbx8qqJhV8-0-16132e9c1dba6d798a9fd1f0a38b73c0)
当然,通常情况下Node.js是已经初始化好了的,所以不会进入!node_is_initialized这个条件分支;而且一个C++扩展显然不是一个内置的模块,那么mp->nm_flags&NM_F_BUILTIN也不成立。最后,就只剩下modpending=mp;这个逻辑了。也就是说,把由C++扩展(*.node文件)中注册的模块赋值给modpending,看变量名我们就知道这是一个注册好的待处理的模块。
弄明白了前面说的这一点,我们就知道了在uv_dlopen函数执行的时候发生了什么事情——加载*.node模块(由于NODE_MODULE宏将模块赋值给modpending)。
那么在DLOpen的后续逻辑中我们的思路就清晰起来了。在uv_dlopen之后有这样的两句代码:
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/126_2.jpg?sign=1738833560-xsDw1U7OXh4a0XMpftvzc8PG3SjvmEvq-0-203797a9e6e0376bb7dd201c746f164b)
就是把刚加载赋值好的modpending取出来赋值给mp,并将modpending指向一个空指针(以免发生野指针等情况)。
好了,mp就是刚通过uv_dlopen加载进来的动态链接库通过NODE_MODULE宏生成的模块对象处理器了。不过,这个模块对象处理器还只是空壳,需要将module和exports两个对象传进去才能把要导出的内容挂载上去,这就跟先前的用户源码模块编译一样。
于是接下去的步骤就是将exports和module挂载上导出内容。
在“2.cpp entry”中,我们展开NODE_MODULE之后可以得知,nm_register_func就是init函数,所以会进入下面的一个分支条件中执行,也就是把module和exports两个对象传给init函数执行。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/126_3.jpg?sign=1738833560-f4ilWfmhxc7EDrAqWzOTjQk27ZwS8sOI-0-a905f49e97c13bf4c90ce041d6d4bb86)
而通过阅读“2.cpp entry”中的init函数我们可以得知,在这里把RunCallback函数挂载到exports对象上,并且调用console.log输出了module和exports两个对象。
为了更清晰地展示DLOpen函数的流程,笔者画了一个关于DLOpen函数的流程图,如图2-7所示。
![](https://epubservercos.yuewen.com/29FF84/16896237005617906/epubprivate/OEBPS/Images/127_1.jpg?sign=1738833560-gRN34eIbReEsLfnPdcbnbRC6t90I303V-0-54a942b9be532938846160201dd51d7a)
图2-7 DLOpen流程图
至此,几种Node.js模块的最后一种C++扩展模块加载原理也解析完成了。
可能有些读者有疑问:怎么保证下次用到这个modpending的时候它是当前加载注册的模块,而不会被别的模块覆盖呢?
Node.js在主事件循环中的所有操作都是单线程的,而一个按照正常Node.js规范写的代码或者模块当然也是这样的。这样require函数加载一个C++模块的时候也是单线程的。所以,在加载Node.js模块的时候就不存在资源抢占和锁的问题。
2.2.3 小结
本节首先介绍了C++扩展模块的本质,其实际上就是一个对应各操作系统的动态链接库,只不过暴露出了特定的API。
然后介绍了4种不同类型的Node.js模块的加载原理。
其中C++核心模块会通过NODE_MODULE_CONTEXT_AWARE_BUILTIN等宏将不同的模块注册进Node.js C++核心模块链表中;而Node.js内置模块则会在Node.js编译时被写入C++源码中并被编译到Node.js可执行二进制文件中,并在恰当的时机被拿出来闭包化导出;用户源码模块会在首次执行require的时候被读取源码并闭包化导出,然后再加入模块缓存中;C++扩展则会在首次执行require的时候通过uv_dlopen加载该扩展的*.node动态链接库文件,在链接库内部把模块注册函数赋值给modpending,然后将执行require时传入的module和exports两个对象传入模块注册函数进行导出。
至此,希望读者对Node.js的模块原理尤其是其C++扩展模块的原理有一个更深层次的理解。
2.2.4 参考资料
[1]C/C++Addons:https://nodejs.org/docs/v6.9.4/api/addons.html#addons_addons.
[2]NodeJS源码详解——process对象:http://blog.hellofe.com/nodejs/2013/10/24/Learn-Node-Source-Code-One/.
[3]朴灵.深入浅出Node.js[M].北京:人民邮电出版社,2013.20-27.
[4]详解NodeJs的VM模块:http://www.alloyteam.com/2015/04/xiang-jie-nodejs-di-vm-mo-kuai/.