程序员开发实例大全宝库

网站首页 > 编程文章 正文

最强 nodejs 下 C++绑定方案介绍(nodejs c++扩展)

zazugpt 2024-08-29 02:09:36 编程文章 20 ℃ 0 评论

作者:john

最近基于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;
        return  StaticField + 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('path/to/hello_world');
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('path/to/hello_world')
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 'hello_world'

let hello_world = load<typeof HelloWorldModlue>('path/to/hello_world');

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[0]));
    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 倍。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表