javascript & c++ - v8pp 实现解析

2023-10-16 15:28:58 浏览数 (2)

导语

v8 和 node.js 的流行让 js/ts 相关的脚本开发也慢慢走入像游戏业务开发这些领域, 本文主要从 v8pp 的实现出发, 让读者熟悉极大提高 v8 易用性, 提供诸如像c 类导出到javascript等功能的 v8pp 的同时, 也对怎么在c 中嵌入式的使用 v8 虚拟机有个基础的了解. 依赖v8本身完备的实现和提供的基础对象, c & v8 的跨语言中间件的实现复杂度大幅度下降, 除了因为 js 本身使用 prototype 设计带来的一定程度的理解成本和机制转换成本外, 其他部分都会比像 python 等的跨语言中间件来得简单, 从代码量上来说, v8pp 的代码量也远少于笔者之前剖析过的 pybind11. 从某种层面来说, 基于 v8 的跨语言中间件, v8本身提供的机制解决了绝大部分问题, 剩下的一小部分问题, 是需要 v8pp 本身来解决的.


1. 游戏研发领域的 js 生态简介

笔者本身是从事游戏研发相关工作的, 所以这里我们直接以游戏研发领域的 js 生态为例, 来具体看一下国内游戏圈对 js 的使用情况.

提到游戏圈的 js 生态, 大家先想到的肯定是公司内外知名度都非常高的 PuerTS, PuerTS 很好的解决了 Unreal 引擎中使用 js/ts 作为脚本进行游戏开发的问题, 得益于 js/ts 本身丰富的特性, 和 v8 的强大, 推出后不久就受到了同行的好评并被诸多 UE 项目所使用, 如大家熟知的库洛的<<鸣潮>>, 就是使用 PuerTS 作为中间件, 业务逻辑使用 typescript 进行开发.

往更早的时间点回溯, 大家会发现, 主要针对各类运营活动的 PixUI 方案, 使用的就是 js 脚本. 因为 PixUI 更多是作为一种嵌入式的方案使用, 所以他们选择的虚拟机是更轻量, 更节约内存的 QuickJS 方案, 与 PixUI 本身支持的 H5 布局一起, 一定程度上做到了与常规的 H5 前端开发比较一致的体验, 公司内绝大多数项目基本都使用 PixUI 来开发自己的运营活动. 从这点上来说这套方案还是相当成功的.

以游戏为例, 随着大家越来越多的思考性能之外的维度, 综合考虑脚本选择的性价比, 一度呼声很高, 但特性匮乏的 lua 逐渐败下阵来. 网易在研的游戏大多选择他们一直坚持的 Python, 而公司内选择 js/ts 作为脚本的项目也在逐渐增加. 这中间还有一个事实就是, 对于游戏来说, 使用脚本作为密集计算的场景非常有限, 很多时候我们面临的其实都是执行上下文在 c /脚本中来回切换的执行场景, 网易就有不少的先例, 从 Python 切换到大家公认性能更高的 Lua, 结果性能基本没有提升, 然后还落入了一个特性匮乏的尴尬局面. 最近可考的事例, 是<<燕云十六声>>的后台服务器, 进行了 Python -> lua 的迁移, 完成迁移后基本没有看到性能的提升. 种种因素, 大家逐渐选择特性更丰富, 工程组织更友好的脚本, 来代替原来更简单的 Lua, 也就不难理解了.

本文的重点其实还是关注 v8, 以及 v8 如何与 c 进行交互这个问题, 所以我们将选择代码比较简洁, 充分利用 c 新特性的 v8pp 作为讲述的重点.


2. v8pp 的使用范例

我们先通过一些测试代码来近距离的接触 v8pp.


2.1 一个简单的导出示例

这里我们以一个 3D 中常用的向量 Vector3 为例, Vector3 的声明如下:

代码语言:javascript复制
namespace gbf {
namespace math {

class Vector3 {
 public:
  double x;
  double y;
  double z;

  Vector3() : x(0.0), y(0.0), z(0.0) {}
  Vector3(double _x, double _y, double _z) : x(_x), y(_y), z(_z) {}

  ~Vector3() {}

  // Returns the length (magnitude) of the vector.
  double Length() const;

  /// Extract the primary (dominant) axis from this direction vector
  const Vector3& PrimaryAxis() const;
};

}  // namespace math
}  // namespace gbf

我们利用 v8pp 可以很方便的将 Vector3 导出到 v8 指定的模块 math3d 中:

代码语言:javascript复制
v8pp::context context;
  v8::Isolate* isolate = context.isolate();
  v8::HandleScope scope(isolate);

  v8pp::class_<gbf::math::Vector3> math_vector3_class(isolate);
  math_vector3_class
    .ctor<double, double, double>()
    .function("Length", &gbf::math::Vector3::Length)
    .function("PrimaryAxis", &gbf::math::Vector3::PrimaryAxis)
    .var("x", &gbf::math::Vector3::x)
    .var("y", &gbf::math::Vector3::y)
    .var("z", &gbf::math::Vector3::z)
    .auto_wrap_objects(true)
    ;

  v8pp::module math_module(isolate);
  math_module.class_("Vector3", math_vector3_class);

  context.module("math3d", math_module);

这里我们直接尝试向 v8 注册了一个 math3d 模块, 并在这个模块中导出了一个 Vector3 的类(三维矢量的简单实现), 并导出了Vector3的属性和一些成员方法.

如果正确构建了测试环境, 以下代码:

代码语言:javascript复制
let obj = new math3d.Vector3(1.0, 1.0, 1.0);
print("vec:", obj.x, obj.y, obj.z);
print(`vec len:${obj.Length()}`);

let axis_obj = obj.PrimaryAxis();
print(`primary axis:${axis_obj.x} ${axis_obj.y} ${axis_obj.z}`);

我们就能如上所示在 javascript 中正确的访问到 math3d 模块下的 Vector3 类了.

v8本身是利用 v8::Context 对执行环境进行隔离的, 所以我们也能在 C 中结合 v8ppv8 API 快速的构建测试环境, 这里直接给出一个可能的实现:

代码语言:javascript复制
v8pp::context context;
v8::Isolate* isolate = context.isolate();
v8::HandleScope scope(isolate);

//Some register code here~~
//...

// Now you can create and use c   objects in JavaScript
context.run_script(R"(
//Some javascript here
//...
    )");

得益于强大的 v8 本身以及 v8pp 的封装, 整个实现非常简洁.

[!note] 细心的读者可能留意到了注册 c 类的时候有一个 auto_wrap_objects(true) 的调用, 这部分是 v8pp 比较特殊的地方, 默认情况下不会自动帮你进行 c object -> javascript object的转换, 只有打开了auto wrap objects的情况, 才会自动帮你进行相关的处理, 比如上面的 obj.PrimaryAxis() 调用, 返回了一个新的 Vector3 对象, 这种情况如果不打开auto wrap objects, 我们将会得到一个 failed to wrap C objectstd::runtime_error 异常.


2.2 本节小结

本节中我们通过一个简单的示例了解了 v8pp 的基本使用方法, 从示例中我们也能看到, v8pp 提供了一种简洁的语法来定义模块和在模块中注册类和函数。利用 v8pp 封装的 v8pp::context, v8pp::module, v8pp::class_, 我们可以很方便的将 c 的类导出到 javascript 中进行使用.

那么 v8pp 是如何实现 C <-> javascript 交互的呢, 后面的章节中我们将逐步介绍实现相关机制的基础设施, 逐步分析 v8pp 的核心实现机制. 得益于强大的 v8 本身, 我们也会发现对比之前介绍过的 pybind11 实现, v8pp 的实现看起来会简约非常多.


3. v8ppv8 基础对象的支持

由于 v8 本身的特性非常强大, v8pp 的实现依托于 v8 本身提供的 API 能够解决大部分的问题, v8pp 中的核心对象比较少, 更多的代码是尝试通过泛型来为 c <-> v8 交互提供简洁易用的使用体验.


3.1 v8pp 中的主要对象

v8pp 中主要的对象如下图所示, 主要由一组由层级关系的对象, 和一些外围的工具类和函数组成:

其中的 v8pp::context 用于完成对 v8::Context 的封装, 在单个 v8::Isolate虚拟机实例上, 我们可以利用 v8pp::context 来隔离运行环境, 我们注册的c 类等信息也是直接关联在一个 v8pp::context上的. v8pp::context 上可以包含多个v8pp::module, 而每个v8pp::module又可以包含多个注册在其上的v8pp::class_, 或者多个 module 级别的函数和属性. v8pp 通过这些级联的对象, 完成了 c 对象层级到 v8 映射最外围的工作, 提供了在 v8 中层级化的方式描述c 中的命名空间以及类关系的基础支持. 而 Utilities 部分提供了跨语言的基础设施convert<>, 用于在 c <-> v8 之间方便的转换对应类型的值. get_options()set_options() 则提供了在一个 v8::Local<v8::Object> 对象上方便的以 c 类型直接获取和设置对应值的接口.


3.2 v8pp::context 简述

v8pp::context 完成对 v8::Context 的封装, 为我们导出c 的各类信息提供一个容器基础, 我们需要关注的内容也比较少:

代码语言:javascript复制
class v8pp::context
{
 public:
  context();
  ~context();

  /// V8 isolate associated with this context
  v8::Isolate* isolate() const { return isolate_; }
  /// V8 context implementation
  v8::Local<v8::Context> impl() const
  /// Global object in this context
  v8::Local<v8::Object> global() { return impl()->Global(); }
  /// Library search path
  std::string const& lib_path() const;
  /// Set new library search path
  void set_lib_path(std::string const& lib_path);
  /// Run script file, returns script result
    /// or empty handle on failure, use v8::TryCatch around it to find out why.
    /// Must be invoked in a v8::HandleScope
  v8::Local<v8::Value> run_file(std::string const& filename);
  /// The same as run_file but uses string as the script source
  v8::Local<v8::Value> run_script(std::string_view source, std::string_view filename = "");
  /// Set module to the context global object
  context& module(std::string_view name, v8pp::module& m);
  // ... 
 private:
  v8::Isolate* isolate_;
  v8::Global<v8::Context> impl_;
  // ...
};

最重要的成员是 isolate_impl_, 这两者都是直接使用v8 本身支持的对象类型来表达.

我们一般通过 context::module() 来为 context 注册一个模块, 当然, 我们也可以通过 context::run_script() 方便的在 context 约束的上下文中执行指定的脚本代码.


3.3 v8pp::module 简述

v8pp::module 一般用作 v8pp::class_ 的容器, 我们通过一个具体的名称可以将一个包含若干类声明的模块绑定到v8pp::context上, 再配合v8pp::module本身支持的submodule(), 我们可以很方便的在 v8 中还原 c 中基于命名空间的类结构. 如示例代码中我们将 gbf::math 命名空间下的 Vector3 直接导出到了 math3d 模块中, 你可以选择 1:1 的方式还原 c 中的类结构, 也可以按照示例中一样, 通过 v8pp::module, 我们可以很简单的对类的层级结构进行管理. v8pp::module相关的代码声明如下:

代码语言:javascript复制
class v8pp::module{
 public:
  module(v8::Isolate* isolate);
  ~module();

  /// Set submodule in the module with specified name
  module& submodule(std::string_view name, v8pp::module& m);
  /// Set wrapped C   class in the module with specified name
  template<typename T, typename Traits>
  module& class_(std::string_view name, v8pp::class_<T, Traits>& cl);
  //...
 private:
  v8::Isolate* isolate_;
  v8::Local<v8::ObjectTemplate> obj_;
  //...
};

注意 v8pp::module 中保存的实际上是一个v8::ObjectTemplate, 所以真正向v8pp::context 注册的时候, 我们其实还需要创建 v8::ObjectTemplate 对应的对象, 相关的注册代码如下:

代码语言:javascript复制
context& context::module(std::string_view name, v8pp::module& m)
{
    return value(name, m.new_instance());
}

最终被绑定到 v8pp::context 上的, 其实是 v8pp::module 的实例, 而v8pp::module 本身是作为 v8::ObjectTemplate 存在的, 这里也是比较特殊的.


3.4 v8pp::class_ 简述

一个 c 类最终是通过 v8pp::class_v8 关联起来的, 具体的机制下文再具体展开, 我们先来简单看一下 v8pp::class_ 的声明:

代码语言:javascript复制
/// Interface to access C   classes bound to V8
template<typename T, typename Traits = raw_ptr_traits>
class v8pp::class_{
 public:
  /// Inhert from C   class U
  template<typename U>
  class_& inherit();
  /// Set class member function, or static function, or lambda
  template<typename Function>
  class_& function(std::string_view name, Function&& func, v8::PropertyAttribute attr = v8::None)
  /// Set class member variable
  template<typename Attribute>
  class_& var(std::string_view name, Attribute attribute)
  /// Set read/write class property with getter and setter
  template<typename GetFunction, typename SetFunction = detail::none>
  class_& property(std::string_view name, GetFunction&& get, SetFunction&& set = {});
  //...
 private:
  object_registry& class_info_; //c   class meta info
};

我们通过 function(), var(), property() 等函数直接注册 c 类的函数和成员变量等到 v8 中, 最后大家看到的 object_registry 也是一个核心类, c 类对象在 c <-> v8 之间的转换的核心功能, 基本都是由它来完成的, 其上也有 v8pp 实现 c 对象在 c <-> v8 间转换涉及的最重要的两个 v8::FunctionTemplate, 具体的细节我们下文再具体展开, 我们先来看一下v8pp::object_registry的声明:

代码语言:javascript复制
template<typename Traits>
class v8pp::object_registry final : public v8pp::class_info
{
 public:
  v8::Local<v8::Object> wrap_object(pointer_type const& object, bool call_dtor);

  v8::Local<v8::Object> wrap_object(v8::FunctionCallbackInfo<v8::Value> const& args);

  pointer_type unwrap_object(v8::Local<v8::Value> value);
  // ...
 private:
  std::vector<base_class_info> bases_;
  std::vector<object_registry*> derivatives_;
  std::unordered_map<pointer_type, wrapped_object> objects_;

  v8::Isolate* isolate_;
  v8::Global<v8::FunctionTemplate> func_;
  v8::Global<v8::FunctionTemplate> js_func_;

  ctor_function ctor_;
  dtor_function dtor_;
  bool auto_wrap_objects_;
};

其中包含了c 类的构造函数, 析构函数, 以及在v8 <-> c 间转换 c 类对象用到的 wrap_object()unwrap_object() 函数, 我们先有个基本的印象即可, 此处不具体展开相关的细节.


3.5 convert<> 与一些工具函数

要完成 c v8bridge, 除了基本的c 类和对象到v8的支持, 我们也需要在 c <-> v8 之间处理各种类型的 c 数据, convert<> 和像get_options() set_options() 工具函数存在的价值就是让我们更简单的处理这些数据在 v8c 间的传递, 我们来具体看一个convert<bool>的实现代码:

代码语言:javascript复制
// converter specializations for primitive types
template<>
struct convert<bool>
{
    using from_type = bool;
    using to_type = v8::Local<v8::Boolean>;

    static bool is_valid(v8::Isolate*, v8::Local<v8::Value> value)
    {
        return !value.IsEmpty() && value->IsBoolean();
    }

    static from_type from_v8(v8::Isolate* isolate, v8::Local<v8::Value> value)
    {
        if (!is_valid(isolate, value))
        {
            throw invalid_argument(isolate, value, "Boolean");
        }
#if (V8_MAJOR_VERSION > 7) || (V8_MAJOR_VERSION == 7 && V8_MINOR_VERSION >= 1)
        return value->BooleanValue(isolate);
#else
        return value->BooleanValue(isolate->GetCurrentContext()).FromJust();
#endif
    }

    static to_type to_v8(v8::Isolate* isolate, bool value)
    {
        return v8::Boolean::New(isolate, value);
    }
};

可以看到依赖 v8, 处理bool这种特定类型的数据, 变得特别简单, 每个特化的convert 都会实现 to_v8()from_v8() 这两个函数, 并且正确的定义 from_typeto_type. 其它特化的实现也大量使用了 v8 本身提供的类型, 代码都比较简洁, 这里不一一展开了.


4. 理解 javascript 的构造函数机制

由于 javascript 本身是基于 prototype 设计的语言, 所以对于在 javascript 中创建一个 object, 对比其它语言来说存在一定的特殊性, 我们先要了解 javascript 这部分的设计, 才能够比较好的理解 v8ppjavascript 中创建一个 c 对象, 到底是如何做到的. 下面我们先来介绍 javascript 中这个特殊的实现 - javascript 的构造函数机制.


4.1 javascript 中函数的特殊性

我们先来看一段 javascript 代码:

代码语言:javascript复制
function FunctionTemplate(){

}
var proto= FunctionTemplate.prototype;
proto.vb=10;

var object = new FunctionTemplate();

javascript 中的函数对比其他脚本来说, 是比较特殊的, 如上例所示, 我们会发现, 定义的函数除了通常的 function call 的用法, 它还支持特殊的 new function 操作, 也就是上面提到的构造函数机制, 在new 的情况下, 它有其特殊的运行机制. 以上面的代码为例, 对于new FunctionTemplate() 调用来说, 我们没有添加 return 语句, 但构造函数机制本身会保证: 1. 开始执行的时候先分配基于FunctionTemplateprototypeobject对象, 这里就是一个带 object.vb=10 属性的一个object 2. 执行函数体, 这个时候 this 就指向上一步分配的对象 3. 如果函数体包含return, 则返回return指定的对象到调用者, 否则直接返回最初构建的那个对象, 此处就是一开始从 prototype 构建的对象了.

而通常不带 new 的路径下, 则不会触发 步骤1, 步骤3相关的逻辑了, 就是一个我们理解中的普通函数. 那么, 对应的 javascript 虚拟机实现比如 v8 中, 肯定也会有相关的实现了, 也就是我们下一小节会介绍的 v8::FunctionTemplate 了.


4.2 v8::FunctionTemplate 简介

对于上一节中的 javascript 实现, 我们也能利用 v8 提供的FunctionTemplate 来实现相关的功能, 这也是我们将 c 函数和对象导出到 javascript 中所使用的核心机制. 利用 v8::FunctionTemplatec 中实现相关功能的代码如下:

代码语言:javascript复制
v8::Local<v8::FunctionTemplate> t = v8::FunctionTemplate::New(isolate);
Local<Template> proto = t->PrototypeTemplate();
proto->Set(isolate, "vb",Number::New(10));

Handle<ObjectTemplate> object = t.InstanceTemplate();

此处没有设置 c callbackv8::FunctionTemplate, 作用与js版本中的空函数一致, 最终我们构造出来的 object, 如果我们返回给javascript, 它将与js 版实现一样, 有一个值是 10vb 成员. 本部分我们主要是简单介绍 javascriptv8 中的构造函数相关的使用和概念, 具体 v8pp 的相关机制我们将在下文中继续展开.

[!note] 熟悉 lua 的同学可能会发现, 同为基于 prototype 的设计, javascript 这里的设计其实理解起来比 luameta table 机制还复杂, 可能是由于同一个 function 在不同的执行下给予了不一样的语义. 理解了这里的设计, 基本上其它地方的处理借助 v8 都能轻松实现了.


5. 在 javascript 中构造 c 对象

这部分本身是 v8pp 的核心, 也是整个 v8pp 实现中较难理解的部分. 一方面 v8pp 需要借助自己定义的 object_registry 完成承载 c 类元数据的目的, 在其上正确的记录如ctor(), dtor() 等与 v8 交互过程中需要用到的相关 c 类的功能实现, 另一方面, v8pp 除了借助上面我们提到的 v8::FunctionTemplate 来在 c 中完成如前面例子中提供到 new math3d.Vector3(1.0, 1.0, 1.0) 调用时正确的创建对应的 c 对象外, v8pp 也需要提供机制, 将原来的 c 面向对象的继承结构向 javascript 本身的 基于prototype 机制进行转换.

[!info] 这个过程与 javascript 本身的语法粮class机制, 刚好是个反过程. javascriptclass 机制是利用已有的prototype机制包装出一个面向对象的外观, 而 v8pp 此处是将 c 中已经存在的面向对象继承信息, 包装为javascript中直接可用的 prototype 机制.


5.1 构造对象的流程

我们以前面例子中的math3d.Vector3javascript中的创建为例, 来说明 v8pp 是如何完成 c 对象的构建的, 我们先来看一下整体处理的流程图:

整个过程涉及的对象主要是我们前面介绍的v8pp::class_ 上的 object_resistry 中保存的两个v8::FunctionTemplate, func_js_func, 再关联上 对应的 c 回调(js_func_ 上设置的回调), 通过三步, 最终完成了通过 javascript 触发一个 c 对象的构建并最终转换为v8::Object再返回给javascript的整个执行链路. 我们来具体看一下每一步发生的事情:


5.1.1 step1: 入口 - javascript 的构造函数

这部分机制上主要利用前面我们介绍到的javascript的构造函数语义, 利用该语义, 最终我们会成功触发在 js_func_ 这个v8::FunctionTemplate 上绑定的 c 回调, 并且为我们传入构造参数, 也就上例中的 1.0, 1.0, 1.0. 这也是第二步的入口.


5.1.2 step2: c callback

具体的 c 回调是在 object_registry 中设置的, 代码如下:

代码语言:javascript复制
template<typename Traits>
V8PP_IMPL object_registry<Traits>::object_registry(v8::Isolate* isolate, type_info const& type, dtor_function&& dtor)
    : class_info(type, type_id<Traits>())
    , isolate_(isolate)
    , ctor_() // no wrapped class constructor available by default
    , dtor_(std::move(dtor))
    , auto_wrap_objects_(false)
{
    v8::HandleScope scope(isolate_);

    v8::Local<v8::FunctionTemplate> func = v8::FunctionTemplate::New(isolate_);
    v8::Local<v8::FunctionTemplate> js_func = v8::FunctionTemplate::New(isolate_,
        [](v8::FunctionCallbackInfo<v8::Value> const& args)
        {
            v8::Isolate* isolate = args.GetIsolate();
            object_registry* this_ = external_data::get<object_registry*>(args.Data());
            try
            {
                return args.GetReturnValue().Set(this_->wrap_object(args));
            }
            catch (std::exception const& ex)
            {
                args.GetReturnValue().Set(throw_ex(isolate, ex.what()));
            }
        }, external_data::set(isolate, this));

    func_.Reset(isolate, func);
    js_func_.Reset(isolate, js_func);

    // each JavaScript instance has 2 internal fields:
    //  0 - pointer to a wrapped C   object
    //  1 - pointer to this object_registry
    func->InstanceTemplate()->SetInternalFieldCount(2);
    func->Inherit(js_func);
}

最终的 c 类对象的构建, 是在wrap_object()中完成的, 并且返回的v8::Object被设置为了对应v8::FunctionTemplate的返回值, 这也是最终javascript中所接受到的返回对象:

代码语言:javascript复制
return args.GetReturnValue().Set(this_->wrap_object(args));

另外我们需要注意此处此处分配的两个internal field:

代码语言:javascript复制
// each JavaScript instance has 2 internal fields:
    //  0 - pointer to a wrapped C   object
    //  1 - pointer to this object_registry
    func->InstanceTemplate()->SetInternalFieldCount(2);
    func->Inherit(js_func);

在第三步也就是wrap_object()中我们正是利用这两个InternalField完成了c 对象和v8::Object的绑定.


5.1.3 step3: 构造 v8::Object 并返回至 javascript

我们先来看一下wrap_object()的具体实现:

代码语言:javascript复制
template<typename Traits>
V8PP_IMPL v8::Local<v8::Object> object_registry<Traits>::wrap_object(pointer_type const& object, bool call_dtor)
{
    auto it = objects_.find(object);
    if (it != objects_.end())
    {
        //assert(false && "duplicate object");
        throw std::runtime_error(class_name()
              " duplicate object "   pointer_str(Traits::pointer_id(object)));
    }

    v8::EscapableHandleScope scope(isolate_);

    v8::Local<v8::Context> context = isolate_->GetCurrentContext();
    v8::Local<v8::Function> func;
    v8::Local<v8::Object> obj;
    if (class_function_template()->GetFunction(context).ToLocal(&func)
        && func->NewInstance(context).ToLocal(&obj))
    {
        obj->SetAlignedPointerInInternalField(0, Traits::pointer_id(object));
        obj->SetAlignedPointerInInternalField(1, this);

        v8::Global<v8::Object> pobj(isolate_, obj);
        pobj.SetWeak(this, [](v8::WeakCallbackInfo<object_registry> const& data)
            {
                object_id object = data.GetInternalField(0);
                object_registry* this_ = static_cast<object_registry*>(data.GetInternalField(1));
                this_->remove_object(object);
            }, v8::WeakCallbackType::kInternalFields);
        objects_.emplace(object, wrapped_object{ std::move(pobj), call_dtor });
    }

    return scope.Escape(obj);
}

主要还是利用v8::FunctionTemplateNewInstance() 构建一个新的 v8::Object后(注意此处的 v8::Object 因为构造函数的特殊性, 会自动继承prototype上的所有成员, 也就是我们注册的各成员函数和成员变量等信息), 利用SetAlignedPointerInInternalField() 为我们一开始预分配的两个internal field 绑定我们需要的值, 此处就是 object_registry 和 对应 c 类对象的指针, 这样的操作后如下图所示:

我们成功的将c 类的元信息 - object_registry, c 类对象与一个v8::Object 关联到了一起, 将 v8::Object 转换到 c 也很简单, 我们能够想象, 通过获取v8::Object上关联的internal field, 我们就能拿到相关的 c 类对象的信息做进一步的处理了.


5.2 c 类对象在 c v8 之间的转换处理

类对象 c -> v8, 其实跟我们上面介绍的在js中通过构造函数构造一个c 对象一样, 都是利用wrap_object()来完成的, 区别在于传入的call_ctorfalse, 只会创建对应的v8::Object, 并且设置c 类对象的指针, 而不会从c ctor来构造一个新的指针.

v8 -> c 的处理过程, 代码如下:

代码语言:javascript复制
template<typename Traits>
V8PP_IMPL typename object_registry<Traits>::pointer_type
object_registry<Traits>::unwrap_object(v8::Local<v8::Value> value)
{
    v8::HandleScope scope(isolate_);

    while (value->IsObject())
    {
        v8::Local<v8::Object> obj = value.As<v8::Object>();
        if (obj->InternalFieldCount() == 2)
        {
            object_id id = obj->GetAlignedPointerFromInternalField(0);
            if (id)
            {
                auto registry = static_cast<object_registry*>(
                    obj->GetAlignedPointerFromInternalField(1));
                if (registry)
                {
                    pointer_type ptr = registry->find_object(id, type);
                    if (ptr)
                    {
                        return ptr;
                    }
                }
            }
        }
        value = obj->GetPrototype();
    }
    return nullptr;
}

就是简单的取出前面介绍过的v8::Objectinternal field 并进行进一步的处理的过程, 是不是比读者预想的要简单?

当然, 最终我们是将所有的 c 类型特化为convert<> 来完成的v8<->c 之间的数据互转, 所以此处还会有一个特化的c 类对象的convert<>版本存在:

代码语言:javascript复制
template<typename T>
struct convert<T*, typename std::enable_if<is_wrapped_class<T>::value>::type>
{
    using from_type = T*;
    using to_type = v8::Local<v8::Object>;
    using class_type = typename std::remove_cv<T>::type;

    static bool is_valid(v8::Isolate*, v8::Local<v8::Value> value)
    {
        return !value.IsEmpty() && value->IsObject();
    }

    static from_type from_v8(v8::Isolate* isolate, v8::Local<v8::Value> value)
    {
        if (!is_valid(isolate, value))
        {
            return nullptr;
        }
        return class_<class_type, raw_ptr_traits>::unwrap_object(isolate, value);
    }

    static to_type to_v8(v8::Isolate* isolate, T const* value)
    {
        return class_<class_type, raw_ptr_traits>::find_object(isolate, value);
    }
};

用来进行SFINAEis_wrapped_class<>实现也很简单:

代码语言:javascript复制
template<typename T>
struct is_wrapped_class;

template<typename T>
struct is_wrapped_class : std::conjunction<
    std::is_class<T>,
    std::negation<detail::is_string<T>>,
    std::negation<detail::is_mapping<T>>,
    std::negation<detail::is_sequence<T>>,
    std::negation<detail::is_array<T>>,
    std::negation<detail::is_tuple<T>>,
    std::negation<detail::is_shared_ptr<T>>>
{
};

// convert specialization for wrapped user classes
template<typename T>
struct is_wrapped_class<v8::Local<T>> : std::false_type
{
};

template<typename T>
struct is_wrapped_class<v8::Global<T>> : std::false_type
{
};

template<typename... Ts>
struct is_wrapped_class<std::variant<Ts...>> : std::false_type
{
};

因为对应的c 类对象可能是值类型, 或者shared_ptr<>类型, 所以这里还会有几个大同小异的特化实现, 不一一展开了. 这样对于一个 c 类对象, 我们也能很方便的通过 convert<>或者内部调用convert<>v8pp::from_v8<>() 以及v8pp::to_v8<>()来方便的在c <->v8之间进行转换了.

[!info] 整体感觉 v8ppFunctionTemplate 的设计, 主要还是缺乏对 c 反射的基本抽象, 依赖 v8 本身去处理c 静态成员和动态成员, 这样其实较大的提高了v8pp本身的复杂度, 可以预见的, 当静态反射正式进入c 的标准, 类似的实现都能大幅得到简化, 或者本身框架的实现中包含c 反射的基础实现, v8pp的这部分实现都能改为单FunctionTemplate, 以更简洁易懂的逻辑来实现相关的功能.


5.3 导出 c 函数和属性到 v8

这里比较特殊的地方是v8pp利用两个级联的v8::FunctionTemplate来完成对一个类的静态成员和普通成员的分离, 理解了这里, 相关的函数注册的代码和成员注册的代码都很好理解了.


5.3.1 函数注册

相关实现代码如下:

代码语言:javascript复制
/// Set class member function, or static function, or lambda
    template<typename Function>
    class_& function(std::string_view name, Function&& func, v8::PropertyAttribute attr = v8::None)
    {
        constexpr bool is_mem_fun = std::is_member_function_pointer_v<Function>;

        static_assert(is_mem_fun || detail::is_callable<Function>::value,
            "Function must be pointer to member function or callable object");

        v8::HandleScope scope(isolate());

        v8::Local<v8::Name> v8_name = v8pp::to_v8(isolate(), name);
        v8::Local<v8::Data> wrapped_fun;

        if constexpr (is_mem_fun)
        {
            using mem_func_type = typename detail::function_traits<Function>::template pointer_type<T>;
            wrapped_fun = wrap_function_template<mem_func_type, Traits>(isolate(), std::move(mem_func_type(std::forward<Function>(func))));
        }
        else
        {
            wrapped_fun = wrap_function_template<Function, Traits>(isolate(), std::forward<Function>(func));
            class_info_.js_function_template()->Set(v8_name, wrapped_fun, attr);
        }

        class_info_.class_function_template()->PrototypeTemplate()->Set(v8_name, wrapped_fun, attr);
        return *this;
    }

无论静态函数还是成员函数, 最终我们都是将相关的c 函数类型擦除到v8::FunctionTemplate 后直接赋予class_function_template() - object_registry::func_prototype 来完成的, 静态函数会额外将对应的成员赋值给js_function_template() - object_registry::js_func_, 这样在javascript中, 我们能够直接根据如math3d.Vector3这个名称(实际上是一个FunctionTemplate)直接调用相关的静态函数了. 函数类型擦除的部分我们在c 反射相关的文章中探讨的比较多, 这里就不再具体展开相关的细节了.


5.3.2 成员变量注册

相关的代码实现如下:

代码语言:javascript复制
/// Set class member variable
    template<typename Attribute>
    class_& var(std::string_view name, Attribute attribute)
    {
        static_assert(std::is_member_object_pointer<Attribute>::value,
            "Attribute must be pointer to member data");

        v8::HandleScope scope(isolate());

        using attribute_type = typename detail::function_traits<Attribute>::template pointer_type<T>;
        attribute_type attr = attribute;

        v8::Local<v8::Name> v8_name = v8pp::to_v8(isolate(), name);
        v8::Local<v8::Value> data = detail::external_data::set(isolate(), std::forward<attribute_type>(attr));
        class_info_.class_function_template()->PrototypeTemplate()
            ->SetAccessor(v8_name,
                &member_get<attribute_type>, &member_set<attribute_type>,
                data,
                v8::DEFAULT, v8::PropertyAttribute(v8::DontDelete));
        return *this;
    }

同样也是利用FunctionTemplate 对应的prototype, 并且利用v8本身提供的SetAccessor(), 可以方便的支持将 c 类的成员变量, 转换为javascriptproperty, 本身机制并不复杂, 这里也不再做具体的展开了.


6. 调用一个 javascript 中的函数

我们利用v8pp提供的 v8pp::call_v8() 函数即可完成相关的操作了, 以下面的javascript中定义的js_add()函数为例:

代码语言:javascript复制
function js_add(a, b) {
  return a   b;
}

通过以下c 代码:

代码语言:javascript复制
v8::Local<v8::Function> fun;
  v8pp::get_option(isolate, context.global(), "js_add", fun);
  if(fun->IsFunction()) {
    v8::Local<v8::Value> result = v8pp::call_v8(isolate, fun, fun, 2, 3);
    auto val =  v8pp::from_v8<int>(isolate, result);
  }

我们就可以完成 c 调用javascript 函数的功能, 相关的功能同样也非常的简洁方便. 另外, 此处的from_v8<T>()内部调用的是我们前面介绍过的convert<>().


7. 异常处理

借助 v8::TryCatch, 本身就能很好的完成 v8 的脚本执行异常捕获, v8pp 主要提供了一个主动招聘异常的接口, v8pp::throw_ex(), 我们直接通过示例代码了解 v8 相关的异常捕获机制以及 v8pp::throw_ex() 的基础用法:

代码语言:javascript复制
v8pp::context context;
v8::Isolate* isolate = context.isolate();

v8::HandleScope scope(isolate);
v8::TryCatch try_catch(isolate);

v8::Local<v8::Value> ex = v8pp::throw_ex(isolate,
    "exception message", exception_ctor);

ASSERT_TRUE(try_catch.HasCaught());

v8::String::Utf8Value const err_msg(isolate, try_catch.Message()->Get());

std::cerr << *err_msg << std::endl;

这样, c <-> v8 中间层的异常我们也可以通过主动处理的方式统一按 v8 的异常机制进行处理了.


8. 关于 Promise

Promise 部分的调度处理其实与跨语言这部分关系不是特别大, 准确的讲它其实更多的是业务框架本身调度方式的体现, 在后续文章 node.js 架构分析的部分我们再具体展开相关的细节, 本篇中不做具体的展开.


9. 结语

本篇文章对v8pp的核心实现做了相关的剖析, 如果读者尝试进行过 lua 和 python 相关的c bridge 工作, 可以看到, 理解一些核心机制如javascrptprototype机制后, 整体的实现对比 pythonlua 来说, 其实非常简洁, 也比较容易弄懂. 特别是与pybind11对比, 整体实现复杂度和依赖的机制要少非常多, 从这里也可以看出一个特性完备的虚拟机, 对于c bridge来说, 是相当友好的, 这可能也是v8以及javascript慢慢变得流行的原因吧. 当然, c 本身类型擦除的机制不是本篇的重点, 相关的细节我们没有做具体的展开, 感兴趣的读者可以阅读笔者反射相关的系列文章深入了解相关的细节.

[!info] v8pp 中, 像 overload 之类的机制, 我们更多需要借助 const v8::FunctionCallbackInfo<v8::Value>& 这样的特殊参数来提供多参数和重载相关的支持, 像 v8pp::class_ctor()注册, 以及function()注册, 都能够很好的支持v8::FunctionCallbackInfo<v8::Value>& 这种 v8 原生的参数类型, 这其实跟我们在lua中自定义 Lua C Function 非常相似.


10. 参考

  1. v8pp GitHub
  2. OpenAI Gpt4 Web版
  3. 来自 视频号- "方神的山海小仙" 的友情指导

0 人点赞