Web开发

首页 » 常识 » 常识 » web前端开发Nodejs的C拓展开
TUhjnbcbe - 2024/5/10 16:15:00

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可能会变得极其重要,未来使用场景也会不断的提高。

文章来源于程序员成长指北

1