精华 Node.js挖掘之五:浅析模块加载流程以及我们的应用如何启动
发布于 2天前 作者 LanceHBZhang 287 次浏览 来自 分享

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

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

  1. 模块加载详细过程
  2. 应用启动详细过程

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

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内存大小使用限制。
  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利用它来加载各种native模块
  4. module.js:native模块。用来加载、编译、执行应用程序

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

2.1 跋涉第一步

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

2.2 跋涉第二步

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

111.png

图6:LoadEnvironment关键代码截图

2.3 跋涉第三步

第三步源于src\node.js需要引用native模块module.js。引用事件由NativeModule.require(‘module’)触发。而这步又由4个步骤组成。可以参考图5。 这里有两个重要的地方需要重点关注,我们都知道在node里面,每个JS模块都会被加上如图7所示的wrap。那么这一个动作是在什么时候做的呢?答案就在图5里面。

111.png

图7:JS模块wrap代码截图

第二个需要关注的是,我们都知道node为每个JS模块添加的wrap都包含参数exports,require,module等,JS模块内部的代码也可以自由访问这些参数。那么这些参数到底是什么?答案也在图5里面。

2.4 跋涉第四步

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

需要doc版本的,请QQ联系我:229848501。

10 回复

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

顶LZ,真心不错的文章

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

希望楼主继续~点个赞!

支持挖掘机系列!

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

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

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

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

回到顶部