概述
相信很多的人,每天在終端不止一遍的執行著node
這條命令,對於很多人來說,它就像一個黑盒,並不知道背後到底發生了什麼,本文將會為大家揭開這個神祕的面紗,由於本人水平有限,所以只是講一個大概其,主要關注的過程就是node
模組的初始化,event loop
和v8
的部分基本沒有深入,這些部分可以關注一下我以後的文章。(提示本文非常的長,希望大家不要看煩~)
本文node基於10.5.0
node是什麼?
這個問題很多人都會回答就是v8
+ libuv
,但是除了這個兩個庫以外node
還依賴許多優秀的開源庫,可以通過process.versions
來看一下:
http_parser
主要用於解析http資料包的模組,在這個庫的作者也是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++
模組,這個模組在初始化的時候將其註冊到了一個連結串列中,載入的過程就是將其拿出來然後執行
以上內容如果有錯誤的地方,還請大佬指出,萬分感激,另外一件重要的事情就是:我所在團隊也在招人,如果有興趣可以將簡歷發至zhoupeng.1996@bytedance.com