这是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类图
可以看到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脚本的目标了!