精华 Node.js挖掘之五:浅析模块加载流程,我们的应用如何启动以及如何加载依赖模块
发布于 4 年前 作者 LanceHBZhang 6285 次浏览 最后一次编辑是 3 年前 来自 分享

本文所做的研究基于Node.js v0.12.4 Linux版本。 Node.js挖掘进行到第5篇。

我们的Node应用不可避免,直接或间接地引用第三方模版。这些模块或是Node自带的,或是发布在npm上的。 本文主要探讨三个问题:

  1. Node自带的 builtin模块和native模块加载详细过程
  2. 应用启动详细过程
  3. 应用启动后如何加载依赖模块

应用加载和启动的过程涉及到模块的加载,所以放在第二个来探讨。

1. 模块加载详细过程

严格来讲,Node世界里面分以下几种模块:

  1. builtin module: Node中以c++形式提供的模块,如tcp_wrap、contextify等

  2. constants module: Node中定义常量的模块,用来导出如signal, openssl库、文件访问权限等常量的定义。如文件访问权限中的O_RDONLY,O_CREAT、signal中的SIGHUP,SIGINT等。

  3. native module: Node中以JavaScript形式提供的模块,如http,https,fs等。有些native module需要借助于builtin module实现背后的功能。如对于native模块buffer ,还是需要借助builtin node_buffer.cc中提供的功能来实现大容量内存申请和管理,目的是能够脱离V8内存大小使用限制。 有一个比较重要的注意点:native module一般需要通过process.binding()的方式来引用builtin module。例如,在 net.js里面就通过var TCP = process.binding(‘tcp_wrap’).TCP这样的方式引用builtin module tcp_wrap。

  4. 3rd-party module: 以上模块可以统称Node内建模块,除此之外为第三方模块,典型的如express模块。

1.1 builtin module和native module生成过程

1.png

图1:builtin module和native module生成过程

图1展示了这两个模块的生成过程。

native JS module的生成过程相对复杂一点,把node的源代码下载下来,自己编译后,会在 out/Release/obj/gen目录下生成一个文件node_natives.h。如图2所示。

1.png

图2:node源码编译后的输出截图

该文件由js2c.py生成。 js2c.py会将node源代码中的lib目录下所有js文件以及src目录下的node.js文件中每一个字符转换成对应的ASCII码,并存放在相应的数组里面。如图3的截图所示(数组内容被截掉一部分)。

1.png

图3: node_natives.h截图

图3左边的数组node_native,对应的文件为src/node.js。数组里面数字分别为node.js中每个字符的ASCII码。图3右边为node_natives.h其余代码,在1.2节会用到。

builtin C++ module生成过程较为简单。每个builtin C++模块的入口,都会通过宏NODE_MODULE_CONTEXT_AWARE_BUILTIN扩展为一个函数。例如对于tcp_wrap模块而言,会被扩展为函数static void register_tcp_wrap (void) attribute((constructor))。 熟悉GCC的同学会知道通过__attribute_((constructor))修饰的函数会在node的main()函数之前被执行,也就是说,我们的builtin C++模块会被main()函数之前被加载进modlist_builtin链表。modlist_builtin是一个struct node_module类型的指针,以它为头,get_builtin_module()会遍历查找我们需要的模块。

对于node自身提供的模块,其实无论是native JS模块还是builtin C++模块,最终都在编译生成可执行文件时,嵌入到了ELF格式的二进制文件node里面,输入命令”readelf -s node|grep node_native”可以看到详细的信息。 而对这两者的提取方式却不一样。对于JS模块,使用process.binding(“natives”),而对于C++模块则直接用get_builtin_module()得到,这部分会在1.2节讲述。

1.2 详解Binding()

在node.cc里面提供了一个函数Binding()。当我们的应用或者node内建的模块调用require()来引用另一个模块时,背后的支撑者即是这里提到的Binding()函数。 后面会讲述这个函数如何支撑require()的。这里先主要剖析这个函数。

111.png

图4:Binding()函数代码截图

图4是函数代码截图。可以看到函数主要为三类模块服务: builtin, constants以及native。对这三类模块,由exports带回的值所代表的意义是不一样的。

builtin优先级最高。对于任何一个需要绑定的模块,都会优先到builtin模块列表modlist_builtin中去查找。查找过程非常简单,直接遍历这个列表,找到模块名字相同的那个模块即可。 找到这个模块后,模块的注册函数会先被执行,且将一个重要的数据exports返回。 对于builtin module而言,exports object包含了builtin C++模块暴露出来的接口名以及对于的代码。例如对模块tcp_wrap而言,exports包含的内容可以用如下格式表示: {“TCP”: “/function code of TCPWrap entrance/”, “TCPConnectWrap”: “/function code of TCPConnectWrap entrance/”}。

constants模块优先级次之。node中的常量定义通过constants导出。 导出的exports格式如下: {“SIGHUP”:1, “SIGKILL”:9, “SSL_OP_ALL”: 0x80000BFFL}

对于native module而言,图3中除了数组node_native之外,所有的其它模块都会导出到exports。格式如下: {“_debugger”: _debugger_native , “module”: module_native ,“config”: config_native } 其中,_debugger_native,module_native等为数组名,或者说就是内存地址。

对比上面三类模块导出的exports结构会发现对于每个属性,它们的值代表着完全不同的意义。对于builtin 模块而言,exports的TCP属性值代表着函数代码入口,对于constants模块,SIGHUP的属性值则代表一个数字,而对于native模块,_debugger的属性值则代表内存地址(准确说应该是 .rodata段地址)。

1.3 process

如果我们去查看Node在线的API文档,会发现有一个process类,而且该类提供了若干方法。另外查看src\node.js源代码,会发现调用了大量的process.binding()。那么这个process到底是什么呢,源代码在哪里?又是如何提供给我们使用的?

在node.cc Start()函数内部,会调用另外一个函数CreateEnvironment()。在这个函数内部,利用V8的function template机制,构建了一个process类以及其对应的方法和属性。读者可以参考笔者的“Node.js挖掘之四:从libuv到JS,一个TCP连接事件引发的一系列callback的逆向调用过程”的1.1节了解如果使用function template去构建一个prototype based JS类。

通过函数CreateEnvironment()以及它内部调用的SetupProcessObject()函数:

  1. process类提供了若干方法,包括binding, getuid, setuid, pid等。而这些方法背后支撑的代码由node.cc对应的函数提供。
  2. process类还提供了若干属性,包括node, v8版本等。
  3. 另外,我们通过node启动我们的应用时,输入的额外参数也会被记录在process类属性中。

构建好的process类会产生一个对象,名为process_object,并且将其作为V8的Persistent handle通过env->set_process_object()函数保存。读者可以参照“Node.js挖掘之四:从libuv到JS,一个TCP连接事件引发的一系列callback的逆向调用过程”的1.3节详细了解这一步是如何实现的。

因为process_object为Persistent handle,使得该handle不会因为handle scope问题而被销毁掉。

env指向数据结构Environment。Environment像是一个中介,它作用之一便是把代表JS运行环境的V8 context和异步核心libuv的event loop撮合在一起。V8和libuv都是重量级人物,而Environment的存在使得node得以左手翻云,右手覆雨。

2. 应用启动详细过程

111.png

图5:应用启动流程

上一节描述了模块的加载和引用(绑定)过程。这个过程与本节要讲述的应用启动过程关系密切。当然,模块的引用不只是为应用启动服务的,在任何时候,任何JS代码中,我们都可以引用其它模块。 图5是一个流程图,它描绘了从node将我们的应用test.js作为参数启动开始,到我们的test.js最终被执行的过程。从途中可以看到,一共有如下几个主要参与者:

  1. 可执行文件node:node入口,在启动过程中主要扮演各种环境准备工作
  2. src\node.js:启动脚本
  3. NativeModule:src\node.js利用它来为module.js的执行做各种准备工作
  4. module.js:native模块。用来加载、编译、执行应用程序

这个过程可以用一句话概括:跋涉千山万水。

2.1 跋涉第一步:准备process

跋涉的第一步是准备process 这样一个V8 Persistent handle。这步在1.3节已经做了详细说明。

2.2 跋涉第二步:执行node.js

跋涉的第二步由src\node.js开始。可以说这才是真正的启动脚本,脚步入口在函数startup() src\node.js在编译的时候,编译进数组node_native,而触发这一步的地方在node.cc LoadEnvironment()。 如果我们仔细看src\node.js代码,会发现它是一个匿名函数,而且参数为process。那么这个参数在哪里设置的呢?仔细研究图6所示的LoadEnvironment()代码截图,就会得到答案。

111.png

图6:LoadEnvironment关键代码截图

2.3 跋涉第三步:通过NativeModule导入node自带的natives模块

本文一开始我罗列了node自带了两种模块,natives模块以及builtin模块。且这两个模块在编译时已经被编译进了可执行文件node里面去了。 查看src\node.js源代码会发现这个文件里面定义了一个NativeModule类。凭字面意思我们大概能猜出来这个类用来处理natives模块的。那么是如何处理的呢? 事情从src\node.js中的代码var Module = NativeModule.require(‘module’)说起。很显然,这行代码想加载lib\module.js。但这个时候处于进入我们的应用之前的状态,有很多基础设施还没有准备好。这就类似在进入我们用C写的main()函数之前,加载器在做的一些准备工作。

需要准备的几个基础设施例如:

  1. 我们知道node会为模块代码加上(function (exports, require, module, __filename, __dirname) { ', /your code/ '\n})这样的wrap,这样模块里面才可以引用exports这些变量。 所以需要node同样需要为module.js加上这样的wrap。
  2. 除了加上这样的wrap之外,还需要初始化好require, __filename, __dirname等变量, 以便module.js被执行时引用

而这些准备工作都是在NativeModule.require()里面完成的。对于这个函数,下面列出重点需要关注地方:

  1. 如果cache命中不了,则会新建一个NativeModule对象。
  2. nativeModule.compile() 所执行的函数NativeModule.prototype.compile()会从1.2节所描述的过程中获取到所有的natives模块的内容。
  3. NativeModule.prototype.compile()完成添加wrap的工作。如图7所示。
  4. 第3步的另外一个作用是为lib\module.js提供了包括require在内的变量,详见下文细解。
  5. NativeModule.prototype.compile()完成编译和执行module.js的工作。

111.png

图7:JS模块wrap代码截图

对于lib\module.js而言,传给wrap中的每个参数实际上是::this.exports, NativeModule.require, this, this.filename。所以在module.js里面引用的其它natives模块其实都是通过函数NativeModule.require()完成的。 需要注意的是NativeModule.require()只负责帮助引用natives module,这是通过NativeModule.exists(id)来加以限制的。这也就是为什么会有第3节的存在,在我们自己写的应用里所用到的require和这个地方是不一样的。

2.4 跋涉第四步:module.js完成剩余的准备工作

第四步跋涉则完全由module.js完成。module.js负责完成我们应用程序的加载、wrap、编译以及最终的调用。

3. 应用启动后如何加载依赖模块

在2.3节末尾,我提到NativeModule.require()只负责帮助引用natives module。这对于lib\module.js而言已经足够了,因为它所引用的范围也就是其余的natives module以及builtin module 但很显然我们的应用不但需要要引用natives module,还会引用其它第三方的模块。所以,我们的应用所看到的require()函数,与lib\module.js所看到的是两个东西。 让我们把目光移到位于lib\module.js文件中的Module.prototype._compile()函数中。 在这个函数的末尾,我们看到在调用compiledWrapper.apply()传递进去的参数中包含了一个require对象,而它的真身如图8所示:

1.png

图8:我们的应用中require参数所指向的函数

后面的事情,都很顺畅了。

转载本文请注明作者和出处,请勿用于任何商业用途。如需帮助,请QQ联系作者:229848501

18 回复

下一篇还在选题中。。。。。。希望现在的几篇对“背后的故事”执着者有帮助

顶LZ,真心不错的文章

@hwoarangzk 谢谢!之前的文章有错误的地方我也会一直更新。希望这个系列能有深度地覆盖到node的主要方面,且能帮助到需要的人。“在这个浮躁的时代,我们需要沉淀”

希望楼主继续~点个赞!

支持挖掘机系列!

挖掘系列都是我看node代码和自己的项目经验总结出来的。难免有些错误的地方,如果大家发现了,麻烦告诉我。

请问楼主想换工作吗,we need you!

@hustxiaoc 暂时还没有。谢谢啊 :)

突然发现应用程序调用require()模块部分没有写。最近找时间更新上去。

把这篇文章更新了一下:

  1. 更新标题
  2. 纠正了一些内容错误
  3. 新加了我们的应用启动时如何加载依赖模块的,因为在我们自己写的应用里所用到的require和node.js启动时,引用自己内部的模块所用的require方法是不一样的。。

这段时间太忙了。总算把遗漏的地方补完。

本来是收藏贴,万(meng)恶(meng)的管理员@alsotang 把收藏取消了= =

缓存的部分需要多说一点哟

@JacksonTian 你是说NativeModule.require()里面用到的cache吗?

非常好的系列,第五篇看不太懂。

楼主真心犀利。。哈哈。先mark多看几遍

看完之后对楼主的敬仰之情犹如滔滔江水

回到顶部