原文连接自个人我的博客: 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 源码,探究一下以上这些问题背后的答案。java
在 Node.js 中,模块主要能够分为如下几种类型:node
第三方模块:非 Node.js 源码自带的模块均可以统称第三方模块,好比 express,webpack 等等。python
本篇文章中,咱们会一一涉及到上述几种模块的加载、运行原理。android
这里使用 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,须要提早安装一些工具:
有了这些工具,进入 Node.js 源码目录,咱们只须要依次运行以下命令:
./configuration make make install
便可编译生成可执行文件并安装了。
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 的原理。
咱们进一步,假设咱们的 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; }; // ...
这段代码的流程比较清晰,具体说来:
Module._resolveFilename
解析文件的路径Module._cache
中是否有该模块,若是有,直接返回NativeModule.nonInternalExists
判断该模块是否为核心模块,若是核心模块,调用核心模块的加载方法 NativeModule.require
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
能够看出,文件路径解析的逻辑流程是这样的:
若是 path 的最后一个字符不是 /
:
若是路径是一个目录,调用 tryPackage
函数去解析目录下的 package.json
,而后取出其中的 main
字段所写入的文件路径
若是 path 的最后一个字符是 /
:
tryPackage
,解析流程和上面的状况相似解析文件中用到的 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 步骤。
这里,在 三、4 两步产生了两个分支,即核心模块和第三方模块的加载方法不同。因为咱们假设了咱们的 index.js
中为 var http = require('http')
,http 是一个核心模块,因此咱们先来分析核心模块加载的这个分支。
核心模块是经过 NativeModule.require
加载的,NativeModule的定义在 bootstrap_node.js
中,附上 github 连接:
https://github.com/nodejs/node/blob/v6.x/lib/internal/bootstrap_node.js#L401
从代码中能够看到,NativeModule.require
的流程以下:
首先咱们来看一下如何编译,从代码中看是调用了 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 模块。这里绘制一张图来描述一下核心模块加载过程:
而回忆咱们在最开始介绍的,native 模块在源码中存放在 lib/ 目录下,而 built-in 模块在源码中存放在 src/ 目录下,下面这张图则从编译的角度梳理了 native 和 built-in 模块如何被编译进 Node.js 可执行文件:
下面让咱们继续分析第二个分支,假设咱们的 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 的核心驱动力,这一块自己就值得专门做为一个专题来研究,这里就不展开讲了。
到此为止,咱们理清楚了三种第三方模块的加载、编译过程。
上文分析了 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++ 适用的情景:
thread-a-gogo
的项目,具体能够移步 github:https://github.com/xk/node-threads-a-gogo。关于第一点,笔者这里也分别用原生 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.js 模块加载流程,有助于咱们更深入的了解 Node.js 底层的运行原理,而掌握了其中的扩展模块开发,并学会在适当的场景下使用,则可以使得咱们开发出的 Node.js 应用性能更高。
学习 Node.js 原理是一条漫长的路径。建议了解了底层模块机制的读者,能够去更深刻的学习 v8, libuv 等等知识,对于精通 Node.js,必将大有裨益。