Node小结

最近工作中用到了Node,实现了一个数据抓取处理的自动化工具。平时的使用中,主要还是依赖各种库。对Node本身的一些原理性的东西也不是很清楚,只是会参考文档使用API,所以需要学习总结一下~

要点

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

Node平台的结构

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

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

结构:

Node架构.png

​ 在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中的文件读写函数:

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源文件中有:

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

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

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

var fs = rquire('fs');

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

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

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

在node_file.cc中:

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

C++对象初始化1.png

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

的函数是哪一个

C++对象初始化2.png

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

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

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

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

script::Compile
script::run

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

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

js调用C++.png

右边的部分会在Node初始化的时候进行加载,这个过程主要是initFs初始化函数指针,FSReqWrap类型信息以及FSReqWrap类型包含的函数注册到全局表中。在之后js代码调用中再根据需要去完成FSReqWrap的初始化,并通过上下文对象查找到需要调用的FSReqWrap和相关函数。

异步IO

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

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中文件读写内部是通过不同的线程来完成,内部维护这一个线程池。

如下图所示:

异步IO.png

左侧绿色部分的流程是js所在线程的执行过程,每一次调用readFile之后接着往下执行下一个readFile。对于每一次readFile的调用最终通过NodeBindings转换到libuv库中相关函数的调用,每一次文件读取派发到一个线程来完成。等待文件读取完成,再通知js层callback回调。通过分析js层的调用顺序应该是fun1->fun2->fun3 后面3个callback的先后顺序则是依赖libuv中文件读取完成的先后顺序,通过libuv来实现了js层的异步IO。

事件循环

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

Node中事件循环中的设计:

事件循环队列.png

libuv将各种系统层面的操作都封装成了一个事件对象,js和libuv之间的交互都是以事件进行驱动的。图中左侧是libuv中设计的用于存储各种事件的队列,右侧则是uv_run函数运行的基本流程,类似一个不断迭代的循环,每次迭代的过程中去检查各类对列中有没有需要处理的已经完成的事件,来驱动后面的流程。

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

事件循环过程.png

上图中浅蓝色的部分是整个流程中与文件读写有关系的部分,当js层调用了文件读取的函数后,通过jsbinding转化成对libuv层的调用。libuv会先将文件读写的请求封装成一个事件对象(存储了回调函数),并将该事件对象注册到uv_fs_events队列中,然后将文件读写的任务派发给工作线程池中的一个闲置线程。等待该线程文件读写的任务完成之后,通过修改uv_fs_events中事件对象的状态来进行通知。随后在uv_run的迭代中,检查uv_fs_events中事件对象的状态发生变化,然后通过回调的方式通知js层文件读写已经完成。

模块的加载和查找

模块分类

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

事件循环过程.png

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

第一类是原生模块,Node自带的功能组件,比如'http' 'fs'等。第二类是自定义模块,我们可以通过自己封装一些功能到js,C++,json文件中,C++主要是用来针对一些系统或者是需要性能的部分来进行扩展,js主要是自己定义的业务模块,json文件可以用来放置一些数据或者配置信息。而package文件夹,最熟悉的就是通过npm install 安装的第三方模块了,这些都可以通过require的方式进行加载。通常需要引用一个模块的时候使用require()方法,作为一个模块要导出你想公开的接口使用exports/module.exports。

模块文件加载

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

module加载.png

在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对象加载:

_compile函数的实现.png

在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这个库为例),如何加载:

package加载.png

Node会找到package.json文件,读取里面的main字段。按照main字段配置的值,去按相应的方式(js/json/c++)加载对应的文件。如果没有找到package.json文件,那么就直接找对应的index.js/index.json/index.node文件,再按照文件的方式加载。

exports和module.exports

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

global.exports = this.exports

所以exports相当于一个module.exports的引用,如果是你去改版exports,对于module.exports还是之前的值并没有影响到module.exports。

模块文件的查找

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

模块文件查找流程.png

整个流程中有查找文件模块的过程,这个后面具体细说,除此之外的流程并不复杂。可以看出在查找模块文件的时候,文件缓存的优先级是最高的,其次是原生模块,最后才去文件模块中查找。猜测是跟时间复杂度有关系,从文件缓存中应该是最快的。其次是原生模块,因为原生模块所在的路径位置是相对固定的。最后才是文件模块,它的位置并不固定,查找起来就相对麻烦花费的时间更多一些。

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

require('./test')

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

/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返回的路径数组逐一查找。如果最终没有找到就抛出异常:

文件模块查找.png

相关工具推荐

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

推荐阅读更多精彩内容