liaodan 发表于 2023-8-15 16:26

最强nodejs下C++绑定方案介绍

比来基于puerts做了个nodejs addon,能让nodejs便利的调用c++的库。拿一个斗劲知名的同类方案v8pp做对比:
不异点

[*]都是基于C++模板技术提供了声明式绑定API。
[*]都能撑持nodejs和其它v8环境
先列几个分歧点

[*]v8pp提供了包罗v8的初始化,设置,c++/js交互等封装,而puerts仅仅专注于c++/js交互一项。
[*]声明要绑定c++ api后,puerts能生成这些c++ api的TypeScript声明(.d.ts文件),这似乎是初创
[*]puerts对c++特性撑持丰硕些,比如撑持函数重载
[*]puerts的性能更强悍:简单C++静态方式比v8pp快50%~90%,简单C++成员方式比v8pp快4~5倍,在此基础上如果开启v8 fast api call特性还能再提升一倍。
语言无关的原生addon尺度

puerts不仅仅想做更好的v8/C++绑定方案,还通过“跨语言交互”抽象出来的一套api,定义了一个语言无关的原生addon尺度。该尺度的addon无需从头编译可以在实现了该尺度的游戏引擎(UE /Unity),nodejs、lua等环境加载使用。可以下载这个工程体验一下:puerts_addon_demos,也等候该尺度的更多语言撑持。
反不雅观nodejs原生addon,要在同出一源的electron加载也要用electron的东西从头构建:using-native-node-modules
HelloWorld

被调用的C++代码

class HelloWorld
{
public:
    HelloWorld(int p) {
      Field = p;
    }

    void Foo(std::function<bool(int, int)> cmp) {
      bool ret = cmp(Field, StaticField);
      std::cout << ”Foo, Field: ” << Field << ”, StaticField: ” << StaticField << ”, compare result:” << ret << std::endl;
    }
   
    static int Bar(std::string str) {
      std::cout << ”Bar, str:” << str << std::endl;
      returnStaticField + 1;
    }
   
    int Field;
   
    static int StaticField;
};

int HelloWorld::StaticField = 0;声明式导出到addon

UsingCppType(HelloWorld);

void Init() {
    puerts::DefineClass<HelloWorld>()
      .Constructor<int>()
      .Method(”Foo”, MakeFunction(&HelloWorld::Foo))
      .Function(”Bar”, MakeFunction(&HelloWorld::Bar))
      .Property(”Field”, MakeProperty(&HelloWorld::Field))
      .Variable(”StaticField”, MakeVariable(&HelloWorld::StaticField))
      .Register();
}

//hello_world is module name, will use in js later.
PESAPI_MODULE(hello_world, Init)js调用该addon

const puerts = require(”puerts”);

let hello_world = puerts.load(&#39;path/to/hello_world&#39;);
const HelloWorld = hello_world.HelloWorld;

const obj = new HelloWorld(101);
obj.Foo((x, y) => x > y);

HelloWorld.Bar(”hello”);

HelloWorld.StaticField = 999;
obj.Field = 888;
obj.Foo((x, y) => x > y);lua调用该addon

local puerts = require ”puerts”

local hello_world = puerts.load(&#39;path/to/hello_world&#39;)
local HelloWorld = hello_world.HelloWorld

local obj = HelloWorld(101)
obj:Foo(function(x, y)
    return x > y
end)

HelloWorld.Bar(”hello”)

HelloWorld.StaticField = 999
obj.Field = 888
obj:Foo(function(x, y)
    return x > y
end)代码解释


[*]被调用的代码包含了斗劲常用的几种情况:构造函数、成员变量、成员函数、静态变量、静态函数,也包含了斗劲高级点的std::function,这种变量在js/lua可以直接传函数
[*]绑定声明部门可以理解为基于c++构造的一个dsl,按照文档学习怎么使用即可。
TypeScript调用代码

编译好addon后,可以用puerts提供的东西生成声明文件。
先安装puerts东西
npm install -g puerts将声明文件生成到typing目录
puerts gen_dts path\to\your\addon -t typing打开声明文件typing\module_name\index.d.ts,可以看到针对声明的C++类的ts声明:
declare module ”hello_world” {
    import {$Ref, $Nullable, cstring} from ”puerts”

    class HelloWorld {
      constructor(p0: number);
      Field: number;
      static StaticField: number;
      static Bar(p0: string) :number;
      Foo(p0: (p0:number, p1:number) => boolean) :void;
    }
}把typing目录加到ts工程的tsconfig.json的compilerOptions/typeRoots即可享受代码提示、查抄之乐。
上面js调用代码的ts版本如下:
import {load} from ”puerts”;
import * as HelloWorldModlue from &#39;hello_world&#39;

let hello_world = load<typeof HelloWorldModlue>(&#39;path/to/hello_world&#39;);

const HelloWorld = hello_world.HelloWorld;

const obj = new HelloWorld(101);

obj.Foo((x, y) => x > y);

HelloWorld.Bar(”hello”);

HelloWorld.StaticField = 999;
obj.Field = 888;

obj.Foo((x, y) => x > y);通过HelloWorld例子我们初步了解了puerts for node的初步使用,想进一步使用请看文档和例子。
接下来我们讲下设计、实现相关的东东。篇幅的关系只讲两个主题:

[*]语言无关addon设计
[*]性能
语言无关addon设计

笔者从xLua到puerts,使用过脚本引擎/虚拟机有:lua、v8、jscore、quickjs、wasm3等等,感觉脚本引擎/虚拟机和宿主交互来来去去就那么回事,于是萌生了一个“做一套跨虚拟机的FFI抽象”的想法。
C还是C++?

这些引擎有的提供的是C接口,有的提供的是C++接口,这抽象接口用哪个语言好?
很显然应该用C,它兼容性更好,有可能有些环境只能用C,而且一个动态库和可执行法式之间的接口如果用到了C++的类型(std::string, std::shared_ptr等),两边使用的C++版本纷歧样很容易导致崩溃,如果这些不能用,为何不直接用C?
回调签名

虚拟机调用宿主的一个函数,其实是调用宿主注册的一个特定接口的回调,回调中读取参数调用实际函数后,把成果返回给虚拟机。每个虚拟机对这回调的定义基本都纷歧样,也很难评个高下。最终定了如下回调签名。
typedef struct pesapi_callback_info__* pesapi_callback_info; typedef void (*pesapi_callback)(pesapi_callback_info info);主要是基于两点考虑:

[*]这签名和puerts主打撑持的v8是兼容的,可以直接作为v8的回调,减少v8适配的性能损掉
[*]单参数的接口,其它多参数回调只要栈上构造一个栈布局体装一下即可,性能损掉也不大,以quickjs为例,它的签名是这样的
typedef JSValue JSCFunctionData(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv, int magic, JSValue *func_data);虽然分歧很大:有很多参数,而且有返回值。我们可以这么适配一下
struct pesapi_callback_info__ {
    JSContext *ctx;
    JSValueConst this_val;
    int argc;
    JSValueConst *argv;
    int magic;
    JSValue *func_data;
    JSValue result;
};

[](JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv, int magic, JSValue *func_data) {
    pesapi_callback_info__ callbackInfo;
    callbackInfo.ctx = ctx;
    callbackInfo.this_val = this_val;
    callbackInfo.argc = argc;
    callbackInfo.argv = argv;
    callbackInfo.magic = magic;
    callbackInfo.func_data = func_data;
   
    pesapi_callback callback = (pesapi_callback)(JS_VALUE_GET_PTR(func_data));
    callback(callbackInfo);
   
    return callbackInfo.result;
}其它接口


[*]基本数据类型转换
[*]对象生命周期打点:由虚拟机主动new的原生对象,没引用(gc)时应该释放掉,原生持有的一些虚拟机gc对象,比如回调函数,应该保持引用
[*]面向对象信息描述:有哪些类,类的函数和成员信息,这些类间的担任关系
addon初始化

翻到前面的HelloWorld例子,有这么一行:
PESAPI_MODULE(hello_world, Init)PESAPI_MODULE是一个宏,这将会在addon动态库中定义几个入口,此中最重要是一个addon初始化函数,实现了“跨虚拟机的抽象接口”的法式加载addon后会主动调用,传入前面说的那一系列接口实现函数的指针。
pesapi

前面说的“跨虚拟机的抽象接口”叫pesapi,是Portable Embedded Scripting API的缩写,整套API的描述只有一个200多行的简纯挚c头文件。
纯用这套api去编写addon也是可以的,这种方式仅仅依赖一个头文件和一个c文件,不依赖任何库。这是一个例子:tiny_c
可以看到斗劲繁琐,前面的HelloWorld使用的声明式绑定方式简单很多,也仅仅多依赖些头文件和C++14,不需要依赖node或者v8。
性能

我们对一个C++类进行声明式绑定,默认编译后生成的是对pesapi的调用,好处是这种addon不依赖于任何的脚本引擎/虚拟机,以二进制形式发布,可以在任意撑持pesapi的环境使用,但它也犯错误谬误:脚本引擎/虚拟机的API先封装成pesapi再被addon调用,性能会有一些损掉。
具体可以看这个对比测试工程:puerts_node_performance,主页有多个平台的测试成果,此中puerts_perf即为模板绑定+pesapi的测试,作为对比的v8api_perf则是手工调用v8 api的测试,还是有不小的性能损掉的。
napi_perf是手工调用nodejs的napi实现的addon,napi和pesapi类似,都是封装成c接口给addon调用(ps:pesapi的设计也有参考napi),它的测试数据和puerts模板绑定+pesapi是差不多的,可见性能损掉更多的源于c接口的封装。
v8 API直调优化

代码不需要改削,只需编译时插手PES_EXTENSION_WITH_V8_API宏即可获得相当大的性能提升,顾名思义加了这个宏,模板将改为调用v8 api而不是pesapi,puerts_v8_perf便是这种方式编译的addon,性能斗劲接近v8api_perf,远比同样是模板+v8 api的v8pp性能要好(v8pp_pref)。
当然,也有代价的,这导致v8 api的依赖,addon编译需要插手v8,而且这种addon也不能在其它虚拟机上跑。
v8 fast api call撑持

v8有一个甚少人知道和使用的特性:fast api call。
前面也说过原生调用是通过特定形式的回调来实现,每一个参数措置都至少有一次函数调用,而fast api call是按照函数签名信息,用TurboFan编译器运行时jit生成代码完成虚拟机内部Calling Convention到原生Calling Convention的转换,可能一个参数只需要简单的一个指令。
这特性也有一些坑:

[*]该特性并不是所有类型都撑持,对于不撑持的类型,含不撑持类型的函数你用它提供给的模板库去收集签名信息时会报编译错误
[*]成员方式并不直接撑持
[*]碰到过一个神奇的问题:静态方式甚至比不用该特性还慢,进一步摸索发现静态方式先用变量持有再调用就有效果
const Add = Calc.Add
Add(1, 2) // fast
Calc.Add(1, 2) // very slow网络甚少fast api call的资料,只能结合源码去摸索去解决这些问题,所幸都搞定了。
之前puerts_v8_perf不需要改削代码,只需:

[*]编译时添加WITH_V8_FAST_CALL宏
[*]如果是用node-gyp编译,会报找不到v8-fast-api-calls.h,需要自行下载合适版本的该文件,puerts_node_performance主页有介绍方式
[*]启动node要加--turbo-fast-api-calls参数
即可享用这巨大的性能提升。实测puerts_fastcall_perf比v8api_perf还要快1~2倍。

qaz8888 发表于 2023-8-15 16:27

跨语言调用是真的别扭,无论哪种

twinsbbs 发表于 2023-8-15 16:27

多种脚本里一套通用,好评!

zxhk0794 发表于 2023-8-15 16:27

[爱][爱]
页: [1]
查看完整版本: 最强nodejs下C++绑定方案介绍