Nodejs模块机制
首先在开始之前先简单介绍一下Nodejs里面的模块引入机制。
1.Node.js核心模块
例如fs、net、path这样的模块,代码在nodejs源码(lib目录下)中,通过API来暴露给开发者,这些核心模块都有自己的预留标识,当require()函数传入的标识和核心模块相同时,就会返回核心模块的API。
constfs=require(fs);
2.文件模块
文件模块则分为两种方式:
2.1第三方模块
这些模块以Nodejs依赖包的形式存在。例如一些常见的npm包axios、webpack等等。
Nodejsrequire这一类模块的话是会去找该模块项目下面的package.json文件,如果package.json文件合法,则会解析main字段的那个路径。
当require()函数中传入一个第三方模块,例如axios,那么Nodejs对于寻找这个axios目录的路径的过程是这样的:
去当前文件目录下node_modules中找
没找到就去当前文件父目录下的node_modules中找
还没找到就再往上一层
还没找到就重复3,直到找到符合的模块或者根目录为止
以一个monorepo项目为例子,一般在monorepo中一些包管理工具例如yarnworkspace下会把一些依赖提升到外层的目录中来,那么子项目就是这样去寻找外层的依赖的:
node_modulesaxioshere
packages
package-a
node_modulesaxiosnotfoundhere
index.js-constaxios=require(axios);
2.2项目模块
在项目中执行require()来载入"/"、"./"或者"../"开头的模块就是项目模块。这里根据相对路径或者绝对路径所指向的模块去进行加载。通过加载模块的时候如果不指定后缀名,Nodejs则会通过枚举去尝试后缀名。后缀名依次是.js、.json和.node,其中.node后缀的文件就是C++拓展。
例如目录下有个addon.node文件,我们可以require去加载(nodejs是默认支持的):
constaddon=require(./addon);
什么是NodejsC++拓展
本质
Node.js是基于C++开发的(底层用chromev8做js引擎libuv完成事件循环机制),因此它的所有底层头文件暴露的API也都是适用于C++的。
上一节中提到nodejs模块寻径的时候会默认找.node为后缀名的模块,实际上这是个C++模块的二进制文件,即编译好之后的C++模块,本质上是个动态链接库。例如(Windowsdll/Linuxso/Unixdylib)
在Nodejs在调用原生的C++函数和调用C++拓展函数的本质区别在于前者的代码会直接编译成Node.js可执行文件,而后者则是在动态链接库中。
C++拓展加载方式
通过uv_dlopen这个方法去加载动态链接库文件来完成
C++拓展模块(.node二进制链接库文件)的具体加载过程:
在用户首次执行require时使用uv_dlopen来加载cppaddon的.node链接库文件
链接库内部把模块注册函数赋值给mp
将执行require时传入的module和exports两个对象传入模块注册函数(mp实例)进行导出
相关加载代码参考:
voidDLOpen(constFunctionCallbackInfoValueargs){
Environment*env=Environment::GetCurrent(args);
uv_lib_tlib;
...
LocalObjectmodule=args[0]-ToObject(env-isolate());
node::Utf8Valuefilename(env-isolate(),args[1]);
//使用uv_dlopen函数打开.node动态链接库
constboolis_dlopen_error=uv_dlopen(*filename,lib);
//将加载出来的动态链接库的句柄转移给node_module的实例对象上来
node_module*constmp=modpending;
modpending=nullptr;
...
//最后把一些
mp-nm_dso_handle=lib.handle;
mp-nm_link=modlist_addon;
modlist_addon=mp;
LocalStringexports_string=env-exports_string();
//exports_string其实就是`"exports"`
//这句的意思是`exports=module.exports`
LocalObjectexports=module-Get(exports_string)-ToObject(env-isolate());
//exports和module传给模块注册函数导出出去
if(mp-nm_context_register_func!=nullptr){
mp-nm_context_register_func(exports,module,env-context(),mp-nm_priv);
}elseif(mp-nm_register_func!=nullptr){
mp-nm_register_func(exports,module,mp-nm_priv);
}else{
uv_dlclose(lib);
env-ThrowError("Modulehasnodeclaredentrypoint.");
return;
}
}
为什么要写C++拓展
C++比js高效
相同意思的代码,在js解释器中执行js代码效率比直接执行一个Cpp编译好后的二进制文件要低(后续会用demo验证)
一些已有的C++轮子可以拿来用
例如一些常用的算法市面上只有Cpp实现且代码太过复杂,用JS实现不现实(例如BlingHashes字符串hash摘要算法、OpenSDK)
一些系统底层API或者V8API没法通过js调用,可以封装一个cppaddon出来(例如:缓解Node.js因生成heapsnapshot导致进程退出的一种办法)
缺点:
开发维护成本比较高,需要掌握一门native语言
增加了nativeaddon的编译流程以及拓展发布流程
发展历史
这里介绍几种开发Nodejs拓展的方式:
原始方式
这种方式比较暴力,直接使用nodejs提供的原生模块来开发头文件,例如在C++代码中直接使用Nodejs相关的各种API以及V8的各种API。需要开发者对nodejs以及v8文档比较熟悉。而且随着相关API迭代导致无法跨版本去进行使用。
NAN
NativeAbstractionsforNode.js,即Node.js原生模块抽象接口集
本质上是一堆宏判断,在上层针对libuv和v8的API做了一些兼容性的处理,对用户侧而言是比较稳定的API使用,缺点是不符合ABI(二进制应用接口)稳定,对于不同版本的Node.js每次即使每次重新安装了node_modules之后还需要对C++代码进行重新编译以适应不同版本的Nodejs,即代码只需要编写一次,但需要使用者去到处编译。
N-API
N-API相比于NAN则是将Nodejs中底层所有的数据结构都黑盒处理了,抽象成N-API中的接口。
不同版本的Node.js去使用这些接口,都是稳定的、ABI化的。使得在不同的Node.js版本下,代码只需要编译一次就可以直接使用,不需要去重新进行编译。在Nodev8.x时发布。
以C语言风格提供稳定的ABI接口
消除Node.js版本差异
消除js引擎差异(例如Chromev8、MicrosoftChakraCore等)
Node-Addon-API
目前Node.js社区推崇的写Cppaddon的方式,实际上是基于N-API的一层C++封装(本质上还是N-API)。
支持的最早版本是Nodev10.x(在v10.x之后逐步稳定)。
API更简单
文档良心,编写和测试都更方便
官方维护
今天介绍的也是这种方式来编写C++拓展。
准备工作
安装node-gyp
npminode-gyp-g
node-gyp这里是个nodejs官方维护的C++的构建工具,几乎所有的NodejsC++拓展都是由它来构建。基于GYP(generateyourproject,谷歌的一个构建工具)进行工作,简单来说,可以想象成面向C++的Webpack。
作用是将C++文件编译成二进制文件(即前面提到的后缀名为.node的文件)。
node-gyp附带的一些依赖环境(参考官方文档,以macos为例子)
Python(一般unix系统都会自带)
Xcode
同时node-gyp也需要在项目下有个binding.gyp的文件去进行配置,写法上和json类似,不过可以在里面写注释。
例如:
{
"targets":[
{
#编译之后的拓展文件名称,例如这里就是addon.node
"target_name":"addon",
#待编译的原cpp文件
"sources":["src/addon.cpp"]
}
]
}
一些demo
这一节主要是通过一些简单的demo来入门C++Addon的开发:
HelloWorld
在做好一些准备工作之后,我们可以先来利用node-addon-api开发一个简单的helloworld
初始化
mkdirhello-worldcdhello-world
npminit-y
#安装node-addon-api依赖
npminode-addon-api
#新建一个cpp文件js文件
touchaddon.cppindex.js
配置binding.gyp
{
"targets":[
{
#编译出来的xxx.node文件名称,这里是addon.node
"target_name":"addon",
#被编译的cpp源文件
"sources":[
"addon.cpp"
],
#为了简便,忽略掉编译过程中的一些报错
"cflags!":["-fno-exceptions"],
"cflags_cc!":["-fno-exceptions"],
#cpp文件调用n-api的头文件的时候能找到对应的目录
#增加一个头文件搜索路径
"include_dirs":[
"!
(node-p"require(node-addon-api).include")"],
#添加一个预编译宏,避免编译的时候并行抛错
defines:[NAPI_DISABLE_CPP_EXCEPTIONS],
}
]
}
写原生的cpp拓展
这里贴两份代码,为了便于去做个区分比较:
原生NodeCppAddon版本:
//引用node.js中的node.h头文件
#includenode.h
namespacedemo{
usingv8::FunctionCallbackInfo;
usingv8::Isolate;
usingv8::Local;
usingv8::Object;
usingv8::String;
usingv8::Value;
voidMethod(constFunctionCallbackInfoValueargs){
//通过v8中的隔离实例(v8的引擎实例,有各种独立的状态,包括推管理、垃圾回收等)
//存取Nodejs环境的实例
Isolate*isolate=args.GetIsolate();
//返回一个v8的string类型,值为"helloworld"
args.GetReturnValue().Set(String::NewFromUtf8(ioslate,"helloworld"));
}
voidinit(LocalObjectexports){
//nodejs内部宏,用于导出一个function
//这里类似于exports={"hello":Method}
NODE_SET_METHOD(exports,"hello",Method);
}
//来自nodejs内部的一个宏
//用于注册addon的回调函数
NODE_MODULE(addon,init);
}
Node-addon-api版本:
//引用node-addon-api的头文件
#includenapi.h
//Napi这个实际上封装的是v8里面的一些数据结构,搭建了一个从JS到V8的桥梁
//定义一个返回值为Napi::String的函数
//CallbackInfo是个回调函数类型info里面存的是JS调用这个函数时的一些信息
Napi::StringMethod(constNapi::CallbackInfoinfo){
//env是个环境变量,提供一些执行上下文的环境
Napi::Envenv=info.Env();
//返回一个构造好的Napi::String类型的值
//New是个静态方法,一般第一个参数是当前执行环境的上下变量,第二个是对应的值
//其他参数不做过多介绍
returnNapi::String::New(env,"helloworld~");
}
//导出注册函数
//这里其实等同于exports={hello:Method}
Napi::ObjectInit(Napi::Envenv,Napi::Objectexports){
exports.Set(
Napi::String::New(env,"hello"),
Napi::Function::New(env,Method)
);
returnexports;
}
//node-addon-api中用于注册函数的宏
//hello为key,可以是任意变量
//Init则会注册的函数
NODE_API_MODULE(hello,Init);
这里代码里面的Napi::命名空间里面的一些类型实际上是对v8原生的一些数据结构做了包装,调用的时候更简单。
这里的Napi本质上就是C++和JS之间的一座相互沟通的桥梁。
这里拆分讲解一下这些函数的作用,Method函数是我们的一个执行函数,执行该函数会返回一个"helloworld"的字符串值。
CallBackInfo对应v8里面的FunctionCallbackInfo类型(里面有一些函数回调信息,存在info这个地址里面),里面包含了JS函数调用这个方法的时候需要的一些信息。
在js代码中调用cppaddon
我们通过对上面的cpp进行进行node-gyp的编译,得到一个build的目录里面存放的是编译产物,里面会有编译出来的二进制动态链接文件(后缀名为.node):
$node-gypconfigurebuild
#或者为了更简便一点会直接使用node-gyprebuild,这个命令包含了清除缓存并重新打包的功能
$node-gyorebuild
编译之后我们直接在js代码中引入即可:
//hello-world/index.js
const{hello}=require(./build/Release/addon);
console.log(hello());
A+B
在上一节我们讲到了Napi::CallbackInfoinfoinfo中会存JS调用该函数时的一些上下文信息,因此我们在js中给cpp函数传参数也可以在info中获取到,于是可以写出下面一个简单的a+b的cppaddondemo:
#includenapi.h
//这里为了做演示,把Napi直接通过usingnamespace声明了
//只要该文件不被其他的cpp文件引用就不会出现namespace污染这里主要为了简洁
usingnamespaceNapi;
//因为这里可能会遇到抛error的情况,因此返回值类型设置为Value
//Value包含了Napi里面的所有数据结构
ValueAdd(constCallBackInfoinfo){
Envenv=info.Env();
if(info.Length()2){
//异常处理相关的API可以参考
//不过这里可以看到cpp里面抛异常代码很麻烦...建议这里可以在js端就处理好
TypeError::New(env,"Numberofargwrong").ThrowAsJavaScriptException();
returnenv.Nulll();
}
doublea=info[0].AsNumber().Doublevalue();
doubleb=info[1].AsNumber().DoubleValue();
Numbernum=Number::new(env,a+b);
returnnum;
}
//exports={add:Add};
ObjectInit(Envenv,Objectexports){
exports.Set(String::New(env,"add"),Function::new(env,Add));
}
NODE_API_MODULE(addon,Init);
Js调用只需要:
const{add}=require(./build/Release/addon);
//outputis5.2
console.log(add(2,3.2));
callback
回调函数也是一样,通过info这个也可以拿到,再贴个cppaddon的demo:
//addon.cpp
#includenapi.h
//这一节用namespace包裹一下,提前声明一些数据结构
//省得调用的时候一直Napi::xxx...
namespaceCallBackDemo{
usingNapi::Value;
usingNapi::CallbackInfo;
usingNapi::Env;
usingNapi::TypeError;
usingNapi::Number;
usingNapi::Object;
usingNapi::String;
usingNapi::Function;
voidRunCallBack(constCallbackInfoinfo){
Envenv=info.Env();
Functioncb=info[0].AsFunction();
cb.Call(env.Global(),{String::New(env,"helloworld")});
}
ObjectInit(Envenv,Objectexports){
returnFunction::New(env,RunCallback);
}
NODE_API_MODULE(addon,Init);
}
实战demo
上面简单讲了一些nodenativeaddon的简单API使用,算是做了个简单的入门教学,下面选了个简单的实际demo来看一下node-addon-api在具体项目中起到的作用:
案例展开讲一下,封装了v8的API用于debug
参考案例:缓解Node.js因生成heapsnapshot导致进程退出的一种办法
性能对比
可以通过一个简单的Demo去做一下对比:
quickSort(O(nlogn))
我们可以手写个快排分别在JS或者CPP两边去run一下来对比性能:
首先我们的cppaddon代码可以这样写:
#includenapi.h
#includeiostream
#includealgorithm
//快排时间复杂度O(nlogn)空间复杂度O(1)
voidquickSort(inta[],intl,intr){
if(l=r)return;
intx=a[(l+r)1],i=l-1,j=r+1;
while(ij){
while(a[++i]x);
while(a[--j]x);
if(ij){
std::swap(a,a[j]);
}
}
quickSort(a,l,j);
quickSort(a,j+1,r);
}
Napi::ValueMain(constNapi::CallbackInfoinfo){
Napi::Envenv=info.Env();
Napi::Arrayarr=info[0].AsNapi::Array();
intlen=arr.Length();
//存返回值
Napi::Arrayres=Napi::Array::New(env,len);
int*arr2=newint[len];
//转化一下数据结构
for(inti=0;ilen;i++){
Napi::Valuevalue=arr;
arr2=value.ToNumber().Int64Value();
}
quickSort(arr2,0,len-1);
//for(inti=0;ilen;i++){
//std::coutarr2"";
//}
//std::coutstd::endl;
//转回JS的数据结构
for(inti=0;ilen;i++){
res=Napi::Number::New(env,arr2);
}
returnres;
}
Napi::ObjectInit(Napi::Envenv,Napi::Objectexports){
exports.Set(
Napi::String::New(env,"quicksortCpp"),
Napi::Function::New(env,Main)
);
returnexports;
}
NODE_API_MODULE(addon,Init);
JS侧的代码可以这样写:
//这里使用bindings这个库,他会帮我们自动去寻找addon.node对应目录
//不需要再去指定对应的build目录了
const{quicksortCpp}=require(bindings)(addon.node);
//构造一个函数出来
constarr=Array.from(newArray(1e3),()=Math.random()*1e4
0);
letarr1=JSON.parse(JSON.stringify(arr));
letarr2=JSON.parse(JSON.stringify(arr));
console.time(JS);
constsolve=(arr)={
letn=arr.length;
constquickSortJS=(arr,l,r)={
if(l=r){
return;
}
letx=arr[Math.floor((l+r)1)],i=l-1,j=r+1;
while(ij){
while(arr[++i]x);
while(arr[--j]x);
if(ij){
[arr,arr[j]]=[arr[j],arr];
}
}
quickSortJS(arr,l,j);
quickSortJS(arr,j+1,r);
}
quickSortJS(arr,0,n-1);
}
solve(arr2);
console.timeEnd(JS);
console.time(C++);
consta=quicksortCpp(arr1);
console.timeEnd(C++);
这里两侧代码基本上从实现上来说都是一模一样的,在实际运行中,通过去修改数组的长度对比两者的效率,我们可以得到如下的数据:
那么我们可以看到在数组长度相对而言比较低的时候,C++Addon的快排效率是要完爆JS的,但随着数组长度的增长,C++就呈现一种被完爆的趋势。
导致这种情况的原因是因为V8的数据结构与C++里面原生的数据结构转换所带来的消耗:
1e5的数据规模下,实际上cpp的quickSort算法只跑了大概6.9ms,而算上数据转换的时间,一共就跑了28.9ms......
随着数据规模的增大这种转换带来的开销就越来越大,因此在这种时候如果使用C++的话,可能会得不偿失。
综上来看,有时候C++写出来的包确实会在性能上稍微高于Nodejs的JS代码,但如果高出来的这部分性能还比不过Nodejs打开并且执行C++Addon所消耗掉的I/O时间或者在v8数据结构与C++数据结构之前进行转换的所消耗的时间(例如上面的Case),这个时候用C++可能就得不偿失了。
不过一般情况下,针对并非并行计算密集型代码来说,C++效率还是会好于Nodejs的。
总结
随着N_API体系的发展以及nodejs开发团队的不断迭代更新,未来开发nativeaddon的成本也会越来越低,在一些特定的场景里面(例如需要用到一些v8的API场景或者electron+openCV场景),nodejsaddon可能会变得极其重要,未来使用场景也会不断的提高。
文章来源于程序员成长指北