概述
相信很多的人,每天在终端不止一遍的执行着
node
这条命令,对于很多人来说,它就像一个黑盒,并不知道背后到底发生了什么,本文将会为大家揭开这个神秘的面纱,由于本人水平有限,所以只是讲一个大概其,主要关注的过程就是
node
模块的初始化,
event loop
和
v8
的部分基本没有深入,这些部分可以关注一下我以后的文章。(提示本文非常的长,希望大家不要看烦~)
node是什么?
这个问题很多人都会回答就是
v8
+
libuv
,但是除了这个两个库以外
node
还依赖许多优秀的开源库,可以通过
process.versions
来看一下:
-
主要用于解析http数据包的模块,在这个库的作者也是http_parser
,一个纯ry
的库,无任何依赖c
-
这个大家就非常熟悉了,一个优秀的v8
引擎js
-
这个就是uv
实现的ry
,其封装了libuv
libev
,实现了跨平台,IOCP
中的node
就是它,尽管i/o
是单线程的,但是js
并不是,其有一个线程池来处理这些libuv
操作。i/o
-
主要来处理压缩操作,诸如熟悉的zlib
操作gzip
-
是ares
,这个库主要用于解析c-ares
,其也是异步的dns
-
就是modules
的模块系统,其遵循的规范为node
,不过commonjs
也支持了node
模块,不过需要加上参数并且文件名后缀需要为ES
,通过源码看,mjs
将node
模块的名称作为了一种ES
来看待,具体可以参见 这里url
-
如其名字一样,是一个nghttp2
的库http2
-
是在napi
出现,node8
稳定下来的,可以给编写node10
原生模块更好的体验(终于不用在依赖于node
,每次更换nan
版本还要重新编译一次了)node
-
非常著名的库,openssl
模块依赖于这个库,当然还包括tls
https
-
icu
,主要用于解决跨平台的编码问题,small-icu
对象中的versions
,unicode
cldr
也源自tz
,这个的定义可以参见icu
从这里可以看出的是
process
对象在
node
中非常的重要,个人的理解,其实
node
与浏览器端最主要的区别,就在于这个
process
对象
注:
node
只是用
v8
来进行
js
的解析,所以不一定非要依赖
v8
,也可以用其他的引擎来代替,比如利用微软的
ChakraCore
,对应的
node仓库node初始化
经过上面的一通分析,对
node
的所有依赖有了一定的了解,下面来进入正题,看一下
node
的初始化过程:
挖坑
node_main.cc
为入口文件,可以看到的是除了调用了
node::Start
之外,还做了两件事情:
NODE_SHARED_MODE忽略SIGPIPE信号
SIGPIPE
信号出现的情况一般在
socket
收到
RST packet
之后,扔向这个
socket
写数据时产生,简单来说就是
client
想
server
发请求,但是这时候
client
已经挂掉,这时候就会产生
SIGPIPE
信号,产生这个信号会使
server
端挂掉,其实
node::PlatformInit
中也做了这种操作,不过只是针对
non-shared lib build
改变缓冲行为
stdout
的默认缓冲行为为
_IOLBF
(行缓冲),但是对于这种来说交互性会非常的差,所以将其改为
_IONBF
(不缓冲)
探索
node.cc
文件中总共有三个
Start
函数,先从
node_main.cc
中掉的这个
Start
函数开始看:
int Start(int argc, char** argv) {
// 退出之前终止libuv的终端行为,为正常退出的情况
atexit([] () { uv_tty_reset_mode(); });
// 针对平台进行初始化
PlatformInit();
// ...
Init(&argc, const_cast<const char**>(argv), &exec_argc, &exec_argv);
// ...
v8_platform.Initialize(v8_thread_pool_size);
// 熟悉的v8初始化函数
V8::Initialize();
// ..
const int exit_code =
Start(uv_default_loop(), argc, argv, exec_argc, exec_argv);
}
上面函数只保留了一些关键不走,先来看看
PlatformInit
PlatfromInit
unix
中将一切都看作文件,进程启动时会默认打开三个
i/o
设备文件,也就是
stdin stdout stderr
,默认会分配
0 1 2
三个描述符出去,对应的文件描述符常量为
STDIN_FILENO STDOUT_FILENO STDERR_FILENO
,而
windows
中没有文件描述符的这个概念,对应的是句柄,
PlatformInit
首先是检查是否将这个三个文件描述符已经分配出去,若没有,则利用
open("/dev/null", O_RDWR)
分配出去,对于
windows
做了同样的操作,分配句柄出去,而且
windows
只做了这一个操作;对于
unix
来说还会针对
SIGINT
(用户调用Ctrl-C时发出)和
SIGTERM
(
SIGTERM
与
SIGKILL
类似,但是不同的是该信号可以被阻塞和处理,要求程序自己退出)信号来做一些特殊处理,这个处理与正常退出时一样;另一个重要的事情就是下面这段代码:
struct rlimit lim;
// soft limit 不等于 hard limit, 意味着可以增加
if (getrlimit(RLIMIT_NOFILE, &lim) == 0 && lim.rlim_cur != lim.rlim_max) {
// Do a binary search for the limit.
rlim_t min = lim.rlim_cur;
rlim_t max = 1 << 20;
// But if there's a defined upper bound, don't search, just set it.
if (lim.rlim_max != RLIM_INFINITY) {
min = lim.rlim_max;
max = lim.rlim_max;
}
do {
lim.rlim_cur = min + (max - min) / 2;
// 对于mac来说 hard limit 为unlimited
// 但是内核有限制最大的文件描述符,超过这个限制则设置失败
if (setrlimit(RLIMIT_NOFILE, &lim)) {
max = lim.rlim_cur;
} else {
min = lim.rlim_cur;
}
} while (min + 1 < max);
}
这件事情也就是提高一个进程允许打开的最大文件描述符,但是在
mac
上非常的奇怪,执行
ulimit -H -n
得到
hard limit
unlimited
,所以我认为
mac
上的最大文件描述符会被设置为
1 << 20
,但是最后经过实验发现最大只能为
24576
,非常的诡异,最后经过一顿搜索,查到了原来
mac
的内核对能打开的文件描述符也有限制,可以用
sysctl -A | grep kern.maxfiles
进行查看,果然这个数字就是
24576
Init
Init
函数调用了
RegisterBuiltinModules
:
// node.cc
void RegisterBuiltinModules() {
#define V(modname) _register_##modname();
NODE_BUILTIN_MODULES(V)
#undef V
}
// node_internals.h
#define NODE_BUILTIN_MODULES(V) \
NODE_BUILTIN_STANDARD_MODULES(V) \
NODE_BUILTIN_OPENSSL_MODULES(V) \
NODE_BUILTIN_ICU_MODULES(V)
从名字也可以看出上面的过程是进行
c++
node
利用了一些宏定义的方式,主要关注
NODE_BUILTIN_STANDARD_MODULES
这个宏:
#define NODE_BUILTIN_STANDARD_MODULES(V) \
V(async_wrap) \
V(buffer)
...
结合上面的定义,可以得出编译后的代码大概为:
void RegisterBuiltinModules() {
_register_async_wrap();
_register_buffer();
}
而这些
_register
又是从哪里来的呢?以
buffer
来说,对应
c++
文件为
src/node_buffer.cc
,来看这个文件的最后一行,第二个参数是模块的初始化函数:
NODE_BUILTIN_MODULE_CONTEXT_AWARE(buffer, node::Buffer::Initialize)
这个宏存在于
node_internals.h
中:
#define NODE_MODULE_CONTEXT_AWARE_CPP(modname, regfunc, priv, flags)
static node::node_module _module = {
NODE_MODULE_VERSION,
flags,
nullptr,
__FILE__,
nullptr,
(node::addon_context_register_func) (regfunc),// 暴露给js使用的模块的初始化函数
NODE_STRINGIFY(modname),
priv,
nullptr
};
void _register_ ## modname() {
node_module_register(&_module);
}
#define NODE_BUILTIN_MODULE_CONTEXT_AWARE(modname, regfunc)
NODE_MODULE_CONTEXT_AWARE_CPP(modname, regfunc, nullptr, NM_F_BUILTIN)
发现调用的
_register_buffer
实质上调用的是
node_module_register(&_module)
,每一个
c++
模块对应的为一个
node_module
结构体,再来看看
node_module_register
发生了什么:
extern "C" void node_module_register(void* m) {
struct node_module* mp = reinterpret_cast<struct node_module*>(m);
if (mp->nm_flags & NM_F_BUILTIN) {
mp->nm_link = modlist_builtin;
modlist_builtin = mp;
}
...
}
由此可以见,
c++
模块被存储在了一个链表中,后面
process.binding()
本质上就是在这个链表中查找对应
c++
模块,
node_module
是链表中的一个节点,除此之外
Init
还初始化了一些变量,这些变量基本上都是取决于环境变量用
getenv
获得即可
v8初始化
到执行完
Init
为止,还没有涉及的
js
c++
的交互,在将一些环境初始化之后,就要开始用
v8
这个大杀器了,
v8_platform
是一个结构体,可以理解为是
node
对于
v8
的
v8::platform
一个封装,紧接着的就是对
v8
进行初始化,自此开始具备了与
js
进行交互的能力,初始化
v8
之后,创建了一个
libuv
事件循环就进入了下一个
Start
函数
第二个Start函数
inline int Start(uv_loop_t* event_loop,
int argc, const char* const* argv,
int exec_argc, const char* const* exec_argv) {
std::unique_ptr<ArrayBufferAllocator, decltype(&FreeArrayBufferAllocator)>
allocator(CreateArrayBufferAllocator(), &FreeArrayBufferAllocator);
Isolate* const isolate = NewIsolate(allocator.get());
// ...
{
Locker locker(isolate);
Isolate::Scope isolate_scope(isolate);
HandleScope handle_scope(isolate);
}
}
首先创建了一个
v8
Isolate
(隔离),隔离在
v8
中非常常见,仿佛和进程一样,不同隔离不共享资源,有着自己得堆栈,但是正是因为这个原因在多线程的情况下,要是对每一个线程都创建一个隔离的话,那么开销会非常的大(可喜可贺的是
node
有了
worker_threads
),这时候可以借助
Locker
来进行同步,同时也保证了一个
Isolate
同一时刻只能被一个线程使用;下面两行就是
v8
的常规套路,下一步一般就是创建一个
Context
(最简化的一个流程可以参见
v8
hello world ),
HandleScope
叫做句柄作用域,一般都是放在函数的开头,来管理函数创建的一些句柄(水平有限,暂时不深究,先挖个坑);第二个
Start
的主要流程就是这个,下面就会进入最后一个
Start
函数,这个函数可以说是非常的关键,会揭开所有的谜题
解开谜题
inline int Start(Isolate* isolate, IsolateData* isolate_data,
int argc, const char* const* argv,
int exec_argc, const char* const* exec_argv) {
HandleScope handle_scope(isolate);
// 常规套路
Local<Context> context = NewContext(isolate);
Context::Scope context_scope(context);
Environment env(isolate_data, context, v8_platform.GetTracingAgentWriter());
env.Start(argc, argv, exec_argc, exec_argv, v8_is_profiling);
// ...
可以见到
v8
的常见套路,创建了一个上下文,这个上下文就是
js
的执行环境,
Context::Scope
是用来管理这个
Context
Environment
可以理解为一个
node
的运行环境,记录了
isolate,event loop
等,
Start
的过程主要是做了一些
libuv
的初始化以及
process
对象的定义:
auto process_template = FunctionTemplate::New(isolate());
process_template->SetClassName(FIXED_ONE_BYTE_STRING(isolate(), "process"));
auto process_object =
process_template->GetFunction()->NewInstance(context()).ToLocalChecked();
set_process_object(process_object);
SetupProcessObject(this, argc, argv, exec_argc, exec_argv);
SetupProcessObject
生成了一个
c++
层面上的
process
对象,这个已经基本上和平时
node
process
对象一致,但是还会有一些出入,比如没有
binding
等,完成了这个过程之后就开始了
LoadEnvironment
LoadEnvironment
Local<String> loaders_name =
FIXED_ONE_BYTE_STRING(env->isolate(), "internal/bootstrap/loaders.js");
MaybeLocal<Function> loaders_bootstrapper =
GetBootstrapper(env, LoadersBootstrapperSource(env), loaders_name);
Local<String> node_name =
FIXED_ONE_BYTE_STRING(env->isolate(), "internal/bootstrap/node.js");
MaybeLocal<Function> node_bootstrapper =
GetBootstrapper(env, NodeBootstrapperSource(env), node_name);
先将
lib/internal/bootstrap
文件夹下的两个文件读进来,然后利用
GetBootstrapper
来执行
js
代码分别得到了一个函数,一步步来看,先看看
GetBootstrapper
为什么可以执行
js
代码,查看这个函数可以发现主要是因为
ExecuteString
MaybeLocal<v8::Script> script =
v8::Script::Compile(env->context(), source, &origin);
...
MaybeLocal<Value> result = script.ToLocalChecked()->Run(env->context());
这个主要利用了
v8
的能力,对
js
文件进行了解析和执行,打开
loaders.js
看看其参数,需要五个,捡两个最重要的来说,分别是
process
getBinding
,这里面往后继续看
LoadEnvironment
发现
process
对象就是刚刚生成的,而
getBinding
是函数
GetBinding
node_module* mod = get_builtin_module(*module_v);
Local<Object> exports;
if (mod != nullptr) {
exports = InitModule(env, mod, module);
} else if (!strcmp(*module_v, "constants")) {
exports = Object::New(env->isolate());
CHECK(exports->SetPrototype(env->context(),
Null(env->isolate())).FromJust());
DefineConstants(env->isolate(), exports);
} else if (!strcmp(*module_v, "natives")) { // NativeModule _source
exports = Object::New(env->isolate());
DefineJavaScript(env, exports);
} else {
return ThrowIfNoSuchModule(env, *module_v);
}
args.GetReturnValue().Set(exports);
其作用就是根据传参来初始化指定的模块,当然也有比较特殊的两个分别是
constants
natives
(后面再看),
get_builtin_module
调用的就是
FindModule
,还记得之前在
Init
过程中将模块都注册到的链表吗?
FindModule
就是遍历这个链表找到相应的模块:
struct node_module* mp;
for (mp = list; mp != nullptr; mp = mp->nm_link) {
if (strcmp(mp->nm_modname, name) == 0)
break;
}
InitModule
就是调用之前注册模块定义的初始化函数,还以
buffer
看的话,就是执行
node::Buffer::Initialize
函数,打开着函数来看和平时写addon的方式一样,也会暴露一个对象出来供
js
调用;
LoadEnvironment
下面就是将
process, GetBinding
等作为传入传给上面生成好的函数并且利用
v8
来执行,来到了大家熟悉的领域,来看看
loaders.js
const moduleLoadList = [];
ObjectDefineProperty(process, 'moduleLoadList', {
value: moduleLoadList,
configurable: true,
enumerable: true,
writable: false
});
定义了一个已经加载的Module的数组,也可以在
node
通过
process.moduleLoadList
来看看加载了多少的原生模块进来
process.binding
process.binding = function binding(module) {
module = String(module);
let mod = bindingObj[module];
if (typeof mod !== 'object') {
mod = bindingObj[module] = getBinding(module);
moduleLoadList.push(`Binding ${module}`);
}
return mod;
};
终于到了这个方法,翻看
lib
js
文件,有着非常多的这种调用,这个函数就是对
GetBinding
做了一个
js
层面的封装,做的无非是查看一下这个模块是否已经加载完成了,是的话直接返回回去,不需要再次初始化了,所以利用
prcoess.binding
加载了对应的
c++
模块(可以执行一下
process.binding('buffer')
,然后再去
node_buffer.cc
中看看)继续向下看,会发现定义了一个
class
NativeModule
,发现其有一个静态属性:
加载js
NativeModule._source = getBinding('natives');
返回到
GetBinding
函数,看到的是一个
if
分支就是这种情况:
exports = Object::New(env->isolate());
DefineJavaScript(env, exports);
来看看
DefineJavaScript
发生了什么样的事情,这个函数发现只能在头文件(
node_javascript.h
)里面找到,但是根本找不到具体的实现,这是个什么鬼???去翻一下
node.gyp
文件发现这个文件是用
js2c.py
这个文件生成的,去看一下这个
python
文件,可以发现许多的代码模板,每一个模板都是用
Render
返回的,
data
参数就是
js
文件的内容,最终会被转换为
c++
byte
数组,同时定义了一个将其转换为字符串的方法,那么问题来了,这些文件都是那些呢?答案还是在
node.gyp
中,就是
library_files
数组,发现包含了
lib
下的所有的文件和一些
dep
下的
js
文件,
DefineJavaScript
这个文件做的就是将待执行的
js
代码注册下,所以
NativeModule._source
中存储的是一些待执行的
js
代码,来看一下
NativeModule.require
NativeModule
const cached = NativeModule.getCached(id);
if (cached && (cached.loaded || cached.loading)) {
return cached.exports;
}
moduleLoadList.push(`NativeModule ${id}`);
const nativeModule = new NativeModule(id);
nativeModule.cache();
nativeModule.compile();
return nativeModule.exports;
可以发现
NativeModule
也有着缓存的策略,
require
先把其放到
_cache
中再次
require
就不会像第一次那样执行这个模块,而是直接用缓存中执行好的,后面说的
Module
与其同理,看一下
compile
的实现:
let source = NativeModule.getSource(this.id);
source = NativeModule.wrap(source);
NativeModule.wrap = function(script) {
return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};
NativeModule.wrapper = [
'(function (exports, require, module, process) {',
'\n});'
];
首先从
_source
中取出相应的模块,然后对这个模块进行包裹成一个函数,执行函数用的是什么呢?
const script = new ContextifyScript(
source, this.filename, 0, 0,
codeCache[this.id], false, undefined
);
this.script = script;
const fn = script.runInThisContext(-1, true, false);
const requireFn = this.id.startsWith('internal/deps/') ?
NativeModule.requireForDeps :
NativeModule.require;
fn(this.exports, requireFn, this, process);
本质上就是调用了
vm
编译自妇产得到函数,然后给其传入了一些参数并执行,
this.exports
就是一个对象,
require
区分了一下是否加载
node
依赖的
js
this
也就是参数
module
,这也说明了两者的关系,
exports
module
的一个属性,也解释了为什么
exports.xx
之后再指定
module.exports = yy
会将
xx
忽略掉,还记得
LoadEnvironment
吗?
bootstrap/loaders.js
执行完之后执行了
bootstrap/node.js
,可以说这个文件是
node
真正的入口,比如定义了
global
对象上的属性,比如
console setTimeout
等,由于篇幅有限,来挑一个最常用的场景,来看看这个是什么一回事:
else if (process.argv[1] && process.argv[1] !== '-') {
const path = NativeModule.require('path');
process.argv[1] = path.resolve(process.argv[1]);
const CJSModule = NativeModule.require('internal/modules/cjs/loader');
...
CJSModule.runMain();
}
这个过程就是熟悉的
node index.js
这个过程,可以看到的对于开发者自己的
js
来说,在
node
中对应的
class
Module
,相信这个文件大家很多人都了解,与
NativeModule
相类似,不同的是,需要进行路径的解析和模块的查找等,来大致的看一下这个文件,先从上面调用的
runMain
来看:
if (experimentalModules) {
// ...
} else {
Module._load(process.argv[1], null, true);
}
Module
node
中开启
--experimental-modules
可以加载
es
模块,也就是可以不用
babel
转义就可以使用
import/export
啦,这个不是重点,重点来看普通的
commonnjs
process.argv[1]
一般就是要执行的入口文件,下面看看
Module._load
Module._load = function(request, parent, isMain) {
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
}
// 查找文件具体位置
var filename = Module._resolveFilename(request, parent, isMain);
// 存在缓存,则不需要再次执行
var cachedModule = Module._cache[filename];
if (cachedModule) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
// 加载node原生模块,原生模块不需要缓存,因为NativeModule中也存在缓存
if (NativeModule.nonInternalExists(filename)) {
debug('load native module %s', request);
return NativeModule.require(filename);
}
// 加载并执行一个模块
var module = new Module(filename, parent);
if (isMain) {
process.mainModule = module;
module.id = '.';
}
Module._cache[filename] = module;
// 调用load方法进行加载
tryModuleLoad(module, filename);
return module.exports;
};
这里看每一个
Module
有一个
parent
的属性,假如
a.js
中引入了
b.js
,那么
Module b
parent
Module a
,利用
resolveFilename
可以得到文件具体的位置,这个过程而后调用
load
函数来加载文件,可以看到的是区分了几种类型,分别是
.js .json .node
.js
是读文件然后执行,
.json
是直接读文件后
JSON.parse
一下,
.node
是调用
dlopen
Module.compile
于
NativeModule.compile
相类似都是想包裹一层成为函数,然后调用了
vm
编译得到这个函数,最后传入参数来执行,对于
Module
来说,包裹的代码如下:
Module.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
执行完上述过程后,前期工作就已经做得比较充分了,再次回到最后一个
Start
函数来看,从代码中可以看到开始了
node
event loop
,这就是
node
的初始化过程,关于
event loop
需要对
libuv
有一定的了解,可以说
node
真正离不开的是
libuv
,具体这方面的东西,可以继续关注我后面的文章
总结
总结一下这个过程,以首次加载没有任何缓存的情况开看:
require('fs')
,先是调用了
Module.require
,而后发现为原生模块,于是调用
NativeModule.require
,从
NativeModule._source
lib/fs
的内容拿出来包裹一下然后执行,这个文件第一行就可以看到
process.binding
,这个本质上是加载原生的
c++
模块,这个模块在初始化的时候将其注册到了一个链表中,加载的过程就是将其拿出来然后执行
以上内容如果有错误的地方,还请大佬指出,万分感激,另外一件重要的事情就是:我所在团队也在招人,如果有兴趣可以将简历发至[email protected]
原文地址:https://segmentfault.com/a/1190000016318567