// Loads a module at the given file path. Returns that module's
// `exports` property.
Module.prototype.require = function(id) {
if (typeof id !== 'string') {
throw new ERR_INVALID_ARG_TYPE('id', 'string', id);
}
if (id === '') {
throw new ERR_INVALID_ARG_VALUE('id', id,
'must be a non-empty string');
}
return Module._load(id, this, /* isMain */ false);
};
代码前面时一些path校验,那么Module._load做了什么呢?
// Check the cache for the requested file.
// 1. If a module already exists in the cache: return its exports object.
// 2. If the module is native: call `NativeModule.require()` with the
// filename and return the result.
// 3. Otherwise, create a new module for the file and save it to the cache.
// Then have it load the file contents before returning its exports
// object.
Module._load = function(request, parent, isMain) {
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
}
if (experimentalModules && isMain) {
asyncESM.loaderPromise.then((loader) => {
return loader.import(getURLFromFilePath(request).pathname);
})
.catch((e) => {
decorateErrorStack(e);
console.error(e);
process.exit(1);
});
return;
}
var filename = Module._resolveFilename(request, parent, isMain);
var cachedModule = Module._cache[filename];
// 如果在缓存
if (cachedModule) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
// 原生模块
if (NativeModule.nonInternalExists(filename)) {
debug('load native module %s', request);
return NativeModule.require(filename);
}
// 创建新module
// Don't call updateChildren(), Module constructor already does.
var module = new Module(filename, parent);
if (isMain) {
process.mainModule = module;
module.id = '.';
}
Module._cache[filename] = module;
tryModuleLoad(module, filename);
return module.exports;
};
NativeModule.require = function(id) {
if (id === loaderId) {
return loaderExports;
}
const cached = NativeModule.getCached(id);
// 判断是否缓存
if (cached && (cached.loaded || cached.loading)) {
return cached.exports;
}
if (!NativeModule.exists(id)) {
// Model the error off the internal/errors.js model, but
// do not use that module given that it could actually be
// the one causing the error if there's a bug in Node.js
// eslint-disable-next-line no-restricted-syntax
const err = new Error(`No such built-in module: ${id}`);
err.code = 'ERR_UNKNOWN_BUILTIN_MODULE';
err.name = 'Error [ERR_UNKNOWN_BUILTIN_MODULE]';
throw err;
}
moduleLoadList.push(`NativeModule ${id}`);
const nativeModule = new NativeModule(id);
nativeModule.cache();
nativeModule.compile();
return nativeModule.exports;
};
最初的想法是去写一个node C++ addon,但在了解如何去写node addon的过程中,发现想要知根知底的去使用node addon,需要对node架构、V8、libuv、模块加载等要点都有一个了解。所以本文的目的就是梳理如何清楚的使用node addon?
本文将从以下几点进行阐述:
node架构
node的架构相信大家都不陌生,以模块加载为角度架构图如下:
V8 engine是Google开发的javascript引擎,是一个独立运行的虚拟机,node以第三方依赖的形式引入V8(与libuv等依赖放在deps目录下)。除了作为Javascript运行引擎外,V8提供了嵌入API,为编译和执行JS脚本, 访问 C++ 方法和数据结构, 错误处理, 开启安全检查等提供了函数接口,承担着是node中js与C++桥接的重要作用。
libuv是专门为node开发的库,提供跨平台的异步I/O能力。其基于异步的、事件驱动模型,提供一个event-loop,还有基于I/O和其它事件通知的回调函数。
Builtin modules是node提供的C++模块。
Native module是node提供的js模块,其被使用者直接调用,并且有些Native module会借助下层的Builtin module。在native模块中使用builtin模块,利用的是node提供的process.binding方法(后面模块加载会有介绍)。
Addon是一个用C++写的node的动态链接库,使用者可以直接使用
require()
方法进行加载,当然前提是addon已经编译好。Addon主要用来扩展node的底层能力,具体应用可能是计算密集型的模块(C++的运行性能高,可以利用libuv异步和事件循环的能力,同时可以使用多进程、多线程)。V8
V8提供了对外的使用API,可以参考V8嵌入指南。
下面主要对其中主要概念进行简要梳理
Isolate
是一个独立的V8实例,也可以说一个独立虚拟机,其中可以包含一个或多个线程,但同一时间,只有一个线程是执行状态。Context
代表一个执行上下文(执行环境),它使得可以在一个 V8 实例中运行相互隔离且无关的 JavaScript 代码. 你必须为你将要执行的 JavaScript 代码显式的指定一个 context。Context支持嵌套。Handle
是一个指向堆内存的指针,在V8中JavaScript的值和对象也都存放在堆中,Handle提供了一个JS对象在堆内存中的地址的引用。有人会有疑问我们直接操作JS变量指针不可以嘛?由于V8的GC策略,可能会对堆中的JS变量移动其内存位置,Handle的出现可以跟踪相应变量的地址。Handle Scope
是一个Handle的容器,为了解决一个个释放handle过于繁琐,将一些handle接入handle scope中,方便统一管理(释放等)。下图主要是为了大家理解Isolate、Context、Handle Scope、Handle的大小关系,在细节上不够准确。
libuv
libuv是一个跨平台的异步I/O库。其架构图如下:
上图的左侧是网络相关的I/O,使用的都是各个平台比较有效率的多路I/O模型,Linux上的epoll,OSX和BSD类OS上的kqueue,SunOS上的event ports以及Windows上的IOCP机制。
右侧File类型的I/O,基于线程池的方式来实现异步的请求和处理。
具体讲解可参考libuv 教程。
node 模块加载
node模块可分为Native Module、Builtin Module、Constants。
Native Module在下载node源码并编译后,会在
out/Release/obj/gen
目录下node_natives.h。该文件由 js2c.py 生成,其会将node源代码中的lib目录下所有js文件以及src目录下的node.js文件中每一个字符转换成对应的ASCII码,并存放在相应的数组里面。Builtin模块会被main()之前加载到modlist_builtin中,当使用时,从链表中将模块取出即可。在每个builtin模块中,都会通过宏
NODE_BUILTIN_MODULE_CONTEXT_AWARE
预编译阶段将其转为函数_register_ ## modname
,函数会调用node_module_register
方法将其加载进modlist_builtin。tcp_wrap中宏定义如下:模块加载
我们加载node C++ addon时,可以直接使用
process.binding
方法。其实process.binding是node require()的基础,所以后面也介绍了require
的实现process.binding()
process.binding()做了什么呢?
process.binding()主要做了对不同类型的模块做了不同的处理:
require
首先node中require()方法做了什么呢?
代码前面时一些path校验,那么Module._load做了什么呢?
Module._load()
主要做了三件事:我们再深度遍历代码到
NativeModule.require
NativeModule.require
主要做了两件事:nativeModule.compile()
做了什么呢?nativeModule.compile()
就是将源码wrap起来,使用script.runInThisContext
去运行。script.runInThisContext
做了什么呢?Contextify
中的runInThisText如何实现的呢?Node Addon
加载
上面讲解了node的模块加载,那么Node Addon为什么能够正确加载呢?
原因在于每个Node Addon模块入口中,需要
#include <node.h>
,node.h
包含者一些宏定义,其中有:我们又看到了我们熟悉的
node_module_register
方法,我们再写一个addon时调用的NODE_MODULE
方法,实际上就是将该模块编译后的结果加入到modlist_builtin
,我们调用process.binding()
就可以将其加载。NAN
为什么要有NAN呢?
原因在于随着Node.js和V8的版本迭代,其底层API可能会发生变化,我们写的这些原生模块又依赖了变化了的API的话,包就作废了。除非包的维护者去支持新版的API,不过这样依赖,老版Node.js下就又无法编译通过新版的包了。
为了解决这种尴尬的局面,NAN出现了,其在
nan.h
中定义了许多判断宏,会判断当前node版本,我们使用nan.h
宏定义中的方法,编译器会将其展开成不同的结果。Node v8.0之后,官方推出了N-API,它与NAN的区别在于,NAN在适配不同版本时,需要每次重新编译,而N-API将其底层接口抽象,所有版本都适用一套API即可。