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++模块,有兴趣的读者可自行验证。
图2-2 ONS包中Linux下编译的C++模块的十六进制标识位
结合上述说法,我们大概能猜到,在Node.js中引入一个C++模块的过程,实际上就是Node.js在运行时引入了一个动态链接库的过程。运行时接受JavaScript代码中的调用,解析出来具体是扩展中的哪个函数需要被调用,在调用完获得结果之后再通过运行时返回给JavaScript代码,如图2-3所示。
图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文件中。
上述代码说明了进入C++主函数之后直接调用node这个命名空间中的Start函数,而这个函数则处于src/node.cc。
根据src/node.cc中Start函数的逐层深入,我们在LoadEnvironment函数中会发现如下这段代码。
这段代码的意思是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所示。
图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的关怀。
读者是不是觉得上面列举的方法、属性设置更熟悉了?除了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函数。姑且不管这个函数在哪里被用到,笔者先对源码进行一遍粗略的解析。
其中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函数中被逐一注册进链表中的,我们可以阅读一下下面的代码。
这个node_module_register函数清晰表达了,如果传入待注册模块的标识位是内置模块(mp->nm_flags&NM_F_BUILTIN),就将其加入C++核心模块的链表中;否则将认为它是其他模块,由于这个条件分支与本书关联性不大,笔者对后者就不进行深究了。
我们继续对C++核心模块分析下去。在src/node.h中有一个宏是用于注册C++核心模块的。
结合之前看的node_module_register函数和这个src/node.h中的宏定义,我们发现只要Node.js在其C++源码中调用NODE_MODULE_CONTEXT_AWARE_BUILTIN这个宏,就有一个模块会被注册进Node.js的C++核心模块链表中。
那么问题来了:什么时候会有这样的注册呢?读者不妨自己动手,到之前提到过的file模块看看吧。它的源码是src/node_file.cc,这里的最后一行就是答案了。
这个宏被展开后的结果将会是这样的:
至此,真相大白。也就是说,基本上在每个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类的声明。为了更突出关键代码,本书中的该部分源码略去了特殊情况分支和缓存的处理。
这个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的源码,其中有一段判断是这样的。
也就是说执行process.binding('natives')返回的结果是DefineJavaScript函数中处理的内容。
马不停蹄来到了src/node_javascript.cc中,让我们好好观察一下这个DefineJavaScript。
从上述代码中我们了解到了它做的事情就是遍历一遍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转换后,会以类似于下述代码的形式存在。
在此可以看出,这样的文件形式正好满足了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变量重新赋值。
require()直接调用了Module._load这个静态函数,并声明isMain(是否是入口模块)为false。
Module._load中大致分了几步走,具体流程如图2-5所示。
图2-5 Module._load流程图
在流程图中笔者简化了“加载模块”这个步骤,因为在_load函数中,它是作为另一个函数被调用的——tryModuleLoad(module,filename)。顾名思义,tryModuleLoad是尝试载入模块的意思,其实它就是在执行module.load时多加了一些错误处理的过程,本体其实还是module对象的load函数。
load()函数的源码相当于一个适配器,其根据传进来文件名的后缀名不同,会使用不同的载入规则。默认情况下,有3种规则:
· Module._extensions[".js"]
· Module._extensions[".json"]
· Module._extensions[".node"]
本节将介绍Module._extensions[".js"]这种规则。该规则做的事情分两步:
① 同步读取源码(filename)的内容,使用fs.readFileSync;
② 调用module._compile()函数编译源码并执行。
这其中的第二步就很有讲究。下面先看看module._compile()代码。
其实这个函数与前面提到的NativeModule的_compile函数类似,都是生成闭包源码,然后传入相应的函数执行,流程如图2-6所示。
图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类的构造函数中。
在接下来的StartNodeInstance()函数中以及后面的几度转手中,里面的argv和exec_argv会被传到process对象中供lib/internal/bootstrap_node.js使用。
至于argv和exec_argv的区别,读者可以看看src/node_main.cc里面ParseArgs函数中的具体实现,这里不再赘述。
我们在lib/internal/bootstrap_node.js中可以看出来,整个闭包函数被执行的时候,会执行里面的startup()函数,这样才算进入了Node.js的JavaScript代码启动的流程。
该函数中提及了,如果是正常以node <文件名>的形式启动,会进入这么一段逻辑:
也就是说取出文件名(即process.argv[1])并将其格式化成绝对路径,然后执行run()进行启动。通常情况下这个run()函数会立即执行以参数形式传进去的函数,也就是执行Module.runMain()。
Module就是lib/module.js这个内置模块。
我们可以看到这里调用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会有下面的结果:
由上面的执行结果我们可以看出,node <C++扩展模块>的命令也可以正常执行,效果跟用户源码模块并无两样。该目录下C++源码的意思转义为Node.js源码大致如下:
所以输出如上命令行的结果也是在意料之中的。
笔者接下来剖析一下这个加载*.node扩展的函数——请打开lib/module.js。
简而言之,载入*.node的C++扩展直接使用了process.dlopen函数。dlopen是在src/node.cc中的SetupProcessObject里面被挂载上去的,它实际上对应的函数是这个src/node.cc文件中的DLOpen函数。
DLOpen函数先使用uv_dlopen函数打开了*.node扩展(也就是动态链接库),将其载入到内存uv_lib_t中。
然后通过mp->nm_dso_handle将使用uv_dlopen加载的动态链接库句柄转移到node_module结构体的实例对象上来。
在这个DLOpen函数中的后续内容我们先放一下,这里看一下uv_dlopen加载*.node扩展的时候发生了什么事吧。
首先这个entry.node所对应的源码entry.cpp中有如下代码:
这是一个宏,类似的宏在前面C++核心模块相关内容中介绍过。这就是将一个模块注册进Node.js的模块列表。这个宏在展开后的逻辑是去执行src/node.cc里的node_module_register函数。为了方便阅读,这里再给出该函数相关的逻辑代码。
当然,通常情况下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之后有这样的两句代码:
就是把刚加载赋值好的modpending取出来赋值给mp,并将modpending指向一个空指针(以免发生野指针等情况)。
好了,mp就是刚通过uv_dlopen加载进来的动态链接库通过NODE_MODULE宏生成的模块对象处理器了。不过,这个模块对象处理器还只是空壳,需要将module和exports两个对象传进去才能把要导出的内容挂载上去,这就跟先前的用户源码模块编译一样。
于是接下去的步骤就是将exports和module挂载上导出内容。
在“2.cpp entry”中,我们展开NODE_MODULE之后可以得知,nm_register_func就是init函数,所以会进入下面的一个分支条件中执行,也就是把module和exports两个对象传给init函数执行。
而通过阅读“2.cpp entry”中的init函数我们可以得知,在这里把RunCallback函数挂载到exports对象上,并且调用console.log输出了module和exports两个对象。
为了更清晰地展示DLOpen函数的流程,笔者画了一个关于DLOpen函数的流程图,如图2-7所示。
图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/.