​ 最近工作中用到了Node,实现了一个数据抓取处理的自动化工具。平时的使用中,主要还是依

赖各种库。对Node本身的一些原理性的东西也不是很清楚,只是会参考文档使用API,所以需要学习总结一下~

要点

  • Node平台的结构
  • js调用C++
  • 异步IO
  • 事件循环
  • 异步流程处理
  • 模块的加载和查找
  • 相关的工具

Node平台的结构

​ Node使用js来进行开发,既可以用来写一些命令行的脚本工具,也可以用来开发服务端接口。显然js已经

脱离了浏览器的运行环境,并且平时的开发都是异步为主js又是单线程,是如何实现呢。需要先了解一下Node的

结构:

​ 在Node开始运行的时候,会进行C++库的加载。在js代码运行的时候,通过V8解析执行,再由于Node

bindings绑定到C++的调用上,这一切发生在V8的运行阶段。V8保证了js的运行环境,所以js可以在任何有v8的

环境中运行。libuv是一个主要来负责Node中异步处理和事件驱动以及跨平台的调用,也就是说在js中对于系统

api的调用都会通过libuv来实现,js只负责了调用和回调的处理。js和C++部分的调用有需要通过jsBinding来

处理的。

​ Node中的异步主要是通过libuv来实现,当任务完成之后,libuv通过回调来通知js层。

js调用C++

js调用C++主要依赖于V8的运行时,现在考虑以下js代码如何最终调用libuv中的文件读写函数:

1
2
var fs = require('fs');
fs.readFileSync('path');

对于libuv部分的C++库,在Node加载的时候会被加载到V8中等待Js的调用。

对于JS部分的代码,会通过V8进行编译解释,再通过jsbinding调用到C++的接口。既然JS能够调用到C++的接口,

那么C++中必然有相应能够表示JS对象和方法的对象,在Node中通过XXWrap来进行封装的。比如针对现在的例子

fs对应的就是FSReqWrap对象,它位于node_file.cc文件中。

在Node开始执行js代码之前,首先会对自己进行初始化,加载自身的C++库。对于C++库中的对象,以当前代码

为例子它不会直接创建FSReqWrap对象,而是将该对象的初始化函数通过node_module_register函数保存到一个

全局的表中,那么什么时候会真正初始化对象呢。

在fs.js源文件中有:

1
const binding = process.binding('fs');

也就是在这行代码被调用的时候,FSReqWrap对象开始初始化,以便可以js调用

在下面代码执行的时候,上面的process.binding(‘fs’)也就会被调用了:

1
var fs = rquire('fs');

也就是说什么时候你使用了fs这个库,什么时候C++中对应的FSReqWrap对象进行加载和初始化。这也是为了加载

时间以及运行效率考虑吧,类似懒加载的策略。

现在来看一下FSReqWrap初始化的时候做了哪些事呢:

在node_file.cc中:

因为初始化函数比较长,所以分成2块:

这一部分其实是往上下文对象中注册当前对象的函数信息,例如js中调用’read’的时候,对应到C++对象中对应

的函数是哪一个

这一部分主要是往上下文对象context中注册类型信息,也就是说在js中使用对象’fs’对应到C++中是哪一个对象

通过这种方式,将js中某个对象的某个方法调用和C++中的实现对应关系存到上线文对象context中了。

剩下的部分只需要V8解释执行js的时候,从上下文对象中获取这些信息就能将js的调用转化到C++的调用上了

实质上在V8解释js的时候主要运用了两个函数:

1
2
script::Compile
script::run

这两个函数调用的时候,V8会将上下文对象context传入。

现在通过一张图总结一下整个过程:图中context有标号1和2,代表着它们的执行顺序。

右边的部分会在Node初始化的时候进行加载,这个过程主要是initFs初始化函数指针,FSReqWrap类型信息以及

FSReqWrap类型包含的函数注册到全局表中。在之后js代码调用中再根据需要去完成FSReqWrap的初始化,并

通过上下文对象查找到需要调用的FSReqWrap和相关函数。

异步IO

还是通过文件读写的例子来进行说明:

1
2
3
4
var fs = require('fs');
fs.readFile('path1', 'utf-8', callback1);
fs.readFile('path2', 'utf-8', callback2);
fs.readFile('path3', 'utf-8', callback3);

现在通过fs对象读取文本文件,都是异步的方式,传入回调函数。在JS层是调用readFile之后直接返回接着执行

后面的代码,当文件读取完成在执行相应的callback。文件的读写功能在Node中通过libuv来实现,在libuv中文件

读写内部是通过不同的线程来完成,内部维护这一个线程池。

如下图所示:

左侧绿色部分的流程是js所在线程的执行过程,每一次调用readFile之后接着往下执行下一个readFile。对于每一

次readFile的调用最终通过NodeBindings转换到libuv库中相关函数的调用,每一次文件读取派发到一个线程来完

成。等待文件读取完成,再通知js层callback回调。通过分析js层的调用顺序应该是fun1->fun2->fun3 后面3个

callback的先后顺序则是依赖libuv中文件读取完成的先后顺序,通过libuv来实现了js层的异步IO。

事件循环

在异步IO小结中,说明了Node中的异步IO是如何设计的。针对具体libuv如何通知js回调的过程,就需要了解一下

Node中事件循环中的设计:

事件循环队列

libuv将各种系统层面的操作都封装成了一个事件对象,js和libuv之间的交互都是以事件进行驱动的。图中左侧是

libuv中设计的用于存储各种事件的队列,右侧则是uv_run函数运行的基本流程,类似一个不断迭代的循环,每次

迭代的过程中去检查各类对列中有没有需要处理的已经完成的事件,来驱动后面的流程。

通过文件读写的例子来说明一下详细过程:

上图中浅蓝色的部分是整个流程中与文件读写有关系的部分,当js层调用了文件读取的函数后,通过jsbinding转化

成对libuv层的调用。libuv会先将文件读写的请求封装成一个事件对象(存储了回调函数),并将该事件对象注册到

uv_fs_events队列中,然后将文件读写的任务派发给工作线程池中的一个闲置线程。等待该线程文件读写的任务完

成之后,通过修改uv_fs_events中事件对象的状态来进行通知。随后在uv_run的迭代中,检查uv_fs_events中

事件对象的状态发生变化,然后通过回调的方式通知js层文件读写已经完成。

模块的加载和查找

模块分类

在说明Node中模块的加载和查找之前,先看一下Node中模块的分类:

Node中模块可以分为两大类:

第一类是原生模块,Node自带的功能组件,比如’http’ ‘fs’等。第二类是自定义模块,我们可以通过自己封装一些

功能到js,C++,json文件中,C++主要是用来针对一些系统或者是需要性能的部分来进行扩展,js主要是自己定义的

业务模块,json文件可以用来放置一些数据或者配置信息。而package文件夹,最熟悉的就是通过npm install 安装

的第三方模块了,这些都可以通过require的方式进行加载。

通常需要引用一个模块的时候使用require()方法,作为一个模块要导出你想公开的接口使用exports/

module.exports。

模块文件加载

module加载有一套自身的流程,在此之前先看一下对于一个全新Module,Node如何加载:

在Node中,每个模块都是通过一个Module对象处理的,所以当你使用require(‘module’)引用一个新模块的时

候,Node会创建一个Module对象。Module对象在初始化的过程中会初始化当前module的exports属性(也就是

你在js文件中使用的Module.exports了),生成新的Module中使用的require函数。然后调用load方法来加载文

件,它首先会进行文件类型的判断,根据文件类型进行加载。对于C++模块,会调用process.dlopen方法加载。

对于json文件则读取出来,再通过JSON.parse转化成json对象,并赋值给module.exports。对于js文件,会调用

_compile方法,更复杂一些下面会单独介绍。

总结一下,在生成module对象的时候会初始化exports属性和require函数,然后load方法主要是加载具体的模块

文件,最终的目的是给module.exports赋值,到最后require函数返回的是新生成的module.exports属性。

现在来看一下对于js文件,Node如何通过Module对象加载:

在Module的load方法中,对于js文件会调用_compile方法,在 _compile中会先调用wrap方法。wrap方法主要

是对于当前要加载js文件通过function做了一层包装,function的参数传入了当前module中的exports,require

以及module自身。然后将该function传递给v8的runInThisContext函数,runInThisContext函数主要做的事情就

是去执行这个传入的function,其实就相当于在执行你自己写好的js文件中的所有代码,这个时候最终会执行你自

己写的module.exports=xxxx。也就是说你写的js代码会在require的时候被执行,js代码中本身使用到的require

和module.exports都是通过当时运行环境的上下文参数传过来的。

所以和json文件加载不同的地方时,module.exports属性的赋值一个是在Node的load函数中,js文件的是在你自

己的代码中。

最后看一下对于package文件夹(以underscore这个库为例),如何加载:

Node会找到package.json文件,读取里面的main字段。按照main字段配置的值,去按相应的方式(js/json/c++)

加载对应的文件。如果没有找到package.json文件,那么就直接找对应的index.js/index.json/index.node文件,

再按照文件的方式加载。

exports和module.exports

看了一下Node v0.1版本的相关源码,在module类型中对于exports的定义如下:

1
global.exports = this.exports

所以exports相当于一个module.exports的引用,如果是你去改版exports,对于module.exports还是之前的值

并没有影响到module.exports。

模块文件的查找

以下是当调用require(‘module’)的时候,模块文件的查找流程:

整个流程中有查找文件模块的过程,这个后面具体细说,除此之外的流程并不复杂。可以看出在查找模块文件的时

候,文件缓存的优先级是最高的,其次是原生模块,最后才去文件模块中查找。猜测是跟时间复杂度有关系,从

文件缓存中应该是最快的。其次是原生模块,因为原生模块所在的路径位置是相对固定的。最后才是文件模块,它

的位置并不固定,查找起来就相对麻烦花费的时间更多一些。

下面再单独针对文件模块的查找讲一下它的过程:

1
require('./test')

module对象中有一个path方法,它返回的是在进行文件查找的时候,遍历的文件路径:

1
2
3
4
5
/home/jim/repos/node/node_modules
/home/jim/repos/node_modules
/home/jim/node_modules
/home/node_modules
/node_modules

首先会在执行脚本所在路径以js/json/node或者文件件夹(package)的方式夹加载test,如果没有找到就去module

的path返回的路径数组逐一查找。如果最终没有找到就抛出异常:

相关工具推荐

NVM Node的安装/卸载以及版本管理工具

NPM 模块的安装/卸载管理工具

NRM 模块源的管理工具

最后是IDE:我使用的是sublime + Node插件 + sublime-text2-buildview

优点:编码和测试运行很方便,很轻量级,另外log信息是以一个独立窗口的形式呈现(而不是在最下面,这样log

比较多的时候就不方便查看了,主要是sublime-text2-buildview的功劳),通过安装Node插件,在调试的时候也不

用在编辑器和控制台来回切换了,提高了效率。

Reference:

https://www.cnblogs.com/lijiayi/p/js_node_module.html

http://www.infoq.com/cn/articles/nodejs-module-mechanism/#

http://taobaofed.org/blog/2015/10/29/deep-into-node-1/

https://i5ting.github.io/wechat-dev-with-nodejs/index.html

https://luzeshu.com/tech

http://zihua.li/2012/03/use-module-exports-or-exports-in-node/

https://github.com/nodejs/node