1. 程式人生 > 其它 >深入理解nodejs的模組機制

深入理解nodejs的模組機制

  • CommonJS規範回顧與nodejs模組機制基礎內容
  • 基於VS調式斷點了解node模組載入編譯原始碼流程
  • 基於nodejs的VM模組手動模擬實現模組載入
  • node的模組編譯
  • C/C++擴充套件模組

 一、CommonJS規範回顧與nodejs模組機制基礎內容

在深入瞭解nodejs模組機制之前首先回顧一下CommonJS規範和nodejs模組機制的基礎內容,為什麼叫做回顧呢?因為在兩年前就以過一篇非常簡單的nodejs模組相關的部落格:js模組化入門與commonjs解析與應用,這篇部落格簡單的介紹了CommonJS的原理、應用、基於Browserify編譯成瀏覽器能執行的JS程式碼。

1.1CommonJS規範出現的原因

//javaScript的語言缺陷
沒有模組系統。
標準庫較少。比如ECMAScript僅定義了部分核心庫,對於檔案系統、I/O流等常見需求卻沒有標準的API。
沒有標準介面。JavaScript幾乎沒有定義過web伺服器或資料庫之類的標準統一介面。
缺乏包管理工具。這導致JavaScript基本沒有自動載入和安裝依賴的能力。

1.2基於CommonJS規範解決了那些問題

可以實現服務端的JavaScript應用程式。
命令列工具。
桌面圖形介面應用程式。
混合應用

1.3CommonJS規範涵蓋了那些內容:

模組、二進位制、Buffer、字符集編碼、I/O流、程序環境、檔案系統、套位元組、單元測試、web伺服器閘道器介面、包管理等。

基於這些內容,再通過下面的示圖來理解Node與瀏覽器及W3C、CommonJS組織、ECMAScript之間的關係,也可以理解為JavaScript的生態系統:

 

 1.4Commonjs模組規範

模組引入:require(module)
模組定義或匯出:exports.attr 、module.exports = {k:y}
模組標識:必須符合小駝峰命名的字串,或者以(.)和(..)開頭的相對路徑,或者絕對路徑,可以沒有(.js)字尾。

模組規範的具體表現特性:

任意一個檔案就是一個模組,具有獨立作用域
module屬性:
在任意模組中可直接使用的包含模組資訊
id:返回模組識別符號,一般是一個絕對路徑
filename:返回檔案模組的絕對路徑
loaded:返回布林值,標識模組是否完成載入
parent:返回物件存放呼叫當前模組的模組
children:返回陣列,存放當前模組呼叫的其他模組
exports:返回當前模組需要暴露的內容
paths:返回陣列,存放不同目錄下的node_modules位置
require屬性:
基本功能是讀取並執行一個模組檔案
resolve:返回模組檔案絕對路徑
extensions:依據不同字尾名執行解析操作
main:返回組模組物件
exports與module.exports屬性:匯出模組資料
CommonJs規範定義模組的載入是同步完成

測試CommonJS規範在nodejs中的具體表現的屬性:假設現在有一個依賴模組m.js和入口模組index.js:

 1 //測試module
 2 //m.js
 3 module.exports = 111
 4 console.log(module);
 5 //index.js
 6 require(./m.js);
 7 
 8 //測試exports--module
 9 //m.js
10 //exports = 111   //如果直接在這裡使用一個原始值型別賦值給exports不會正常匯出,在其他模組中匯入這個模組獲得的是一個空物件
11 // exports = {num:333};    //同上,直接使用一個引用值型別賦值給exports也會切斷與module.exports的關聯
12 exports.num = 222          //這個附加屬性的方式能正常匯出
13 console.log(module);
14 //index.js
15 require(./m.js)
16 
17 //測試同步載入
18 //m.js
19 let iTime = new Date();
20 console.log("--------------------");
21 while(new Date() - iTime < 3000){}
22 console.log("....................");
23 //index.js
24 let m = require("./m.js");
25 console.log("index執行了");
26 
27 //測試入口檔案
28 //m.js
29 console.log(require.main === module);
30 //index.js
31 require("./m.js");
32 console.log(require.main === module);
View Code

1.5Node中的模組實現:

1.5.1在node中模組分為兩類:Node提供的基礎模組,也被稱為核心模組、使用者編寫的模組,也被稱為檔案模組。

核心模組在node原始碼編譯過程中,編譯進了二進位制執行檔案。在node程序啟動時,部分核心模組就被直接載入進記憶體,所以這部分核心模組引入時,檔案定位和編譯可以省略,並且在路徑分析中優先判斷,所以它載入的速度是最快的。

檔案模組則在執行時動態載入,需要完整的路徑分析、檔案定位、編譯執行。

1.5.2在node中引入模組的3個步驟:路徑分析、檔案定位、編譯執行。

在路徑分析之前,其實node引入模組是優先從快取中載入模組,當然這個快取存在之前肯定是經歷過模組載入的三個步驟。既然使用快取就需要關注是使用快取的副本還是直接使用快取本身,這兩者的區別核心就在於副本肯定不會產生依賴之間的專案共用和干擾問題,而不幸的是nodejs是直接使用快取本身,也就是說在不同的模組裡引入同一個模組,它們是共用這個模組的,也就是說如果有一個模組修改了這個依賴模組內的資料,就會導致其他模組依賴這個模組的資料發生變化。例如可以測試下面這個示例:

//專案根目錄
index.js //入口檔案
m1.js    //公共被依賴模組
m2.js 
m3.js

程式碼我放到一個程式碼視窗了,測試的時候自行拆分:

 1 //m1.js
 2 module.exports = {
 3     sum:0,
 4     fun:function(){
 5         this.sum ++;
 6     }
 7 }
 8 
 9 //m2.js
10 let m1 = require('./m1.js');
11 console.log("m2載入:" + m1.sum);
12 m1.fun();
13 console.log("m2執行:" + m1.sum);
14 
15 //m3.js
16 let m1 = require('./m1.js');
17 console.log("m3載入:" + m1.sum);
18 m1.fun();
19 console.log("m3執行:" + m1.sum);
20 
21 //index.js
22 let m1 = require("./m1.js");
23 let m2 = require('./m2.js');
24 let m3 = require('./m3.js');
25 console.log("入口檔案載入:" + m1.sum);
26 m1.fun();
27 console.log("入口檔案執行:" + m1.sum);

測試列印結果:

m2載入:0
m2執行:1
m3載入:1
m3執行:2
入口檔案載入:2
入口檔案執行:3

這種共用一個依賴可能在你依賴設定不嚴謹的情況下,就會導致出現數據衝突的問題。既然存在問題就必然有它的原因,這種使用同一個指令碼快取可以非常有效的提高效能。而且需要注意,node在載入模組時會初識化執行一次該模組,但如果引入的模組已經存在快取中就不會再觸發編譯執行,而是直接使用之前的編譯執行結果。

不論是核心模組還是檔案模組,都採用的是優先使用快取,唯一不同的是如果出現檔案模組和核心模組重名會優先使用核心模組的快取,這跟路徑分析的優先順序一致。

路徑分析與檔案定位:

檔案識別符號在node中分為四大類:
1.核心模組,如http、fs、path等
2.以.或..開始的相對路徑檔案模組
3.以/開始的絕對路徑檔案模組
4.非路徑形式的檔案模組

在對node的require路徑分析進行解析之前,構建一個簡單的測試程式碼:

 1 //根目錄
 2 node_modules    //資料夾:手動建立一個當前專案下的模組包資料夾
 3 --fs            //資料夾:自定義模組fs
 4 ----index.js    //自定義模組fs的入口檔案
 5 --aaa           //資料夾:自定義模組aaa
 6 ----index.js    //自定義模組aaa的入口檔案
 7 --index.js      //測試專案的入口檔案
 8 --fs.js         //根目錄下測試一個fs模組重名的自定義檔案模組
 9 
10 //測試程式碼
11 //node_modules/fs/index.js
12 module.exports={
13     fun:function(){console.log("--fs")}
14 }
15 //node_modules/aaa/index.js
16 module.exports={
17     fun:function(){console.log("--aaa")}
18 }
19 //fs.js
20 module.exports={
21     fun:function(){console.log("fun")}
22 }
23 //index.js
24 let fs1 = require('fs');
25 let fs2 = require('fs.js');  //這個匯入就會失敗,報fs.js不是一個模組(執行失敗以後註釋這行程式碼再測試)
26 let fs3 = require('./fs.js');   //這個能匯出根目錄下的檔案模組
27 let aaa = require('aaa');
28 console.log(fs1.constants.S_IFREG);
29 fs2.fun();  //注意這行程式碼不會執行
30 fs3.fun();
31 aaa.fun();    

將25、29行程式碼註釋後測試的結果:

32768
fun
--aaa

通過上面的測試結果可以得出:

按照模組識別符號會將模組分為兩大類:路徑模組、非路徑模組
路徑模組:會通過路徑直接匯入模組
非路徑模組:優先匯入核心模組、然後匹配node_module中的包名匯入其入口檔案

node模組路徑分析是不允許出現這兩類情況之外的其他形式的,這也是require('fs.js')會報錯的原因。

最後關於路徑分析就是要注意非路徑模組的逐層優先順序問題,這時候你可以再剛剛的測試入口檔案index.js中寫入下面這行程式碼:

console.log(process.argv);
console.log(module.paths);

然後測試module.paths會打印出一系列的node_modules模組包資料夾路徑的陣列,在匯入非路徑模組時node會優先從node程式目錄中的node_modules中去匹配核心模組,如果沒有匹配到核心模組就會從module.paths的node_modules中去逐個匹配,直到匹配到為止,如果沒有匹配到就會報改模組不存在。

副檔名分析:

無論是路徑模組還是非路徑模組,最後檔案匯入還有一個非常關鍵的環節,這個環節的工作就是分析路徑模組或非路徑模組的入口檔案的副檔名,因為node模組包含三種檔案型別:.js、.json、.node。

現在假設在根目錄入口檔案index.js中匯入模組的程式碼如下所示:

console.log("./aaa")

按照上面的測試程式碼,node模組匯入根據路徑分析到了根目錄下,它會一次在“./aaa”後面新增.js、.json、.node字尾去匹配模組。如果匹配到aaa.js就匯入這個模組,後面的json和node字尾就不會再匹配,以此類推。

同樣在非路徑模組中如果沒有配置檔案package.json或package.json中配置的main指向的路徑也沒有後綴,也會按照.js、.json、.node優先順序去匹配模組。

//模組載入流程
路徑分析:確定目標模組位置
檔案定位:確定目標模組中的具體檔案
編譯執行:對模組內容進行編譯,返回可用exports物件

 二、基於VS調式斷點了解node模組載入編譯原始碼流程

 2.1基於VS Code除錯斷點分析node模組載入流程

windows下快捷鍵:ctrl + shift + d

先準備兩個最簡單的模組:m.js、index.js

//m.js
exports.name = "m";
//index.js
let m = require("./m");
console.log(m.name);

然後快捷鍵:“ctrl + shift + d”自定義建立launch.json除錯檔案,選中node環境:

 

 因為要檢視node的原始碼,所以launch.json除錯檔案需要做一些修改:

 在“let m = require("./m");”這行程式碼前打上斷點,使用F5啟動除錯:

具體的原始碼閱讀這裡就不做具體的介紹了,後面會對模組編譯做歸納性解析,結合前面的模組載入流程對照原始碼除錯執行理解就好。

 三、基於nodejs的VM模組手動模擬實現模組載入

VM模組是Nodejs中的核心模組,支援require方法和Nodejs的執行機制。通過VM,JS可以被編譯後立即執行或者編譯儲存下來稍後執行。

VM模組包含三個用常用的方法,用於建立獨立的沙箱機制:

vm.runInThisContext(code, filename);
vm.createContext()
vm.runInNewContext(code, sandbox, opt)

瞭解一些詳細內容可以參考這篇部落格:https://www.jb51.net/article/65554.htm

基於VM模擬實現require()方法實現模組載入:

 1 const fs = require('fs');
 2 const path = require('path');
 3 const vm = require('vm');
 4 
 5 function Module(id){
 6     this.id = id;
 7     this.exports = {};  //最終被匯出的物件
 8 }
 9 Module._resolveFilename = function(filename){
10     //使用path將filename轉換成絕對路徑
11     let absPath = path.resolve(__dirname, filename);
12     //判斷當前路徑對應的內容是否存在
13     if(fs.existsSync(absPath)){
14         //如果條件成立說明absPath對應的內容是存在的
15         return absPath;
16     }else {
17         //檔案定位
18         let suffix = Object.keys(Module._extensions);
19         for(let i = 0; i < suffix.length; i++){
20             let newPath = absPath + suffix[i];
21             if(fs.existsSync(newPath)){
22                 return newPath;
23             }
24         }
25     }
26     throw new Error(`${filename} is not exists`);
27 };
28 Module._extensions = {
29     '.js'(module){
30         //讀取模組檔案內容
31         let content = fs.readFileSync(module.id,'utf-8');
32         //包裝
33         content = Module.wrapper[0] + content + Module.wrapper[1];
34         //VM
35         let compileFn = vm.runInThisContext(content);
36         //準備引數值
37         let exports = module.exports;
38         let dirname = path.dirname(module.id);
39         let filename = module.id;
40         //呼叫執行
41         compileFn.call(exports, exports, myRequire, module, filename, dirname);
42     },
43     '.json'(module){
44         let content = JSON.parse(fs.readFileSync(module.id, 'utf-8'));
45         module.exports = content;
46     }
47 };
48 Module.wrapper = [
49     "(function(exports, require, module, __filename, __dirname){\n",
50     "\n})"
51 ];
52 Module._cache = {}; //快取
53 //載入
54 Module.prototype.load = function(){
55     let extname = path.extname(this.id);
56     Module._extensions[extname](this);
57 };
58 
59 function myRequire(filename){
60     //1 獲取檔案模組的絕對路徑
61     let mPath = Module._resolveFilename(filename);
62     //2 快取優先
63     let cacheModule = Module._cache[mPath];
64     if(cacheModule) return cacheModule.exports;
65     //3 建立空物件載入目標模組
66     let module = new Module(mPath);
67     //4 快取已載入過的模組
68     Module._cache[mPath] = module;
69     //5 執行載入(編譯執行)
70     module.load();
71     //6 返回資料
72     return module.exports;
73 }
74 
75 let obj = myRequire('./test');
76 let name = myRequire('./test.json');
77 console.log(obj);
78 console.log(name);

測試程式碼test.js和test.json:

1 //test.js
2 const str = "基於vm手動實現模組載入";
3 module.exports = str;
4 //test.json
5 {
6     "ame":"他鄉踏雪"
7 }

 四、node的模組編譯

編譯和執行是引入檔案模組的最後階段,定位到具體檔案後,Node會新建一個模組物件,然後根據路徑載入並編譯。對於不同副檔名,其載入方法也有所不同:

.js檔案:通過fs模組同步讀取檔案後編譯執行。
.json檔案:通過fs模組同步讀取檔案後,用JSON.parse()解析返回結果。
.node檔案:這是用C/C++編寫的擴充套件檔案,通過dlopen()方法載入最後編譯生成的檔案。
其他副檔名檔案會被當作.js檔案載入。

4.1關於JavaScript模組的編譯

本質上就是在模組的內容上使用一個立即執行函式包裝起來(參考第三節中的第33行示例程式碼),然後將這個包裝後的字串丟該VM上的runInThisContext編譯成一個Function物件,然後將例項化模組時建立的Module物件等作為引數傳入這個函式執行(參考第三節中的第35、41行程式碼),這個函式的執行就會將模組內module.exports匯出的內容新增到Module例項上,然後require返回module.exports,這個過程就實現了模組的匯入操作。

4.2關於JSON模組的編譯

這個類模組的編譯就非常簡單,是讀取JSON字串內容後直接交給JavaScript的JSON.parse()其轉換後直接交給module.exports,然後由require返回。

4.3關於C/C++模組的編譯

由於在Node中使用C/C++是編譯後的.node二進位制檔案,所以它並不需要編譯,而是通過Module._extensions上的.node方法(參考第三節中的第28行示例程式碼)直接交給process.dlopen()方法進行載入和執行。dlopen()方法在windows和*nix平臺下分別有不同的實現,其底層模組依賴於libuv相容層進行封裝。

同樣,process.dlopen()載入執行過程中,模組的exports物件與.node模組產生聯絡,然後返回給呼叫者。

每個模組編譯成功後的模組物件都會將其檔案路勁作為索引快取在Module._cache物件上,以提高二次引入的效能,所以在二次引入時匯入模組不會在進行編譯執行,而是直接將之前的編譯執行結果通過require返回。

4.4核心模組的編譯:

nodejs入門的第一節課中的架構模式中就提到過,Node的核心模組由C/C++和JavaScript兩部分編寫,C/C++檔案存放在Node專案的src目錄下,JavaScript檔案存放在lib目錄下,核心模組在編譯過程中被編譯進了二進位制檔案。

4.4.1JavaScript核心模組的編譯過程:

Node採用V8附帶的js2c.py工具,將所有內建的JavaScript程式碼(src/node.js和lib/*.js)轉換成C++裡的陣列,生成node_natives.h標頭檔案。這個過程中,JavaScript程式碼以字串的形式儲存在node名稱空間中,是不可直接執行的。

在node程序啟動時,JavaScript程式碼直接載入進記憶體中。載入過程中,JavaScript核心模組經歷了識別符號分析後直接定位到記憶體中,所以比普通檔案模組從磁碟中一處一處查詢要塊很多。

JavaScript模組在編譯時同樣要經歷通過立即執行函式包裝的過程,但它們是通過process.binding('natives')取出,編譯成功後的模組存到NativeModule._cache物件上,而不是檔案模組那樣存到Module._cache物件上。它們與檔案模組的區別就是:獲取原始碼的方式不一樣(核心模組從記憶體中載入,檔案模組從磁碟的檔案中讀取),以及它們快取執行結果的位置不同。

4.4.2C/C++核心模組的編譯過程:

在Node中C/C++核心模組也被稱為內建模組,其內部結構定義如下:

struct node_module_struct {
    int version;
    vid *dso_handle;
    const char *filename;
    void (*register_func) (v8:Handle<v8::Object> target);
    const char *modname;
};

每個內建模組在定義之後,都通過NODE_MODULE巨集將其模組定義到node名稱空間中,模組的具體初識化方法掛載結構的register_func成員:

#define NODE_MODULE(modname, regfunc)
    extern "C" {
        NODE_MODULE_EXPORT node::node_module_struct modname ##_module =
        {
            NODE_STANDARD_MODULE_STUFF,
            regfunc,
            NODE_STRINGIFY(modname)
        };
    }

node_extensions.h檔案將這些雜湊的內建模組統一放進node_module_list的陣列中,這些模組有:

node_buffer
node_crypto
node_evals
node_fs
node_http_parser
node_os
node_zlib
node_timer_wrap
node_tcp_wrap
node_udp_wrap
node_pipe_wrap
node_cares_wrap
node_tty_wrap
node_process_wrap
node_fs_event_wrap
node_signal_watcher

在Node中提供了get_builtin_module()方法從node_modeule_list陣列中取出這些模組,內建模組的優勢在於效能優於指令碼語言,在進行檔案編譯時,它們被編譯進二進位制檔案。一旦Node開始執行,它們被直接載入進記憶體中,無需在做識別符號定位、檔案定位、編譯等過程,直接就可執行。

Node在啟動時,會生成一個全域性變數process,並提供Binding()方法來協助載入內建模組,Binding()的實現程式碼在src/node.cc。在載入內建模組時,先建立一個exports空物件,然後呼叫get_builtin_module()方法取出內建模組物件,通過register_func()填充exports物件,最後將exports物件按模組名快取,並返回給呼叫方完成匯出。

process.binding()除了能匯出內建模組,前面的JavaScript核心模組被轉換成C/C++陣列儲存後,便通過process.binding('natives')取出放置在NativeModule._source中的:

NativeModule._source = process.binding('natives');

該方法將通過js2c.py工具轉換出的字串陣列取出,然後重新轉換成普通字串,以對JavaScript核心模組進行編譯和執行。

核心模組的引入流程

 五、C/C++擴充套件模組

測試編譯了《Nodejs深入淺出》和node官方文件中的示例及node-gyp在github上的示例程式碼都未成功,都是報各種語法問題,由於對C++語法不瞭解,這一節內容暫時不寫了,後面學一下C++再來補充吧。

測試程式碼還是貼出來:

//hello.cc

#include <node.h>
#include <v8.h>

using namespace v8;
Handle<Value> SayHello(const Arguments& args){
    HandleScope scope;
    return scope.Close(String::New("Hello world"));
}

void Init_Hello(Handle<Object> target){
    target->Set(String::NewSymbol("sayHello"), FunctionTemplate::New(SayHello)->GetFunction());
}

NODE_MODULE(hello, Init_Hello);

測試專案結構:

//根目錄
--src
----hello.cc
--binding.gyp

binding.gyp的內容:

{
    'targets':[
        {
            'target_name':'hello',
            'sources':[
                'src/hello.cc'
            ],
            'conditions': [
                 ['OS=="win"', {
                    'libraries': ['-lnode.lib']
                }]
            ]
        }
    ]
}

測試環境:

os:windows10

node -v:16.14.0

node-gyp -v:9.0.0

編譯工具:

Python 3.7.2

Visual Studio Community 2019(C++桌面開發)

編譯流程:

//根目錄下
node-gyp configure   //沒有報錯沒有警告
node-gyp build       //報錯: warning C4312: “型別強制轉換”: 從“int”轉換到更大的“node::addon_register_func”

有直到問題出在哪裡或者知道怎麼解決的兄弟請留言,感激不盡!