【核心技术】功夫核心库架构篇之三「跨语言数据交换」

董可人/CEO

功夫核心库https://libkungfu.cc自2017年发布以来,经过七年发展,已经从最初的作为 Linux 后台进程运行、仅支持实时交易数据通信的原始形态,演化成了如今的跨平台、自带完整的 GUI/TUI 交互、App/Headless随意切换、带有丰富的数据管理/诊断工具等实用工具箱、多语言(C++/Python/JS)融合的一个极速低延迟计算框架。功夫核心库也积累了众多的专业用户群体,甚至包括多家国内头部投资机构,有超大型AA券商,有百亿私募,也有尖端自营团队。由于一直专注于提升产品质量,我的大部分时间都用在了写代码和改进生成流程,这个“架构篇”系列也断更了七年。如今随着新版发布,终于有时间继续讲解功夫的架构思路。

 
 

简单的回顾下前情,我们在前两篇文章中介绍了功夫核心库的设计动机,以及核心的底层原理。经过七年的长跑,今天我可以非常自信的说,这些核心理念经受住了时间的考验,一直贯彻在功夫核心库的迭代演化中,至今也没有一丝的改变。并且,从这些最基础的概念出发,我们不断的通过技术创新来拓展产品的边界,并得到了很多意想不到的新能力。这一篇就来为大家介绍我们在2.3版本引入的跨语言的数据交换方案。

首先简单说明为什么我们需要跨语言数据交互能力。大家知道,基于 mmap 的共享内存通信方案,底层实现必须通过 C++ 这种编译语言来实现对硬件和操作系统的细粒度控制;但是到了策略开发、UI控制这种上面的应用层,如果还是只能使用 C++ 就会非常的笨重,而更需要比如 Python 科学计算或是 JS 响应式设计的优势能力。如果没有这种拓展,只专注在少量的核心函数上,会给核心库的使用场景带来极大的局限,很难得到广泛的实际应用;这也就意味着核心库很难得到大量真实的生产案例,最终会严重的制约产品质量,只能是一个短命的结局。因此从2.0版本开始,我们就试图融合 C++/Python/JS 这三种在各自领域独具优势的语言,来实现一个完整的解决方案。

为了实现这个目标,我们有这样一个简化版本的基本诉求:对于一个核心的数据类型,例如行情快照 Quote,假设它的 C++ 版本是这样的:

struct Quote {
  float price;
  int volume;
  char[20] symbol;
}

我们就需要能够在 Python 端做对应的类型映射来确保数据的一致性:

def on_quote(quote):
  print(quote.price)
  print(quote.volume);
  print(quote.symbol);

以及 JS 端需要同样性质的工作:

function on_quote(quote):
  console.log(quote.price);
  console.log(quote.volume);
  console.log(quote.symbol);

(以及顺便一提,除了这几种语言,功夫核心库还采用 sqlite 来做一些内部的数据索引工作,也需要使用 ORM 技术来解决类型映射的问题。)

但在初始阶段,我们只能做到通过 pybind11 这样的语言相关粘结剂来手动的绑定类型,这使得对每种语言都需要单独维护一套手工绑定代码,例如 Python 版本的绑定是类似这样的:

py::class_<Quote>(m, "Quote")
    .def(py::init<>())
    .def_readwrite("price", &Quote::price)
    .def_readwrite("volume", &Quote::volume)
    .def_readwrite("symbol", &Quote::symbol);

做 sqlite 的 ORM,是这样:

from sqlalchemy import *

class Quote(ModelMixin, Base):
    __tablename__ = 'Quote'
    price = Column(Float)
    volume = Column(Integer)
    symbol = Column(String)

而对于 JS ,由于当时的前后端通信采用了一种 CS 架构,还需要手动对数据进行 JSON 编解码,才能实现数据流动。

当核心的数据类型有几十种时,这种映射带来的手工工作量是巨大的,每一次对类型的修改都十分的繁琐,并且很难在编译期就发现所有问题,比如 ORM 及 JS 端,都只能在运行时才发现问题。这样带来极搞的开发成本和巨大的产品质量隐患。

看到这里,可能有人会说,可以通过比如 Protobuf 这一类工具库来解决。但是考虑到如果使用这种工具,需要在编译步骤里额外的引入代码生成代码的环节,这种负担对于一个需要多人协作的复杂项目来说有不小的副作用:

  • 需要在编译流程里增加额外的工具调用,这需要增加开发者的学习成本;
  • 对自动生成的代码进行版本管理会带来这样一个两难选择:
    • 把生成的代码加入到版本追踪 – 这就很难避免一些开发者无意中手动修改这些代码而带来意外的问题;
    • 把生成的代码加入某个临时目录 – 这会给代码阅读带来额外负担,任何想要阅读完整代码的人首先都必须成功执行编译流程

如果项目的协作者比较少,上面这种问题比较容易控制,大家都是熟人打个招呼就能沟通;但是对于一个长生命周期的项目来说,需要考虑到随着时间发展,不断会有新人加入到项目开发中,这些埋藏在代码或文档细节里的额外工作必然会带来更多的培训和管理成本,老鸟们也会很快失去对新人的耐心。

另外,回顾我们在「易筋经初探」中讲到的 mmap 设计方案,通过共享内存我们能做到数据在不同进程间的零拷贝通信,如果还需要再进行额外的 socket 传输以及编解码操作才能实现前后端通信,实在是非常的浪费。为什么不能通过 C++ 的 binding 来直接进行数据转化呢?一个现实的阻碍在于,C++ 本身没有运行时的类型反射机制,无法“智能”的对类型进行自动化映射,必须手写转化代码。

这里就必须先介绍一个 C++ 的“元编程库”Boost::Hana:“Hana 是一个元编程库,提供异构容器和算法,用于类型和值的计算。”说人话就是,通过 Hana,我们可以在编译期实现 C++ 版本的类型反射。虽然这是一种编译期的静态反射,比起运行时的动态反射还是要弱一些,但是已经能够极大的帮到我们解决上述的问题。

简言之,Hana 通过 C++ 模板以及宏技术,使得我们能够通过代码来获取 C++ 数据类型的元信息,比如对于前述的 struct Quote 这个结构体,可以通过代码获取它的成员变量的类型以及名称,能做到这一点,就提供了一个不需要手写转化代码的理论基础。再结合 pybind11 以及 sqlite_orm 这样的连接库,我们就能实现一套通用的跨语言数据类型转化机制。具体的技术细节我们不在这里细述,感兴趣的朋友可以参见我们的开源代码:GitHub – kungfu-origin/kungfu最终实现出来的效果,我们用 Python 的数据转化作为例子,具体实现写成如下的形式:

template <typename DataType> void bind_data_type(pybind11::module &m_types, const char *type_name) {
  auto py_class = py::class_<DataType>(m_types, type_name);
  py_class.def(py::init<>());
  py_class.def(py::init<const std::string &>());

  hana::for_each(hana::accessors<DataType>(), [&](auto it) {
    auto name = hana::first(it);
    auto accessor = hana::second(it);
    py_class.def_readwrite(name.c_str(), member_pointer_trait<decltype(accessor)>().pointer());
  });
}

void bind_types(py::module &m) {
  auto m_types = m.def_submodule("types");
  hana::for_each(AllDataTypes, [&](auto pair) {
    using DataType = typename decltype(+hana::second(pair))::type;
    bind_data_type<DataType>(m_types, hana::first(pair).c_str());
  });
}

可以看到和之前不同,代码里完全避免掉了任何显式的类型或者成员变量名称。类似的,我们对 JS、sqlite 可以做出相似的转化代码。例如对于 sqlite ORM,核心转化代码如下:

using DataType = typename decltype(+types[key])::type;
auto data_accessors = boost::hana::accessors<DataType>();

auto columns = boost::hana::transform(data_accessors, [&](auto it) {
    auto name = boost::hana::first(it);
    auto accessor = boost::hana::second(it);
    auto member_pointer = member_pointer_trait<decltype(accessor)>().pointer();
    using MemberType = std::decay_t<decltype(accessor(DataType{}))>;
    return sqlite_orm::make_column(name.c_str(), member_pointer, sqlite_orm::default_value(make_default<MemberType>()));
});

auto pk_members = boost::hana::transform(DataType::primary_keys, [&](auto pk) {
    auto pk_member = boost::hana::find_if(data_accessors, hana::on(boost::hana::equal.to(pk), boost::hana::first));
    [[maybe_unused]] auto accessor = boost::hana::second(*pk_member);
    return member_pointer_trait<decltype(accessor)>().pointer();
});

auto make_primary_keys = [](auto... keys) { return sqlite_orm::primary_key(keys...); };
auto primary_keys = boost::hana::unpack(pk_members, make_primary_keys);

constexpr auto table_maker = [](const std::string &table_name, const auto &primary_keys) {
return [&](auto... columns) { return sqlite_orm::make_table(table_name, columns..., primary_keys); };
};

甚至可以通过一些 Hana 参数来实现对数据标注主键这种非常 SQL 的操作。完全不用在手写各个成员变量的名称,新增或是修改也不用再担心忘了同步。

这样真正的数据类型只需要用 C++ 形式做一次定义,就可以在多语言框架里各处都能使用。结合我们下一章将要讲到的状态机复制的设计,就能做到在多进程架构间,通过不同语言实现的各个独立进程,能够共享一套底层代码来进行状态机管理。具体而言,在功夫代码 GitHub – kungfu-origin/kungfu 里可以看到,所有的计算逻辑都只有一处代码;这也保证了在功夫框架里,不论是策略计算、风控管理还是前端UI,系统保证代码处理的完全一致,是真正的“所见即所得”。

功夫UI上展示的所有信息,和策略中使用的数据来自完全相同的代码

============

要进一步了解功夫的代码和相关文档,请关注功夫核心库:

 

最后附上我们的公众号和微信群,欢迎对高频因子研究感兴趣的朋友关注,加入(微信搜索 功夫量化,关注后可扫码入群 ),对于产品的使用有任何疑问,可以在公众号后台直接回复,功夫小伙伴们会第一时间为你解答: