基于protocol buffer和Lua的一种动态消息实现

这是13年做项目的时候,实现的一个技术方案,很早就想拿出来分享了。鉴于后来在网上搜索到的其他的一些protocol buffer的Lua化方案,个人感觉也不尽如人意,用起来颇不遍历,因此今天就将之前的实现方案分享出来。不足的地方,希望大家多多指教。
Protocol Buffer是Google开源的一个他们自己内部RPC和数据存储的一种格式。在消息协议处理上有很多优势。主要是数据存储速度快,消耗资源少。具体的protocol buffer的数据存储原理还有和类似xml,json的性能对比之类的,网上也有很多文章了,在这里我就不详细写了。可以参考这里这里。
由于protocol buffer在性能上的优势和消息上的便利性,也常常被用于游戏开发中,常见作为前后端交互的消息格式。

基于Lua的协议设计目标

为了便于在手机上进行更新,不少游戏采用脚本化来实现,Lua就是其中首选。那么,实现protocol buffer的脚本化目标,当然是要能够达到——协议描述文件的任何修改,都无需重新编译C++,脚本可以直接获取这种变化
这样的Lua化才有意义,否则如果每次修改协议文件都需要重新编译一些东西,那岂不是太麻烦了,而且也没有达到我们的目标。
类似的,假设游戏上线之初,我们定义登录消息格式如下

message Login {
    required string username = 1;
    required string password = 2;
};

游戏运营了一段时间,也有了一定的用户量了。这时候,可能我们在用户登录的时候,需要附加一些其他的信息,来为用户提供更好的服务,修改Login消息如下

message Login {
    required string username = 1;
    required string password = 2;
    optional string appendinfo = 3; // 此为新增数据
};

那么问题就来了。

协议描述文件的更新

首先我们第一个要实现的目标是支持动态加载编译协议文件。也就是当客户端的app启动之后,检测到描述文件发生修改,更新至本地之后,app需要即时的加载新的描述文件,并使用新的描述文件来序列化/反序列化消息数据。

客户端app无需重新编译

第二个目标是显而易见的,如果已经支持动态的更新使用新的协议描述文件了,那么无需重新编译也是理所当然的。
目的就是Lua在序列化或者反序列化这些消息的时候,只需向字段里新增appendinfo的内容就可以了,而无需重新编译C++接口。

设计思路和实现

当年做这块的时候,很想找到一些现成的方案来解决,但是发现都不能满足这个要求。于是一发狠,就研究了protocol buffer的相关文档。发现本身pb就有很好的反射机制,略加处理就能满足我们的需求了。最终自己动手实现了一个能满足需求的小玩意儿。

protocol buffer相关原理

要实现这两个目标,有一些原理性的东西还是得讲一讲。
首先就是protocol buffer动态编译相关的一些东西。Protocol buffer主要是通过google::protobuf::compiler::Importer这个类来实现对未知的proto描述文件进行动态编译的。相关还涉及了google::protobuf::compiler::MultiFileErrorCollector类(用于动态编译时搜集描述文件的语法错误,如果存在的话),google::protobuf::compiler::SourceTree类(用于缓存已加载的描述文件)。说明一下,其实SourceTree这个类并不是必须的,但是由于考虑客户端安全性的问题,因此proto协议描述文件是进行了加密的,为了处理这种情况,所以需要用我们派生的SourceTree来AddFile做缓存,做个小小的中间层。
基本原理就是,使用Importer对象的import方法,就可以动态编译一个proto描述文件了。类似如下代码:

google::protobuf::compiler::MultiFileErrorCollector error_collector_;
google::protobuf::compiler::SourceTree source_tree_;

google::protobuf::compiler::Importer importer_(&source_tree_, &error_collector_);
importer_.Import(filename);

到这一步,如果成功的话,此时我们已经动态编译了这个proto文件了,这个描述文件中所定义的类的一些信息已经被缓存起来了。
那么下一步如何获取到具体的消息呢?
先放上一张具体的消息对应的protocol buffer类图


基于protocol buffer和Lua的一种动态消息实现_第1张图片
protocol buffer对应相关类

可以看到Descriptor正对应着具体一个message的描述。问题转化为如何根据message的名称获得这个Descriptor。答案是impoter的Descriptor Pool的FindMessageTypeByName方法。

const google::protobuf::Descriptor *desc_ = importer_.pool()->FindMessageTypeByName(msgname);

轻轻松松,这一句就可以获得这个具体message的Descriptor了。再接下来,就是通过Descriptor来创建一个Message实例了。作为本身就具备很强反射机制的protocol buffer,本身就提供了MessageFactory类,支持使用Descriptor来创建消息原型。如下:

google::protobuf::DynamicMessageFactory factory_;
const google::protobuf::Message *proto_type_ = factory_.GetPrototype(desc_);

这样就可以获得一个消息原型了(如果成功的话)。接着使用这个proto_type_来new一个Message,这个Message就是我们想要的动态消息了。至此,工作已经大致完成了1/3了。

if (proto_type_) {
    google::protobuf::Message *dynamic_msg_ = proto_type_->New();
    dynamic_msg_->ParseFromString(data); // 接着我们就可以反序列化二进制数据了
}

没有错误的话,我们获得的dynamic_msg_就包含了我们想要的关于指定message的具体信息。
下面的工作就是如何传递给Lua呢?Lua table是一个很不错的选择。

protocol buffer到Lua table

Lua table天然的设计简直就是传递消息的利器。将message结构转换为lua table实在是再合适不过了(不过如果您有更好的方案可以和我交流)。
大致目标如下:

// 用之前的Login举例,反序列化之后,Lua应该收到这样一个table
t = {
    username = 'xxx',
    password = 'xxx',
}

OK,第一步我们需要知道传递给Lua哪些key-value。祭出反射大杀器Reflection类。每个Message都可以获取到自身的Reflection。再根据这个Reflection我们可以List出所有的Field。然后遍历这些Field做Lua压表key-value的操作。

const google::protobuf::Reflection  *ref_ = msg_->GetReflection();
std::vector< const google::protobuf::FieldDescriptor* > fields_;
ref_->ListFields(*msg_, &fields_); // 存储反序列化之后所有存在的key(如果是optional的话,有可能是不存在的哦)

接着遍历这些存在的数据的key,开始压表

for (unsigned int i = 0; i < fields_.size(); ++i) {
    const google::protobuf::FieldDescriptor *field_ = fields_[i];
    lua_pushstring(tolua_S, field_->name().c_str()); // 首先压入key
    // 接着压表
}

这里需要特殊处理一下的是repeated的这种字段。可能相同的key会存在多项。这种情况下怎么传递给Lua呢?因为Lua的key-value是唯一的。方法也很简单,对这种repeated类型的再压一个table,变成table嵌套table就可以了。

if (field_->is_repeated()) {
    lua_newtable(tolua_S);
    // 递归处理
    GetProto(...);
} else {
    GetProto(...);
}

GetProto函数是自定义的,功能很简单,根据Field的value的数据类型进行数据压栈就好了。

void GetProto(lua_State *tolua_S, google::protobuf::Message *msg_, const google::protobuf::FieldDescriptor* field_) {
    if (field_->is_repeated()) {
        // 单独处理下repeated类型的key的问题
    }
    const google::protobuf::Reflection  *ref_ = msg_->GetReflection();
    // 根据type类型set lua table的key-value
    switch (field_->type()) {
        ...
    }
}

如果是message嵌套message的情况呢?一样也能使用protocol buffer提供的接口轻松处理。
序列化的原理和处理思路基本上是一样的。我们只需要根据file和message创建一个动态Message对象,然后,遍历Lua table的key值,然后根据table的key和value填充刚才New出来的动态Message对象,在调用SerializeToString序列化一下就可以了。
譬如,类似上文说的Login消息。脚本如要传递如下消息给服务器,username值为"test",password值为"test123",那么只需要传递

local t = {
    username = "test",
    password = "test123",
}

这样一个table给C++接口作为参数就可以了。未来有新增的消息,比如之前说的appendinfo,传递的table只需变成

local t = {
    username = "test",
    password = "test123",
    appendinfo = "append",
}

就可以了。而C++层无需重新编译。
其中有些细节还需要注意一下,类似required类型的消息,C++是需要判断脚本table是否包含了required,应给应用层较好的提示信息,容错处理要稍微注意。
之后,空一点我会上传代码。至此,已经实现我们最初的动态编译protocol buffer到Lua脚本的目标了!

你可能感兴趣的:(基于protocol buffer和Lua的一种动态消息实现)