Node.js 的出現,讓 JavaScript 脫離了瀏覽器的束縛,進入了廣闊的服務端開發領域。而 Node.js 對 CommonJS 模組化規範的引入,則更是讓 JavaScript成為了一門真正能夠適應大型工程的語言。
在 Node.js 中使用模組非常簡單,我們日常開發中幾乎都有過這樣的經歷:寫一段 JavaScript 程式碼,require 一些想要的包,然後將程式碼產物 exports 匯出。但是,對於 Node.js 模組化背後的載入與執行原理,我們是否清楚呢。首先丟擲以下幾個問題:
- Node.js 中的模組支援哪些檔案型別?
- 核心模組和第三方模組的載入執行流程有什麼不同?
- 除了 JavaScript 模組以外,怎樣去寫一個 C/C++ 擴充套件模組?
- ……
本篇文章,就會結合 Node.js 原始碼,探究一下以上這些問題背後的答案。
1. Node.js 模組型別
在 Node.js 中,模組主要可以分為以下幾種型別:
- 核心模組:包含在 Node.js 原始碼中,被編譯進 Node.js 可執行二進位制檔案 JavaScript 模組,也叫 native 模組,比如常用的 http, fs 等等
- C/C++ 模組,也叫 built-in 模組,一般我們不直接呼叫,而是在 native module 中呼叫,然後我們再 require
- native 模組,比如我們在 Node.js 中常用的 buffer,fs,os 等 native 模組,其底層都有呼叫 built-in 模組。
- 第三方模組:非 Node.js 原始碼自帶的模組都可以統稱第三方模組,比如 express,webpack 等等。
- JavaScript 模組,這是最常見的,我們開發的時候一般都寫的是 JavaScript 模組
- JSON 模組,這個很簡單,就是一個 JSON 檔案
- C/C++ 擴充套件模組,使用 C/C++ 編寫,編譯之後字尾名為 .node
本篇文章中,我們會一一涉及到上述幾種模組的載入、執行原理。
2. Node.js 原始碼結構一覽
這裡使用 Node.js 6.x 版本原始碼為例子來做分析。去 github 上下載相應版本的 Node.js 原始碼,可以看到程式碼大體結構如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
├── AUTHORS ├── BSDmakefile ├── BUILDING.md ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── COLLABORATOR_GUIDE.md ├── CONTRIBUTING.md ├── GOVERNANCE.md ├── LICENSE ├── Makefile ├── README.md ├── android-configure ├── benchmark ├── common.gypi ├── configure ├── deps ├── doc ├── lib ├── node.gyp ├── node.gypi ├── src ├── test ├── tools └── vcbuild.bat |
其中:
./lib
資料夾主要包含了各種 JavaScript 檔案,我們常用的 JavaScript native 模組都在這裡。./src
資料夾主要包含了 Node.js 的 C/C++ 原始碼檔案,其中很多 built-in 模組都在這裡。./deps
資料夾包含了 Node.js 依賴的各種庫,典型的如 v8,libuv,zlib 等。
我們在開發中使用的 release 版本,其實就是從原始碼編譯得到的可執行檔案。如果我們想要對 Node.js 進行一些個性化的定製,則可以對原始碼進行修改,然後再執行編譯,得到定製化的 Node.js 版本。這裡以 Linux 平臺為例,簡要介紹一下 Node.js 編譯流程。
首先,我們需要認識一下編譯用到的組織工具,即 gyp
。Node.js 原始碼中我們可以看到一個 node.gyp
,這個檔案中的內容是由 python 寫成的一些 JSON-like 配置,定義了一連串的構建工程任務。我們舉個例子,其中有一個欄位如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
{ 'target_name': 'node_js2c', 'type': 'none', 'toolsets': ['host'], 'actions': [ { 'action_name': 'node_js2c', 'inputs': [ '<@(library_files)', './config.gypi', ], 'outputs': [ '<(SHARED_INTERMEDIATE_DIR)/node_natives.h', ], 'conditions': [ [ 'node_use_dtrace=="false" and node_use_etw=="false"', { 'inputs': [ 'src/notrace_macros.py' ] }], ['node_use_lttng=="false"', { 'inputs': [ 'src/nolttng_macros.py' ] }], [ 'node_use_perfctr=="false"', { 'inputs': [ 'src/perfctr_macros.py' ] }] ], 'action': [ 'python', 'tools/js2c.py', '<@(_outputs)', '<@(_inputs)', ], }, ], }, # end node_js2c |
這個任務主要的作用從名稱 node_js2c
就可以看出來,是將 JavaScript 轉換為 C/C++ 程式碼。這個任務我們下面還會提到。
首先編譯 Node.js,需要提前安裝一些工具:
- gcc 和 g++ 4.9.4 及以上版本
- clang 和 clang++
- python 2.6 或者 2.7,這裡要注意,只能是這兩個版本,不可以為python 3+
- GNU MAKE 3.81 及以上版本
有了這些工具,進入 Node.js 原始碼目錄,我們只需要依次執行如下命令:
1 2 3 |
./configuration make make install |
即可編譯生成可執行檔案並安裝了。
3. 從 node index.js
開始
讓我們首先從最簡單的情況開始。假設有一個 index.js 檔案,裡面只有一行很簡單的 console.log('hello world')
程式碼。當輸入 node index.js
的時候,Node.js 是如何編譯、執行這個檔案的呢?
當輸入 Node.js 命令的時候,呼叫的是 Node.js 原始碼當中的 main 函式,在 src/node_main.cc
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// src/node_main.cc #include "node.h" #ifdef _WIN32 #include <VersionHelpers.h> int wmain(int argc, wchar_t *wargv[]) { // windows下面的入口 } #else // UNIX int main(int argc, char *argv[]) { // Disable stdio buffering, it interacts poorly with printf() // calls elsewhere in the program (e.g., any logging from V8.) setvbuf(stdout, nullptr, _IONBF, 0); setvbuf(stderr, nullptr, _IONBF, 0); // 關注下面這一行 return node::Start(argc, argv); } #endif |
這個檔案只做入口用,區分了 Windows 和 Unix 環境。我們以 Unix 為例,在 main 函式中最後呼叫了 node::Start
,這個是在 src/node.cc
檔案中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
// src/node.cc int Start(int argc, char** argv) { // ... { NodeInstanceData instance_data(NodeInstanceType::MAIN, uv_default_loop(), argc, const_cast<const char**>(argv), exec_argc, exec_argv, use_debug_agent); StartNodeInstance(&instance_data); exit_code = instance_data.exit_code(); } // ... } // ... static void StartNodeInstance(void* arg) { // ... { Environment::AsyncCallbackScope callback_scope(env); LoadEnvironment(env); } // ... } // ... void LoadEnvironment(Environment* env) { // ... Local<String> script_name = FIXED_ONE_BYTE_STRING(env->isolate(), "bootstrap_node.js"); Local<Value> f_value = ExecuteString(env, MainSource(env), script_name); if (try_catch.HasCaught()) { ReportException(env, try_catch); exit(10); } // The bootstrap_node.js file returns a function 'f' CHECK(f_value->IsFunction()); Local<Function> f = Local<Function>::Cast(f_value); // ... f->Call(Null(env->isolate()), 1, &arg); } |
整個檔案比較長,在上面程式碼段裡,只擷取了我們最需要關注的流程片段,呼叫關係如下: Start -> StartNodeInstance -> LoadEnvironment
。
在 LoadEnvironment
需要我們關注,主要做的事情就是,取出 bootstrap_node.js
中的程式碼字串,解析成函式,並最後通過 f->Call
去執行。
OK,重點來了,從 Node.js 啟動以來,我們終於看到了第一個 JavaScript 檔案 bootstrap_node.js
,從檔名我們也可以看出這個是一個入口性質的檔案。那麼我們快去看看吧,該檔案路徑為 lib/internal/bootstrap_node.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// lib/internal/boostrap_node.js (function(process) { function startup() { // ... else if (process.argv[1]) { const path = NativeModule.require('path'); process.argv[1] = path.resolve(process.argv[1]); const Module = NativeModule.require('module'); // ... preloadModules(); run(Module.runMain); } // ... } // ... startup(); } // lib/module.js // ... // bootstrap main module. Module.runMain = function() { // Load the main module--the command line argument. Module._load(process.argv[1], null, true); // Handle any nextTicks added in the first tick of the program process._tickCallback(); }; // ... |
這裡我們依然關注主流程,可以看到,bootstrap_node.js
中,執行了一個 startup()
函式。通過 process.argv[1]
拿到檔名,在我們的 node index.js
中,process.argv[1]
顯然就是 index.js
,然後呼叫 path.resolve
解析出檔案路徑。在最後,run(Module.runMain)
來編譯執行我們的 index.js
。
而 Module.runMain
函式定義在 lib/module.js
中,在上述程式碼片段的最後,列出了這個函式,可以看到,主要是呼叫 Module._load
來載入執行 process.argv[1]
。
下文我們在分析模組的 require 的時候,也會來到 lib/module.js
中,也會分析到 Module._load
。因此我們可以看出,Node.js 啟動一個檔案的過程,其實到最後,也是 require
一個檔案的過程,可以理解為是立即 require 一個檔案。下面就來分析 require 的原理。
4. 模組載入原理的關鍵:require
我們進一步,假設我們的 index.js
有如下內容:
1 |
var http = require('http'); |
那麼當執行這一句程式碼的時候,會發生什麼呢?
require的定義依然在 lib/module.js
中:
1 2 3 4 5 6 7 8 |
// lib/module.js // ... Module.prototype.require = function(path) { assert(path, 'missing path'); assert(typeof path === 'string', 'path must be a string'); return Module._load(path, this, /* isMain */ false); }; // ... |
require
方法定義在Module的原型鏈上。可以看到這個方法中,呼叫了 Module._load
。
我們這麼快就又來到了 Module._load
來看看這個關鍵的方法究竟做了什麼吧:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// lib/module.js // ... 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) { return cachedModule.exports; } 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; tryModuleLoad(module, filename); return module.exports; }; // ... |
這段程式碼的流程比較清晰,具體說來:
- 根據檔名,呼叫
Module._resolveFilename
解析檔案的路徑 - 檢視快取
Module._cache
中是否有該模組,如果有,直接返回 - 通過
NativeModule.nonInternalExists
判斷該模組是否為核心模組,如果核心模組,呼叫核心模組的載入方法NativeModule.require
- 如果不是核心模組,新建立一個 Module 物件,呼叫
tryModuleLoad
函式載入模組
我們首先來看一下 Module._resolveFilename
,看懂這個方法對於我們理解 Node.js 的檔案路徑解析原理很有幫助:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// lib/module.js // ... Module._resolveFilename = function(request, parent, isMain) { // ... var filename = Module._findPath(request, paths, isMain); if (!filename) { var err = new Error("Cannot find module '" + request + "'"); err.code = 'MODULE_NOT_FOUND'; throw err; } return filename; }; // ... |
在 Module._resolveFilename
中呼叫了 Module._findPath
,模組載入的判斷邏輯實際上集中在這個方法中,由於這個方法較長,直接附上 github 該方法程式碼:
https://github.com/nodejs/node/blob/v6.x/lib/module.js#L158
可以看出,檔案路徑解析的邏輯流程是這樣的:
- 先生成 cacheKey,判斷相應 cache 是否存在,若存在直接返回
- 如果 path 的最後一個字元不是
/
:- 如果路徑是一個檔案並且存在,那麼直接返回檔案的路徑
- 如果路徑是一個目錄,呼叫
tryPackage
函式去解析目錄下的package.json
,然後取出其中的main
欄位所寫入的檔案路徑- 判斷路徑如果存在,直接返回
- 嘗試在路徑後面加上 .js, .json, .node 三種字尾名,判斷是否存在,存在則返回
- 嘗試在路徑後面依次加上 index.js, index.json, index.node,判斷是否存在,存在則返回
- 如果還不成功,直接對當前路徑加上 .js, .json, .node 字尾名進行嘗試
- 如果 path 的最後一個字元是
/
:- 呼叫
tryPackage
,解析流程和上面的情況類似 - 如果不成功,嘗試在路徑後面依次加上 index.js, index.json, index.node,判斷是否存在,存在則返回
- 呼叫
解析檔案中用到的 tryPackage
和 tryExtensions
方法的 github 連結: https://github.com/nodejs/node/blob/v6.x/lib/module.js#L108 https://github.com/nodejs/node/blob/v6.x/lib/module.js#L146
整個流程可以參考下面這張圖:
而在檔案路徑解析完成之後,根據檔案路徑檢視快取是否存在,存在直接返回,不存在的話,走到 3 或者 4 步驟。
這裡,在 3、4 兩步產生了兩個分支,即核心模組和第三方模組的載入方法不一樣。由於我們假設了我們的 index.js
中為 var http = require('http')
,http 是一個核心模組,所以我們先來分析核心模組載入的這個分支。
4.1 核心模組載入原理
核心模組是通過 NativeModule.require
載入的,NativeModule的定義在 bootstrap_node.js
中,附上 github 連結: https://github.com/nodejs/node/blob/v6.x/lib/internal/bootstrap_node.js#L401
從程式碼中可以看到,NativeModule.require
的流程如下:
- 判斷 cache 中是否已經載入過,如果有,直接返回 exports
- 新建 nativeModule 物件,然後快取,並載入編譯
首先我們來看一下如何編譯,從程式碼中看是呼叫了 compile
方法,而在 NativeModule.prototype.compile
方法中,首先是通過 NativeModule.getSource
獲取了要載入模組的原始碼,那麼這個原始碼是如何獲取的呢?看一下 getSource
方法的定義:
1 2 3 4 5 6 7 |
// lib/internal/bootstrap_node.js // ... NativeModule._source = process.binding('natives'); // ... NativeModule.getSource = function(id) { return NativeModule._source[id]; }; |
直接從 NativeModule._source
獲取的,而這個又是在哪裡賦值的呢?在上述程式碼中也擷取了出來,是通過 NativeModule._source = process.binding('natives')
獲取的。
這裡就要插入介紹一下 JavaScript native 模組程式碼是如何儲存的了。Node.js 原始碼編譯的時候,會採用 v8 附帶的 js2c.py 工具,將 lib 資料夾下面的 js 模組的程式碼都轉換成 C 裡面的陣列,生成一個 node_natives.h 標頭檔案,記錄這個陣列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
namespace node { const char node_native[] = {47, 47, 32, 67, 112 …} const char console_native[] = {47, 47, 32, 67, 112 …} const char buffer_native[] = {47, 47, 32, 67, 112 …} … } struct _native {const char name; const char* source; size_t source_len;}; static const struct _native natives[] = { { “node”, node_native, sizeof(node_native)-1 }, {“dgram”, dgram_native, sizeof(dgram_native)-1 }, {“console”, console_native, sizeof(console_native)-1 }, {“buffer”, buffer_native, sizeof(buffer_native)-1 }, … } |
而上文中 NativeModule._source = process.binding('natives');
的作用,就是取出這個 natives 陣列,賦值給NativeModule._source
,所以在 getSource
方法中,直接可以使用模組名作為索引,從陣列中取出模組的原始碼。
在這裡我們插入回顧一下上文,在介紹 Node.js 編譯的時候,我們介紹了 node.gyp
,其中有一個任務是 node_js2c
,當時筆者提到從名稱看這個任務是將 JavaScript 轉換為 C 程式碼,而這裡的 natives 陣列中的 C 程式碼,正是這個構建任務的產物。而到了這裡,我們終於知道了這個編譯任務的作用了。
知道了原始碼的獲取,繼續往下看 compile
方法,看看原始碼是如何編譯的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// lib/internal/bootstrap_node.js NativeModule.wrap = function(script) { return NativeModule.wrapper[0] + script + NativeModule.wrapper[1]; }; NativeModule.wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});' ]; NativeModule.prototype.compile = function() { var source = NativeModule.getSource(this.id); source = NativeModule.wrap(source); this.loading = true; try { const fn = runInThisContext(source, { filename: this.filename, lineOffset: 0, displayErrors: true }); fn(this.exports, NativeModule.require, this, this.filename); this.loaded = true; } finally { this.loading = false; } }; // ... |
NativeModule.prototype.compile
在獲取到原始碼之後,它主要做了:使用 wrap
方法處理原始碼,最後呼叫 runInThisContext 進行編譯得到一個函式,最後執行該函式。其中 wrap 方法,是給原始碼加上了一頭一尾,其實相當於是將原始碼包在了一個函式中,這個函式的引數有 exports, require, module 等。這就是為什麼我們寫模組的時候,不需要定義 exports, require, module 就可以直接用的原因。
至此就基本講清楚了 Node.js 核心模組的載入過程。說到這裡大家可能有一個疑惑,上述分析過程,好像只涉及到了核心模組中的 JavaScript native模組,那麼對於 C/C++ built-in 模組呢?
其實是這樣的,對於 built-in 模組而言,它們不是通過 require 來引入的,而是通過 precess.binding('模組名')
引入的。一般我們很少在自己的程式碼中直接使用 process.binding
來引入built-in模組,而是通過 require
引用native模組,而 native 模組裡面會引入 built-in 模組。比如我們常用的 buffer 模組,其內部實現中就引入了 C/C++ built-in 模組,這是為了避開 v8 的記憶體限制:
1 2 3 4 5 6 |
// lib/buffer.js 'use strict'; // 通過 process.binding 引入名為 buffer 的 C/C++ built-in 模組 const binding = process.binding('buffer'); // ... |
這樣,我們在 require('buffer')
的時候,其實是間接的使用了 C/C++ built-in 模組。
這裡再次出現了 process.binding
!事實上,process.binding 這個方法定義在 node.cc
中:
1 2 3 4 5 6 7 8 9 10 |
// src/node.cc // ... static void Binding(const FunctionCallbackInfo<Value>& args) { // ... node_module* mod = get_builtin_module(*module_v); // ... } // ... env->SetMethod(process, "binding", Binding); // ... |
Binding
這個函式中關鍵的一步是 get_builtin_module
。這裡需要再次插入介紹一下 C/C++ 內建模組的儲存方式:
在 Node.js 中,內建模組是通過一個名為 node_module_struct
的結構體定義的。所以的內建模組會被放入一個叫做 node_module_list
的陣列中。而 process.binding
的作用,正是使用 get_builtin_module
從這個陣列中取出相應的內建模組程式碼。
綜上,我們就完整介紹了核心模組的載入原理,主要是區分 JavaScript 型別的 native 模組和 C/C++ 型別的 built-in 模組。這裡繪製一張圖來描述一下核心模組載入過程:
而回憶我們在最開始介紹的,native 模組在原始碼中存放在 lib/ 目錄下,而 built-in 模組在原始碼中存放在 src/ 目錄下,下面這張圖則從編譯的角度梳理了 native 和 built-in 模組如何被編譯進 Node.js 可執行檔案:
4.2 第三方模組載入原理
下面讓我們繼續分析第二個分支,假設我們的 index.js
中 require 的不是 http,而是一個使用者自定義模組,那麼在 module.js 中, 我們會走到 tryModuleLoad 方法中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// lib/module.js // ... function tryModuleLoad(module, filename) { var threw = true; try { module.load(filename); threw = false; } finally { if (threw) { delete Module._cache[filename]; } } } // ... Module.prototype.load = function(filename) { debug('load %j for module %j', filename, this.id); assert(!this.loaded); this.filename = filename; this.paths = Module._nodeModulePaths(path.dirname(filename)); var extension = path.extname(filename) || '.js'; if (!Module._extensions[extension]) extension = '.js'; Module._extensions[extension](this, filename); this.loaded = true; }; // ... |
這裡看到,tryModuleLoad
中實際呼叫了 Module.prototype.load
定義的方法,這個方法主要做的事情是,檢測 filename 的副檔名,然後針對不同的副檔名,呼叫不同的 Module._extensions
方法來載入、編譯模組。接著我們看看 Module._extensions
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// lib/module.js // ... // Native extension for .js Module._extensions['.js'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); module._compile(internalModule.stripBOM(content), filename); }; // Native extension for .json Module._extensions['.json'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); try { module.exports = JSON.parse(internalModule.stripBOM(content)); } catch (err) { err.message = filename + ': ' + err.message; throw err; } }; //Native extension for .node Module._extensions['.node'] = function(module, filename) { return process.dlopen(module, path._makeLong(filename)); }; // ... |
可以看出,一共支援三種型別的模組載入:.js, .json, .node。其中 .json 型別的檔案載入方法是最簡單的,直接讀取檔案內容,然後 JSON.parse
之後返回物件即可。
下面來看對 .js 的處理,首先也是通過 fs
模組同步讀取檔案內容,然後呼叫了 module._compile
,看看相關程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// lib/module.js // ... Module.wrap = NativeModule.wrap; // ... Module.prototype._compile = function(content, filename) { // ... // create wrapper function var wrapper = Module.wrap(content); var compiledWrapper = vm.runInThisContext(wrapper, { filename: filename, lineOffset: 0, displayErrors: true }); // ... var result = compiledWrapper.apply(this.exports, args); if (depth === 0) stat.cache = null; return result; }; // ... |
首先呼叫 Module.wrap
對原始碼進行包裹,之後呼叫 vm.runInThisContext
方法進行編譯執行,最後返回 exports 的值。而從 Module.wrap = NativeModule.wrap
這一句可以看出,第三方模組的 wrap 方法,和核心模組的 wrap 方法是一樣的。我們回憶一下剛才講到的核心js模組載入關鍵程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// lib/internal/bootstrap_node.js NativeModule.wrap = function(script) { return NativeModule.wrapper[0] + script + NativeModule.wrapper[1]; }; NativeModule.wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});' ]; NativeModule.prototype.compile = function() { var source = NativeModule.getSource(this.id); source = NativeModule.wrap(source); this.loading = true; try { const fn = runInThisContext(source, { filename: this.filename, lineOffset: 0, displayErrors: true }); fn(this.exports, NativeModule.require, this, this.filename); this.loaded = true; } finally { this.loading = false; } }; |
兩廂對比,發現二者對原始碼的編譯執行幾乎是一模一樣的。從整體流程上來講,核心 JavaScript 模組與第三方 JavaScript 模組最大的不同就是,核心 JavaScript 模組原始碼是通過 process.binding('natives')
從記憶體中獲取的,而第三方 JavaScript 模組原始碼是通過 fs.readFileSync
方法從檔案中讀取的。
最後,再來看一下載入第三方 C/C++模組(.node字尾)。直觀上來看,很簡單,就是呼叫了 process.dlopen
方法。這個方法的定義在 node.cc
中:
1 2 3 4 5 6 7 8 9 10 |
// src/node.cc // ... env->SetMethod(process, "dlopen", DLOpen); // ... void DLOpen(const FunctionCallbackInfo<Value>& args) { // ... const bool is_dlopen_error = uv_dlopen(*filename, &lib); // ... } // ... |
實際上最終呼叫了 DLOpen
函式,該函式中最重要的是使用 uv_dlopen
方法開啟動態連結庫,然後對 C/C++ 模組進行載入。uv_dlopen
方法是定義在 libuv
庫中的。libuv
庫是一個跨平臺的非同步 IO 庫。對於擴充套件模組的動態載入這部分功能,在 *nix 平臺下,實際上呼叫的是 dlfcn.h 中定義的 dlopen() 方法,而在 Windows 下,則為 LoadLibraryExW() 方法,在兩個平臺下,他們載入的分別是 .so 和 .dll 檔案,而 Node.js 中,這些檔案統一被命名了 .node 字尾,遮蔽了平臺的差異。
關於 libuv
庫,是 Node.js 非同步 IO 的核心驅動力,這一塊本身就值得專門作為一個專題來研究,這裡就不展開講了。
到此為止,我們理清楚了三種第三方模組的載入、編譯過程。
5. C/C++ 擴充套件模組的開發以及應用場景
上文分析了 Node.js 當中各類模組的載入流程。大家對於 JavaScript 模組的開發應該是駕輕就熟了,但是對於 C/C++ 擴充套件模組開發可能還有些陌生。這一節就簡單介紹一下擴充套件模組的開發,並談談其應用場景。
關於 Node.js 擴充套件模組的開發,在 Node.js 官網文件中專門有一節予以介紹,大家可以移步官網文件檢視:https://nodejs.org/docs/latest-v6.x/api/addons.html 。這裡僅僅以其中的 hello world 例子來介紹一下編寫擴充套件模組的一些比較重要的概念:
假設我們希望通過擴充套件模組來實現一個等同於如下 JavaScript 函式的功能:
1 |
module.exports.hello = () => 'world'; |
首先建立一個 hello.cc 檔案,編寫如下程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// hello.cc #include <node.h> namespace demo { using v8::FunctionCallbackInfo; using v8::Isolate; using v8::Local; using v8::Object; using v8::String; using v8::Value; void Method(const FunctionCallbackInfo<Value>& args) { Isolate* isolate = args.GetIsolate(); args.GetReturnValue().Set(String::NewFromUtf8(isolate, "world")); } void init(Local<Object> exports) { NODE_SET_METHOD(exports, "hello", Method); } NODE_MODULE(NODE_GYP_MODULE_NAME, init) } // namespace demo |
檔案雖短,但是已經出現了一些我們比較陌生的程式碼,這裡一一介紹一下,對於瞭解擴充套件模組基礎知識還是很有幫助的。
首先在開頭引入了 node.h
,這個是編寫 Node.js 擴充套件時必用的標頭檔案,裡面幾乎包含了我們所需要的各種庫、資料型別。
其次,看到了很多 using v8:xxx
這樣的程式碼。我們知道,Node.js 是基於 v8 引擎的,而 v8 引擎,就是用 C++ 來寫的。我們要開發 C++ 擴充套件模組,便需要使用 v8 中提供的很多資料型別,而這一系列程式碼,正是宣告瞭需要使用 v8 名稱空間下的這些資料型別。
然後來看 Method
方法,它的引數型別 FunctionCallbackInfo<Value>& args
,這個 args 就是從 JavaScript 中傳入的引數,同時,如果想在 Method
中為 JavaScript 返回變數,則需要呼叫 args.GetReturnValue().Set
方法。
接下來需要定義擴充套件模組的初始化方法,這裡是 Init
函式,只有一句簡單的 NODE_SET_METHOD(exports, "hello", Method);
,代表給 exports 賦予一個名為 hello
的方法,這個方法的具體定義就是 Method
函式。
最後是一個巨集定義:NODE_MODULE(NODE_GYP_MODULE_NAME, init)
,第一個引數是希望的擴充套件模組名稱,第二個引數就是該模組的初始化方法。
為了編譯這個模組,我們需要通過npm安裝 node-gyp
編譯工具。該工具將 Google 的 gyp
工具封裝,用來構建 Node.js 擴充套件。安裝這個工具後,我們在原始碼資料夾下面增加一個名為 bingding.gyp
的配置檔案,對於我們這個例子,檔案只要這樣寫:
1 2 3 4 5 6 7 8 |
{ "targets": [ { "target_name": "addon", "sources": [ "hello.cc" ] } ] } |
這樣,執行 node-gyp build
即可編譯擴充套件模組。在這個過程中,node-gyp
還會去指定目錄(一般是 ~/.node-gyp)下面搜我們當前 Node.js 版本的一些標頭檔案和庫檔案,如果不存在,它還會幫我們去 Node.js 官網下載。這樣,在編寫擴充套件的時候,通過 #include <>
,我們就可以直接使用所有 Node.js 的標頭檔案了。
如果編譯成功,會在當前資料夾的 build/Release/
路徑下看到一個 addon.node
,這個就是我們編譯好的可 require 的擴充套件模組。
從上面的例子中,我們能大體看出擴充套件模組的運作模式,它可以接收來自 JavaScript 的引數,然後中間可以呼叫 C/C++ 語言的能力去做各種運算、處理,然後最後可以將結果再返回給 JavaScript。
值得注意的是,不同 Node.js 版本,依賴的 v8 版本不同,導致很多 API 會有差別,因此使用原生 C/C++ 開發擴充套件的過程中,也需要針對不同版本的 Node.js 做相容處理。比如說,宣告一個函式,在 v6.x 和 v0.12 以下的版本中,分別需要這樣寫:
1 2 |
Handle<Value> Example(const Arguments& args); // 0.10.x void Example(FunctionCallbackInfo<Value>& args); // 6.x |
可以看到,函式的宣告,包括函式中引數的寫法,都不盡相同。這讓人不由得想起了在 Node.js 開發中,為了寫 ES6,也是需要使用 Babel 來幫忙進行相容性轉換。那麼在 Node.js 擴充套件開發領域,有沒有類似 Babel 這樣幫助我們處理相容性問題的庫呢?答案是肯定的,它的名字叫做 NAN (Native Abstraction for Node.js)。它本質上是一堆巨集,能夠幫助我們檢測 Node.js 的不同版本,並呼叫不同的 API。例如,在 NAN 的幫助下,宣告一個函式,我們不需要再考慮 Node.js 版本,而只需要寫一段這樣的程式碼:
1 2 3 4 5 |
#include <nan.h> NAN_METHOD(Example) { // ... } |
NAN 的巨集會在編譯的時候自動判斷,根據 Node.js 版本的不同展開不同的結果,從而解決了相容性問題。對 NAN 更詳細的介紹,感興趣的同學可以移步該專案的 github 主頁:https://github.com/nodejs/nan。
介紹了這麼多擴充套件模組的開發,可能有同學會問了,像這些擴充套件模組實現的功能,看起來似乎用js也可以很快的實現,何必大費周折去開發擴充套件呢?這就引出了一個問題:C/C++ 擴充套件的適用場景。
筆者在這裡大概歸納了幾類 C/C++ 適用的情景:
- 計算密集型應用。我們知道,Node.js 的程式設計模型是單執行緒 + 非同步 IO,其中單執行緒導致了它在計算密集型應用上是一個軟肋,大量的計算會阻塞 JavaScript 主執行緒,導致無法響應其他請求。對於這種場景,就可以使用 C/C++ 擴充套件模組,來加快計算速度,畢竟,雖然 v8 引擎的執行速度很快,但終究還是比不過 C/C++。另外,使用 C/C++,還可以允許我們開多執行緒,避免阻塞 JavaScript 主執行緒,社群裡目前已經有一些基於擴充套件模組的 Node.js 多執行緒方案,其中最受歡迎的可能是一個叫做
thread-a-gogo
的專案,具體可以移步 github:https://github.com/xk/node-threads-a-gogo。 - 記憶體消耗較大的應用。Node.js 是基於 v8 的,而 v8 一開始是為瀏覽器設計的,所以其在記憶體方面是有比較嚴格的限制的,所以對於一些需要較大記憶體的應用,直接基於 v8 可能會有些力不從心,這個時候就需要使用擴充套件模組,來繞開 v8 的記憶體限制,最典型的就是我們常用的 buffer.js 模組,其底層也是呼叫了 C++,在 C++ 的層面上去申請記憶體,避免 v8 記憶體瓶頸。
關於第一點,筆者這裡也分別用原生 Node.js 以及 Node.js 擴充套件實現了一個測試例子來對比計算效能。測試用例是經典的計算斐波那契數列,首先使用 Node.js 原生語言實現一個計算斐波那契數列的函式,取名為 fibJs
:
1 2 3 4 5 6 7 8 |
function fibJs(n) { if (n === 0 || n === 1) { return n; } else { return fibJs(n - 1) + fibJs(n - 2); } } |
然後使用 C++ 編寫一個實現同樣功能的擴充套件函式,取名 fibC
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// fibC.cpp #include <node.h> #include <math.h> using namespace v8; int fib(int n) { if (n == 0 || n ==1) { return n; } else { return fib(n - 1) + fib(n - 2); } } void Method(const FunctionCallbackInfo<Value>& args) { Isolate* isolate = args.GetIsolate(); int n = args[0]->NumberValue(); int result = fib(n); args.GetReturnValue().Set(result); } void init(Local < Object > exports, Local < Object > module) { NODE_SET_METHOD(module, "exports", Method); } NODE_MODULE(fibC, init) |
在測試中,分別使用這兩個函式計算從 1~40 的斐波那契數列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
function testSpeed(fn, testName) { var start = Date.now(); for (var i = 0; i < 40; i++) { fn(i); } var spend = Date.now() - start; console.log(testName, 'spend time: ', spend); } // 使用擴充套件模組測試 var fibC = require('./build/Release/fibC'); // 這裡是擴充套件模組編譯產物的存放路徑 testSpeed(fibC, 'c++ test:'); // 使用 JavaScript 函式進行測試 function fibJs(n) { if (n === 0 || n === 1) { return n; } else { return fibJs(n - 1) + fibJs(n - 2); } } testSpeed(fibJs, 'js test:'); // c++ test: spend time: 1221 // js test: spend time: 2611 |
多次測試,擴充套件模組平均花費時長大約 1.2s,而 JavaScript 模組花費時長大約 2.6s,可見在此場景下,C/C++ 擴充套件效能還是要快上不少的。
當然,這幾點只是基於筆者的認識。在實際開發過程中,大家在遇到問題的時候,也可以嘗試著考慮如果使用 C/C++ 擴充套件模組,問題是不是能夠得到更好的解決。
結語
文章讀到這裡,我們再回去看一下一開始提出的那些問題,是否在文章分析的過程中都得到了解答?再來回顧一下本文的邏輯脈絡:
- 首先以一個
node index.js
的執行原理開始,指出使用node
執行一個檔案,等同於立即執行一次require
。 - 然後引出了node中的require方法,在這裡,區分了核心模組、內建模組和非核心模組幾種情況,分別詳述了載入、編譯的流程原理。在這個過程中,還分別涉及到了模組路徑解析、模組快取等等知識點的描述。
- 最後介紹了大家不太熟悉的c/c++擴充套件模組的開發,並結合一個效能對比的例子來說明其適用場景。
事實上,通過學習 Node.js 模組載入流程,有助於我們更深刻的瞭解 Node.js 底層的執行原理,而掌握了其中的擴充套件模組開發,並學會在適當的場景下使用,則能夠使得我們開發出的 Node.js 應用效能更高。
學習 Node.js 原理是一條漫長的路徑。建議瞭解了底層模組機制的讀者,可以去更深入的學習 v8, libuv 等等知識,對於精通 Node.js,必將大有裨益。