连系源码剖析 Node.js 模块加载与运转道理

原文链接自我的个人博客:
https://github.com/mly-zju/blog/issues/10 迎接关注。

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 源码,能够看到代码大致构造以下:

    ├── 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 设置,定义了一连串的构建工程使命。我们举个例子,个中有一个字段以下:

    {
          '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

    全部流程能够参考下面这张图:

    《连系源码剖析 Node.js 模块加载与运转道理》

    而在文件途径剖析完成以后,依据文件途径检察缓存是不是存在,存在直接返回,不存在的话,走到 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 的流程以下:

    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 模块。这里绘制一张图来形貌一下中心模块加载历程:

    《连系源码剖析 Node.js 模块加载与运转道理》

    而回想我们在最最先引见的,native 模块在源码中寄存在 lib/ 目次下,而 built-in 模块在源码中寄存在 src/ 目次下,下面这张图则从编译的角度梳理了 native 和 built-in 模块怎样被编译进 Node.js 可实行文件:

    《连系源码剖析 Node.js 模块加载与运转道理》

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

        原文作者:勇敢的半导体
        原文地址: https://segmentfault.com/a/1190000014147207
        本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
    点赞