首页protobuf › 在游戏协议中使用Protobuf的正确姿势

在游戏协议中使用Protobuf的正确姿势

通协议中的消息

对游戏项目而言,我们通常会使用TCP进行前后端的通信协议开发,TCP是字节流协议,所以还需要在网络代码里把TCP字节流解析成应用层需要的一条一条消息(message)。

一条消息包含消息ID和消息内容(payload)。

消息ID主要用于告知业务代码后续的二进制payload应该解析成什么样的结构,通常为了节省流量,消息ID使用整数表示。

以登陆消息为例,如下所示:

消息ID 消息payload
1001 登录账号、token等
1002 登录状态、访问token等

收发消息流程

在业务层上消息主要有两种类型,一种是请求响应(request/response),也就是client发送一条消息给server,server也需要相应地回一条消息给client。

另一种是通知,通知消息不需要对方回复响应,client和server都可以给对方发送通知。

server和client的收消息流程是:

  • 读取消息字节流,先根据固定长度(比如4字节)解码出消息大小(包括消息ID和消息payload);
  • 读取消息ID后根据ID内容,new一个编程语言里的对应的结构,把消息payload解码到结构里;
  • 把消息结构传递给后续业务代码处理;

server和client的发消息流程是:

  • 业务代码new一个编程语言的消息结构,设置好结构中每个成员的值;
  • 把此消息结构和其对应消息ID编码为字节流;
  • 投递给网络层发送;

自定义消息编解码

以C++语言为例,来看一个简单的不使用常用序列化格式(json, msgpack, protobuf)的通信协议编解码实现,为了聚焦于编解码的内容,下面的代码都不涉及具体的网络层实现。

首先,我们把消息ID用枚举实现,把消息结构用struct定义出来,如下代码所示

我在以上代码中定义了3个消息ID和与其对应的3个消息结构,在发送的时候需要给每个消息结构的成员赋值,然后按一些约定的序列化方法把消息结构编码到字节流,整数是直接编码内存大小,字符串先编码长度再编码内容(不包括’\0’),struct依次编码每个成员,vector和map等容器先编码大小再逐个编码每一个元素。

所以我们再为每个消息结构定义encodeTo/decodeFrom函数来实现编解码,如下代码所示:

上面的简版序列化方式在业务层的使用示例大致如下面的代码所示,完整的示例见gitee

上述代码有几个细节,比如:

  1. 如何方便的添加和删除字段,并保证前向版本的兼容性;
  2. 如何支持其他编程语言方便地反序列化;

对问题1,自定义协议的编解码不支持更改数据类型和增删某些字段。

对问题2,C++的二进制怎么编码,其他语言就得怎么解码,类似python之类的动态语言需要使用二进制解析库。

虽然自定义协议编解码的方案大多都没有完全解决这些问题,但是这种协议的确是早些年很多项目广泛使用的方式。
甚至现在还有很多C#,Go语言项目也会选择这种自定义协议的方式,只是有了反射的支持,跟传统的C++相比会多一点灵活性。

使用protobuf

protobuf是一种序列化结构化数据的形式,protobuf的基本概念和编码格式参考google官方文档,这里不再赘述。

手动解析消息ID和消息结构

下面还是以C++语言为例,展示一下如何使用protobuf实现上文的登录协议,然后分析一下和自定义协议相比的优势。

protobuf要求我们事先按照它的语法把协议定义在proto文件中,然后再使用它的编译器(protoc)把proto文件编译成对应的编程语言代码,在C++里就是pb.cc文件,protoc会在pb.cc里生成每一个消息结构的字段getter/setter和编解码方法,方便我们直接使用。

protobuf有一套自己的基本数据类型的二进制编码规范,以及一个保证前后版本兼容的编解码方案,proto文件除了是定义DSL语法以外,它还很方便多语言之间的通信协作。

先把消息定义在如下message.proto里

使用protoc把proto文件编译成C++代码

下一步就是如何解析protobuf消息,假定我们的网络代码会返回一个包含消息ID和二进制字节流的buffer,业务层根据消息ID把字节流反序列化为具体的protobuf消息结构。

第一步我们从一个switch/case开始,把消息ID和消息结构的对应关系写在源码里,如下所示:

代码逻辑非常简单明了,根据消息ID创建消息对象,然后把指针赋给基类Message*,再解析后续字节流。只是带来了一个明显的缺点就是,随着后面协议的增加,这个switch/case会变得非常冗长。

这个问题在于如何把消息ID和消息结构方便地关联起来,switch/case只是一种关联形式,当拿到一个消息ID地时候可以很自然地用对应地消息结构进行下一步解析,在语言级别我们可以利用一点C++的宏技巧把消息ID和消息结构映射起来,把解码消息的操作通用化,如下代码所示:

完整的示例见gitee

createMessageBy经过宏展开后就跟parseMessageV2中的switch/case一致了,修改协议的时候只需要修改宏定义。

到这里其实代码已经简化了很多了,但是我们每次增加修改删除协议,除了修改message.proto,还需要修改这个C++宏,也就是源码层面要做两次修改,而且要保持一致性,有没有办法只修改一次,也就是只用修改message.proto,代码就能自动识别?

答案是有的,需要用到protobuf的反射支持。

使用protobuf的反射支持

protobuf的反射使用Descriptor对象来表示,proto文件有FileDescriptor,消息有MessageDescriptor,消息的字段有FieldDescriptor。用Descriptor对象我们能读取到所有消息结构的定义信息,包括类型、名称、包含字段等等。

上面有提到,这个代码简化的核心其实是如何把消息ID和消息结构关联起来,上述代码是通过在代码里手写switch/case来实现,现在有了反射,protobuf支持通过消息名字查询到Descriptor对象,并可以通过Descriptor对象创建消息结构对象,那我们要做的就是把消息ID和消息名字关联起来。

一个很自然的想法就是通过字符串hash(比如crc32/fnv),消息ID就是消息名称的hash值,在程序的启动阶段,遍历所有消息对象拿到所有消息的名称,把消息名称的hash和对应的Descriptor对象关联起来,比如放到字典中。

这样从网络层读取到消息ID(也就是消息名字hash)的时候,用这个hash去关联字典查找到Descriptor对象,再通过Descriptor对象生成消息结构,有了消息结构就可以做消息解析了。

大致实现代码如下,完整的示例见gitee

在程序启动阶段调用initProtoRegistry()初始化所有消息ID和消息结构关联的字典,当从网络层读取到消息ID和消息
结构字节流的时候,把消息ID作为参数调用createMessage()返回消息结构对象,然后再使用protobuf内置的ParseFromArray()方法解析消息字节流到消息结构中。

到此,整个通信协议的开发流程已经非常简化了,大部分项目能做到这一步也已经是很不错了。

对于上面的方案,主要是有几个不尽人意的地方:

  1. 消息名称不能随便修改,因为改动了消息ID(也就是名称的hash)就会变化,会影响到兼容性;
  2. 消息ID不能指定范围,比如我做了一个系统,希望接受的消息ID在范围1000-10000之间,不在此区间的就直接丢弃;
  3. 在调试的时候,收到一个消息ID,它通常很大,我们很难在肉眼层面去debug它是不是一个合理的消息ID值;

所以还有第二个选择,使用protobuf的MessageOption。

我们在定义proto文件的时候,还是使用枚举作为消息ID,这样我们可以控制消息ID的范围,然后我们再使用MessageOption手动给每个Message对象指定消息ID,在注册消息ID的关联字典的时候,把MessageOption里指定的消息ID与消息Descriptor关联,其它都与上面的使用消息名称的hash方式一致。

在初始化消息字典的时候进行注册:

使用MessageOption后序列化代码已经非常简化了,只需要在启动代码里调用initProtoRegistryV2()注册消息ID和消息结构的关联字典即可。

参考链接:在游戏协议中使用Protobuf的正确姿势

发表评论