結合源碼分析 Node.js 模塊加載與運行原理

原文連接自個人我的博客: https://github.com/mly-zju/blog/issues/10 歡迎關注。

Node.js 的出現,讓 JavaScript 脫離了瀏覽器的束縛,進入了廣闊的服務端開發領域。而 Node.js 對 CommonJS 模塊化規範的引入,則更是讓 JavaScript成爲了一門真正可以適應大型工程的語言。javascript

在 Node.js 中使用模塊很是簡單,咱們平常開發中幾乎都有過這樣的經歷:寫一段 JavaScript 代碼,require 一些想要的包,而後將代碼產物 exports 導出。可是,對於 Node.js 模塊化背後的加載與運行原理,咱們是否清楚呢。首先拋出如下幾個問題:html

  • Node.js 中的模塊支持哪些文件類型?
  • 核心模塊和第三方模塊的加載運行流程有什麼不一樣?
  • 除了 JavaScript 模塊之外,怎樣去寫一個 C/C++ 擴展模塊?
  • ……

本篇文章,就會結合 Node.js 源碼,探究一下以上這些問題背後的答案。java

1. Node.js 模塊類型

在 Node.js 中,模塊主要能夠分爲如下幾種類型:node

    • 核心模塊:包含在 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 等等。python

      • JavaScript 模塊,這是最多見的,咱們開發的時候通常都寫的是 JavaScript 模塊
      • JSON 模塊,這個很簡單,就是一個 JSON 文件
      • C/C++ 擴展模塊,使用 C/C++ 編寫,編譯以後後綴名爲 .node

    本篇文章中,咱們會一一涉及到上述幾種模塊的加載、運行原理。android

    2. Node.js 源碼結構一覽

    這裏使用 Node.js 6.x 版本源碼爲例子來作分析。去 github 上下載相應版本的 Node.js 源碼,能夠看到代碼大致結構以下:webpack

    ├── 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

    其中:c++

    • ./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 編譯流程。git

    首先,咱們須要認識一下編譯用到的組織工具,即 gyp。Node.js 源碼中咱們能夠看到一個 node.gyp,這個文件中的內容是由 python 寫成的一些 JSON-like 配置,定義了一連串的構建工程任務。咱們舉個例子,其中有一個字段以下:github

    {
          '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 源碼目錄,咱們只須要依次運行以下命令:

    ./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 中:

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

    // 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

    // 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 有以下內容:

    var http = require('http');

    那麼當執行這一句代碼的時候,會發生什麼呢?

    require的定義依然在 lib/module.js 中:

    // 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 來看看這個關鍵的方法究竟作了什麼吧:

    // 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;
    };
    // ...

    這段代碼的流程比較清晰,具體說來:

    1. 根據文件名,調用 Module._resolveFilename 解析文件的路徑
    2. 查看緩存 Module._cache 中是否有該模塊,若是有,直接返回
    3. 經過 NativeModule.nonInternalExists 判斷該模塊是否爲核心模塊,若是核心模塊,調用核心模塊的加載方法 NativeModule.require
    4. 若是不是核心模塊,新建立一個 Module 對象,調用 tryModuleLoad 函數加載模塊

    咱們首先來看一下 Module._resolveFilename,看懂這個方法對於咱們理解 Node.js 的文件路徑解析原理頗有幫助:

    // 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,判斷是否存在,存在則返回

    解析文件中用到的 tryPackagetryExtensions 方法的 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

    整個流程能夠參考下面這張圖:

    clipboard.png

    而在文件路徑解析完成以後,根據文件路徑查看緩存是否存在,存在直接返回,不存在的話,走到 3 或者 4 步驟。

    這裏,在 三、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 的流程以下:

    1. 判斷 cache 中是否已經加載過,若是有,直接返回 exports
    2. 新建 nativeModule 對象,而後緩存,並加載編譯

    首先咱們來看一下如何編譯,從代碼中看是調用了 compile 方法,而在 NativeModule.prototype.compile 方法中,首先是經過 NativeModule.getSource 獲取了要加載模塊的源碼,那麼這個源碼是如何獲取的呢?看一下 getSource 方法的定義:

    // 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 頭文件,記錄這個數組:

    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 方法,看看源碼是如何編譯的:

    // 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 的內存限制:

    // 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 中:

    // 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 模塊。這裏繪製一張圖來描述一下核心模塊加載過程:

    clipboard.png

    而回憶咱們在最開始介紹的,native 模塊在源碼中存放在 lib/ 目錄下,而 built-in 模塊在源碼中存放在 src/ 目錄下,下面這張圖則從編譯的角度梳理了 native 和 built-in 模塊如何被編譯進 Node.js 可執行文件:

    clipboard.png

    4.2 第三方模塊加載原理

    下面讓咱們繼續分析第二個分支,假設咱們的 index.js 中 require 的不是 http,而是一個用戶自定義模塊,那麼在 module.js 中, 咱們會走到 tryModuleLoad 方法中:

    // 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:

    // 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,看看相關代碼:

    // 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模塊加載關鍵代碼:

    // 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 中:

    // 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 函數的功能:

    module.exports.hello = () => 'world';

    首先建立一個 hello.cc 文件,編寫以下代碼:

    // 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 的配置文件,對於咱們這個例子,文件只要這樣寫:

    {
      "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 如下的版本中,分別須要這樣寫:

    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 版本,而只須要寫一段這樣的代碼:

    #include <nan.h>
    
    NAN_METHOD(Example) {
      // ...
    }

    NAN 的宏會在編譯的時候自動判斷,根據 Node.js 版本的不一樣展開不一樣的結果,從而解決了兼容性問題。對 NAN 更詳細的介紹,感興趣的同窗能夠移步該項目的 github 主頁:https://github.com/nodejs/nan

    介紹了這麼多擴展模塊的開發,可能有同窗會問了,像這些擴展模塊實現的功能,看起來彷佛用js也能夠很快的實現,何須大費周折去開發擴展呢?這就引出了一個問題:C/C++ 擴展的適用場景。

    筆者在這裏大概概括了幾類 C/C++ 適用的情景:

    1. 計算密集型應用。咱們知道,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
    2. 內存消耗較大的應用。Node.js 是基於 v8 的,而 v8 一開始是爲瀏覽器設計的,因此其在內存方面是有比較嚴格的限制的,因此對於一些須要較大內存的應用,直接基於 v8 可能會有些力不從心,這個時候就須要使用擴展模塊,來繞開 v8 的內存限制,最典型的就是咱們經常使用的 buffer.js 模塊,其底層也是調用了 C++,在 C++ 的層面上去申請內存,避免 v8 內存瓶頸。

    關於第一點,筆者這裏也分別用原生 Node.js 以及 Node.js 擴展實現了一個測試例子來對比計算性能。測試用例是經典的計算斐波那契數列,首先使用 Node.js 原生語言實現一個計算斐波那契數列的函數,取名爲 fibJs

    function fibJs(n) {
        if (n === 0 || n === 1) {
            return n;
        }
        else {
            return fibJs(n - 1) + fibJs(n - 2);
        }
    }

    而後使用 C++ 編寫一個實現一樣功能的擴展函數,取名 fibC:

    // 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 的斐波那契數列:

    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,必將大有裨益。

    相關文章
    相關標籤/搜索