查看原文
其他

搭建连接JS与C++的桥梁 - 爱奇艺RND框架之JS绑定

客户端基础架构组 爱奇艺技术产品团队 2019-06-13

写在前面

为满足爱奇艺RND框架的JS批量绑定需求,在调研和借鉴了现有流行开源框架成功经验的基础上,我们开发设计了一套新的JS自动绑定工具,并且定义了弹性化的绑定规则,探索解决了多继承绑定、Wrapped指针转换、多JS引擎支持等问题,特别是将Mate API与Node-Addon-API进行了融合,形成了隔离底层JS引擎的具有ABI稳定性的JS Common API层,并将为其实现JavaScriptCore Port。所有这些工作为RND框架打造了低耦合、易维护的底层JS引擎环境,同时也提供了坚实通用的JS自动绑定支持。


本文将介绍RND中的JS自动绑定工具的功能和实现原理。

首先来说说什么是RND?

RND,全称React Node Desktop,起源于RN在爱奇艺PC端的实现,采用React js framework + Node.js runtime + native UI engine架构,目标是成为最轻量的JS开发桌面应用的跨平台方案。RND将React技术栈和开发体验引入到桌面软件的开发实践中,大大提升了开发效率和产品迭代速度。在RND新版本的迭代过程中,需要将爱奇艺自研UI引擎Lyra中的类库直接绑定到JS运行时中,且同时支持V8、JavaScriptCore等多个JS引擎。由于待绑定的C类数量庞大,若用人工手写绑定代码工作量可想而知,这就要求开发一种JS自动绑定工具以便批量地生成绑定层代码,搭建起这座连接JS与C的桥梁。

确定技术方案之前,要对WebKit和Cocos Creator中的JS自动绑定技术进行调研。WebKit使用Web IDL对绑定需求进行描述,然后通过Perl脚本解析这些IDL,为不同的JS引擎分别生成对应的绑定代码。但总是避免不了会存在一些需要手动编写的绑定代码,在这种情况下就需要为每个JS引擎都手写一份绑定代码。Cocos Creator则为不同的JS引擎抽象出了一套通用接口,通过libclang解析待绑定的C类库后生成基于通用接口的绑定代码。Cocos Creator的方案不存在WebKit的问题,抽象出通用JS接口的设计既避免了手动绑定代码时为多个JS引擎重复编写的麻烦,又为扩展支持新的JS引擎提供了便利。

尽管如此,仍然存在一些无法满足我们需求的地方。首先,Cocos Creator的绑定规则的描述粒度不如Web IDL精细,比如不能针对某个特定的待绑定方法或属性设置其PropertyDescriptor。其次,绑定层函数原型必须是统一的形式,在其实现中绝大部分代码都是调用JS与C类型转换的代码,手写绑定代码时会比较繁琐。最后,Cocos Creator不支持绑定C++中的多继承。为解决上述问题,我们决定重新开发一套适合RND框架的JS自动绑定工具。

再讲讲什么是JS绑定?

我们知道JS程序的运行依赖于宿主环境,不同宿主环境下的JS程序可调用的API集合也是不同的。这些API分为三部分:

1)由JS引擎实现的ECMAScript标准API;

2)由宿主实现并通过JS引擎提供的Native接口向JS Context直接提供的API,例如浏览器中的DOM、Node.js中的内部实现中通过process.binding获取的模块、RND中的_setTimeout等;

3)在前两者基础上实现的API,例如React框架、Node.js中通过require获取的的模块、RND中的setTimeout等。狭义上的JS绑定指的是第2类API的实现方式,广义上的JS绑定还包括由宿主基于第2类API来实现第3类API这种方式。本文中的JS绑定指的是狭义上的JS绑定。

下面来看一个简单的例子,以便更具体地了解JS绑定,顺便也能了解RND中JS自动绑定工具的输入和输出是什么。

假设存在一个待绑定的C++类Foo,如下:

// foo.hpp
#include <string>
class Foo {
public:
std::string ToBind(std::string msg);
int NotBind(int input);
}

// foo.cpp
std::string Foo::ToBind(std::string msg) {
return msg + " world!";
}

int Foo::NotBind(int input) {
return input;
}

下面是一个绑定规则文件foo.yaml,表示要绑定Foo类及其ToBind方法。

# foo.yaml

classes:
  - wrapped_class_name: Foo
    bind_type: class
    methods:

      - wrapped_method_proto: |
std::string ToBind(std::string msg);

自动绑定工具根据输入的foo.yamlfoo.hpp生成的绑定代码片断如下:

// Foo_Auto.hpp
#include "foo.hpp"

class FooWrappable : public Mate::WrappableObject<Foo> {
public:
Foo* impl_;

/*** constructors ***/
    FooWrappable(v8:
:Isolate* isolate, v8::Local<v8::Object> wrapper);

/*** destructors ***/
~FooWrappable();

/*** non-static methods - Foo ***/
std:
:string ToBind(std::string msg);

...
};

// Foo_Auto.cpp
#include "Foo_Auto.hpp"

FooWrappable::FooWrappable(v8::Isolate* isolate, v8::Local<v8::Object> wrapper) {
impl_ = new Foo();
InitWith(isolate, wrapper);
SetTrack(isolate, wrapper, impl_);
}

FooWrappable::~FooWrappable() {
ClearTrack(impl_);
delete impl_;
}

std::string FooWrappable::ToBind(std::string msg){
return impl_->ToBind(msg);
}

...

JS调用代码如下:

// foo.js
var foo = new Foo();
foo.toBind("hello");// hello world!

可以看到,自动绑定工具为Foo类生成了一个FooWrappable类,这个类拥有一个Foo*类型的成员变量,构造函数、析构函数以及用户在绑定规则文件foo.yaml中所指定的ToBind函数。当JS代码调用new Foo()时会调用到FooWrappable的构造函数,进而创建出Foo对象实例。同样的,当调用toBind函数时,也会先调用到FooWrappable这一层,再委托给Foo对象实例的ToBind函数。当Foo对象实例被JS引擎垃圾回收时,析构函数以同样的方式被层层调用。FooWrappable层就是连接JS与C的桥梁 - JS绑定层。JS与C类库之间的相互调用都会经过JS绑定层,虽然例子中仅展示了JS调用C的情形,其实反过来当C调用JS Callback也一样,例如RND中setTimeout的实现就是先由native调用到绑定层,然后再调用到JS Callback。

现在自动绑定工具的使命已经明确了,就是以尽可能低的人工成本生成JS绑定层代码。

工作流程

那么绑定工具是如何根据绑定规则和待绑定类库的头文件自动生成JS绑定层代码的呢?工作流程如下:

C++ Library --libclang--> AST --ast parser--> wrapped class map --rule parser--> target class map --refiner--> wrappable class map --template--> binding code即:

1. 使用libclang解析待绑定的C++库的头文件后得到AST。

2. 遍历上一步生成的AST,得到所有的C++类信息,例如类名、方法名、属性名、参数名、参数类型等。

3. 解析用户定义的绑定规则,对上一步的C类信息进行匹配,过滤不需要绑定的C类,得到目标C++类。

4. 对目标C类信息进一步处理,例如合并C重载函数、合并父/子类方法和属性、检查冲突等,得到可用于模板渲染的数据。

5. 基于绑定层框架编写模板,并将上一步的数据feed给这些模板,渲染出最终的绑定代码。

绑定规则

正如前面例子中foo.yaml所展示的,我们自定义了一套绑定规则,大致如下:


# classes: 由待绑定的class组成的list
# class分三种绑定类型interface, object, class,通过bind_type指定。某些属性只对特定类型有效。
# - interface: 表示在JS中无法通过new实例化此类,相当于C++中的抽象类,只能作为JS原型链上的一环。
# - object: 表示向JS绑定一个对象,相当于C++中的单例对象。
# - class: 表示向JS绑定一个可动态实例化的类,即可通过new构造出对象实例。
#
classes:
# wrapped_class_name: 通用字段,必选,支持正则表达式。表示需要绑定的c++类名。
  - wrapped_class_name: SampleClass
# bind_type: 通用字段,可选。表示绑定类型,共三种,分别是interface,object,class。若省略此项,则使用默认值class。
    bind_type: class
# js_class_name: 通用字段,可选。表示JS中对应的类名。若省略此项,则表示与wrapped_class_name一致。
    js_class_name: SampleClass
# wrapped_decl: 通用字段,可选。表示待绑定的C++对象在生成的绑定层类的头文件中的变量声明语句,一般为祼指针或智能指针。
# 若省略此项,则默认为祼指针,即wrapped_class_name* impl_; 注意变量名必须是impl_,下同
    wrapped_decl: |
SampleClass* impl_;
#
    # methods: 通用字段,可选。指定除构造和析构函数之外需要绑定的方法。
    methods:

# wrapped_method_proto: 必选,支持正则表达式。指定待绑定的方法原型,请复制粘贴待绑定的C++类头文件中的方法原型。
# 采用这种方式时绑定工具会自动生成绑定代码的声明和实现。
      - wrapped_method_proto: |
virtual int GetWidth() const;
        # js_method_name: 可选。指定JS方法名称。若省略此项,则使用wrapped_method_proto函数名的首字母小写形式,下同。
        js_method_name:
getWidth
# wrappable_method_proto: 必选。允许用户不依赖待绑定C++类头文件中的方法,而是完全自定义绑定层函数。
      - wrappable_method_proto: |
          Mate::SafeString
GetName();
        js_method_name: getName
# static: 可选。指定此方法是否为static。若省略此项,则使用默认值false。
        static: true
#
# properties: 通用字段,可选。指定需要绑定的属性。
    properties:
# wrapped_property_name: 必选,支持正则表达式。指定待绑定的属性名。
# 采用这种方式时系统会自动生成绑定代码的声明和实现。
      - wrapped_property_name: m_Width
# js_property_name: 可选。指定JS属性名称。若省略此项,则使用去除前缀且将首字母改为小写形式的wrapped_property_name,下同。
        js_property_name: width
# accessor_descriptor: 可选。指定JS属性的访问器描述符。
# 若此项省略,则采用默认值。各项默认值分别是:configurable-false,enumerable-false,set-true
        accessor_descriptor: {configurable: false, enumerable: false, set: false}
...

绑定规则使用YAML语法进行描述。之所以选择YAML是因为它可读性高,支持注释功能,提供层次化的描述能力,且可以将C代码以文本形式嵌入,而不会对*,#,&等特殊字符进行转义。为保持一定程度的灵活性,绑定规则考虑了待绑定的C库可能存在的内存管理机制,例如C++对象可能是祼指针/智能指针,祼指针的话可能还有引用计数机制等。最后,也允许用户在一些特殊情况下手动实现绑定层代码。

绑定层框架——Mate

前面例子中的绑定层FooWrappable类继承自Mate::WrappableObject,后者是我们的绑定层框架Mate。

上面这幅图以爱奇艺客户端中的UI引擎Lyra的绑定为例,描述了绑定层次结构。首先,QyClient作为一个Embedder嵌入了JS引擎V8和UI引擎Lyra,JS绑定层LyraWrappable是通过自动绑定工具生成的,它依赖于绑定层框架Mate、UI引擎和JS引擎。

Mate是Electron对Chromium Gin的fork,它基于V8 API进行了适配,内置了一些用于转换C和JS基本数据类型的Converter模板,并利用Chromium的base::callback将待绑定的C函数自动wrap成V8 callback。对于可序列化为字符串的C类或结构体,例如SIZE、POINT、RECT等,用户也可通过扩展Converter模板来对这些数据结构的C/JS类型转换进行支持。得益于C模板参数推导机制,所有的Converter都能被Mate框架自动调用,因此数据类型转换工作得以在Mate框架层自动进行,用户只需要按照与待绑定C库函数一致的方式定义绑定层函数原型即可。这些特点使得基于Mate手写无法自动绑定的代码时非常方便和简洁,避免了直接使用较为晦涩的V8 API。下面是与Cocos Creator绑定层代码的一个简单对比:

Cocos Creator绑定函数实现:

static bool JS_box2dclasses_b2Draw_AppendFlags(se::State& s)
{

b2Draw* cobj = (b2Draw*)s.nativeThisObject();
SE_PRECONDITION2(cobj, false, "JS_box2dclasses_b2Draw_AppendFlags : Invalid Native Object");
const auto& args = s.args();
size_t argc = args.size();
CC_UNUSED bool ok = true;
if (argc == 1) {
unsigned int arg0 = 0;
ok &= seval_to_uint32(args[0], (uint32_t*)&arg0);
SE_PRECONDITION2(ok, false, "JS_box2dclasses_b2Draw_AppendFlags : Error processing arguments");
cobj->AppendFlags(arg0);
return true;
}
SE_REPORT_ERROR("wrong number of arguments: %d, was expecting %d", (int)argc, 1);
return false;
}

RND等价实现:

void b2DrawWrappable::AppendFlags(uint32 flags) {
impl_->AppendFlags(flags);
}

上面两段代码都实现了绑定b2Draw::AppendFlags方法的功能,相比之下可以看出RND绑定层函数实现非常简单,绝大部分情况下函数原型可以与待绑定的C++库函数原型保持一致。这是因为Mate框架会自动调用Converter进行参数转换,并处理此过程中可能出现的错误。若转换顺利完成,则会以转换后的参数调用绑定层函数。上例中调用了Mate内置的uint32_t类型Converter,代码示例如下:


bool Converter<uint32_t>::FromV8(Isolate* isolate, Local<Value> val,
uint32_t* out) {
if (!val->IsUint32())
return false;
*out = val->Uint32Value();
return true;
}

Local<Value> Converter<uint32_t>::ToV8(Isolate* isolate, uint32_t val) {
return v8::Integer::NewFromUnsigned(isolate, val);

}

只需要为某个类型定义一次Converter,即可自动处理同类型参数的C++/JS类型转换,而不必在每个函数实现中重复编写类型转换代码。

遇到的问题

目前仍存在一些无法自动绑定的情况,包括:

▪ 待绑定的C++库中并不存在相应的方法。

▪ C调用JS callback的情况。这种情况下JS callback需要先被转换成base::Callback,然后再适配成C库函数中定义的Callback原型。目前需要手动编写这部分代码。

▪ 由于指针本身语义模糊的问题,对于通过Converter方式绑定的C类型以及C基本类型(例如int,bool,float等)来说,如果它们作为函数参数和返回值时是以指针或非const引用形式出现,则需要手写绑定函数。

▪ 由于对象生命周期同步问题,对于通过非Converter方式绑定的C++类型(例如前面的lyra::CControlUI)来说,如果它们作为函数参数和返回值时不是以指针和引用形式出现,则无法绑定。

▪ 以Converter方式进行绑定的情况。

▪ 非类函数和变量的绑定。目前暂未实现。

如何手写绑定代码

自定义Converter

前面例子中的C类Foo和b2Draw被绑定到JS运行时后,JS可通过new运算符创建出JS对象实例,同时在C端一个对应的C对象也被创建出来,JS对象仅仅是一个壳,在它上面的所有操作都会调用到C对象上。但对于C中一些仅用于数据传递目的,且这些数据可序列化为字符串形式的数据结构来说,并不需要这么重量级的绑定方式,只需像C基本数据类型一样,定义相应的Converter来完成C与JS之间的类型转换即可。这样的C数据结构在JS中对应对象字面量,例如JS中的{cx:100,cy:100}等同于下例中C++ SIZE对象。

v8::Local<v8::Value> Converter<SIZE>::ToV8(v8::Isolate* isolate,
const SIZE& val) {
mate::Dictionary dict = mate::Dictionary::CreateEmpty(isolate);
dict.Set("cx", val.cx);
dict.Set("cy", val.cy);
return dict.GetHandle();
}

bool Converter<SIZE>::FromV8(v8::Isolate* isolate,
v8::Local<v8::Value> val,
SIZE* out) {
mate::Dictionary dict;
  if (!ConvertFromV8(isolate, val, &dict))
return false;
  if (!dict.Get("cx", &out->cx) || !dict.Get("cy", &out->cy))
return false;
return true;
}

上面代码中,ConvertFromV8和Dictionary(对应JS中的对象)都会在内部调用现有Converter。具体来说,分别调用了Mate内置的v8::Local<v8::Object>和long的Converter。例子中没有展示出来还有ConverToV8,实际上Dictionary的Set函数内部会调用ConverToV8,Get函数内部调用的则是ConvertFromV8。使用这几个API函数来实现自定义类型Converter还是比较方便的。

自定义绑定层方法

绑定层提供了很大的灵活性,允许用户自定义实现一些特殊情况,例如希望在现有C++库函数基础上封装新的函数给JS使用。

void FooWrappable::SetKeyValue(v8::Isolate * isolate, std::string key, v8::Local<v8::Value> val) {
if (key == "width") {
int width;
if (mate::ConvertFromV8(isolate, val, &width)) {
impl_->SetWidth(width);
}
} else if (key == "height") {
int height;
if (mate::ConvertFromV8(isolate, val, &height)){
impl_->SetHeight(height);
}
}
}

对应的JS调用代码如下:

var foo = new Foo();
foo.SetKeyValue("width", 100);
foo.SetKeyValue("height", 200);

上面的例子中,假设C++类Foo存在SetWidth和SetHeight方法,并想将两个方法组合成一个setKeyValue方法绑定到JS运行时,因为Foo类中本不存在这样一个方法,自然无法自动生成绑定代码,因此需要在绑定层,也就是FooWrappable类中手动实现这个方法。这就需要先在绑定规则文件中使用wrappable_method_proto项写好函数原型,然后在手动创建的一个源文件中include自动生成的绑定层的类头文件,手动实现该函数。

突破一些迭代
  • 多继承绑定

Mate框架的一个问题在于不支持继承关系的绑定,更别说多继承了。虽然待绑定的C++类之间存在继承关系,但绑定层的各个类之间是不存在继承关系的。如果非要利用JS原型链来实现继承,则在函数调用过程中有可能发生错误的指针转换。

先来看函数调用过程中指针经历了怎样的转换


JS对象上绑定的是一个void指针。当调用JS对象的SayHello方法时,Mate会将void指针通过static_cast转成绑定层的Wrappable指针,然后调用Wrappable::SayHello函数,进而调用impl_对象的SayHello方法。在这背后,编译器实际上是通过Wrappable指针值加上impl_变量的偏移得到impl_变量的地址。

再来看使用JS原型链实现继承时子类对象调用自身方法和父类方法时的情形。



请注意子类对象上绑定的指针是WrappableS指针,当子类对象调用自身方法SayWorld时,指针获取和转换如前所述,没什么问题。但当子类对象调用父类方法SayHello时,则会调用到父类的绑定层函数,也就是WrappableB::SayHello中。Mate框架会通过static_cast将事实上的WrappableS指针误转成WrappableB指针(这种转换是在编译时就已经确定了的)。然后按照WrappableB的内存布局和偏移去获取WrappedB指针,这时实际上获取到的是WrappedS指针。

最后把WrappedS指针当成WrappedB指针去调用SayHello方法。在单继承且保证impl_变量总是位于内存布局起始位置的情况下,这种调用过程歪打正着最终还是可以正确调用到父类函数。但如果是多继承且Wrappable类中存在自定义变量时,即使能保证各个Wrappable类中impl_变量的地址偏移都为0,但是自定义变量的偏移无法保证是一致的,如果Wrappable类自定义函数实现中使用了这些自定义变量,就可能导致崩溃。因此RND只能采用在子类上重复绑定父类方法的做法来实现多继承,这样的话父类方法就变成子类上的方法,子类对象调用父类方法就是调用自身方法,因此不会调用到父类的Wrappable,从而避免上述问题。值得一提的是,采用这种方法实现继承后,我们将自定义绑定层方法的实现模板化,这样就避免了在子类上重复编写自定义代码。

  • Wrapped指针转换

在子类上重复绑定父类方法只解决了多继承的一半问题,另一半问题是,当需要将子类对象传递给父类方法时,或者,父类方法返回指向子类对象的父类指针时,同样的指针转换和误用问题会发生。

var base1 = New Base();
var base2 = New Base();
var derived = New Derived();
base1.link(base2);
base1.link(derived);

C++中的Base::link函数要求传入Base指针,当JS中传入一个子类对象时,实际的子类的Wrappable指针被当成父类的Wrappable指针在使用,然后按照父类的Wrappable内存布局和偏移取得的是子类Wrapped指针变量impl_,却被当成父类的Wrapped指针传给了link函数。只要保证父类和子类的Wrappable的内存布局和impl_变量偏移是一致的,那么在单继承情况下,子类和父类的Wrapped指针地址也是相等的,不会存在问题。在多继承情况下必须对wrapped指针impl_进行转换,但impl_指针真实类型只有在运行时才能知道,且是动态可变的,因此只能通过在直接父/子类之间构建起指针转换MAP,通过查询转换来完成运行时期impl_指针的动态转换。

  • 支持多个JS引擎

由于Mate仅支持V8 API,因此需要对绑定层框架进行适当修改,以便支持其他JS引擎,例如JavaScriptCore。存在三种可选方案,如下所示:

  • Mate for JSC

按照与V8绑定相似的思路,实现一个基于JSC的Mate。此方案问题在于,对那些不得不手动绑定的代码来说,仍要为各个JS引擎分别实现。

  • JSCShim

github上有一个开始于2018年9月的Node-JSC,旨在让Node.js运行在JavaScriptCore引擎上。它的做法与微软的Node-ChakraCore相同,都是使用其它JS引擎API去适配V8 API,由于事实上很难实现所有v8.h中的API,且没有必要,因为只要满足Node.js需要即可,所以实现的只是部分V8 API。此方案问题在于,一方面此项目并不成熟,另一方面其实现过程中需要修改JavaScriptCore源码,而不是使用JavaScriptCore官方接口。

  • JS Common API

前面两种方案因其所存在的问题被排除在外。JS Common API方案抽象出一层很薄的中间层JS common API,当然这也需要修改Mate框架进行适配。第二种方案也可看成是这种方案的特例,即选择V8 API作为Js Common API。

如上图所示,RND在JS Common API方面选用了Node.js中的Node-Addon-API,并对其进行了裁剪和修改。Node-Addon-API是N-API的C++接口,由于它只是以inline方式实现的头文件,所以仍保有N-API的ABI稳定性。Mate原本是为了提供相较于V8 API更简单方便的接口而存在的,既然现在Mate已不直接基于V8 API了,所以干脆将Mate的功能融合进Node-Addon-API,即只保留JS Common API层即可。所以方案最终变为:

目前正在开发JavaScriptCore Port,完成后将达到同时支持V8和JavaScriptCore的目的。事实上,Node.js已将N-API代码拆成了node_api和js_native_api两部分,前者是与Node.js本身特性相关的部分,后者则是仅与ECMAScript标准相关的部分,这种拆分为Node.js之外的N-API实现提供了便利。

未来计划

▪ 开发JS Common API的JavaScriptCore Port。

▪ 简单Converter的自动生成。一些简单类型的Converter其实也可通过绑定工具自动生成。

▪ 可视化工具。提供一个可视化工具,用户可使用此工具指定绑定需求,而不是直接手写绑定规则文件。

▪ C++祼指针、智能指针、引用计数机制的选项化。用户只需要根据实际情况选择相应的选项,而不是在绑定规则文件中写C代码。

▪ 非类函数和变量的绑定。


未来我们还将持续改进。如果您想了解更多关于RND的信息,请参阅爱奇艺RND框架系列的其他文章。


end

也许你还想看

干货|格物致知—机器学习应用性能调优

干货|爱奇艺直播 - 春晚直播业务API架构


扫一扫下方二维码,更多精彩内容陪伴你!

爱奇艺技术产品团队

简单想,简单做


Modified on

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存