|
通协议中的消息
对游戏项目而言,我们通常会使用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定义出来,如下代码所示
// protocol.h
// 消息ID枚举
enum MessageID
{
MSG_DISCONNECT_NOTIFY = 1000, // 下线通知
MSG_LOGIN_REQUEST = 1001, // 登录请求
MSG_LOGIN_REPLY = 1002, // 登录响应
};
// 下线通知
struct DisconnectNotify
{
int32_t err_code; // 错误码
string reason; // 原因(重复登录或者被踢下线)
};
// 登录请求
struct LoginReq
{
string user; // 账号
string token; // 令牌
int64_t unix_time; // 时间戳
string lang; // 区域和语言
string client_os; // iOS, Android, Web
string app_version; // 客户端版本
};
// 登录响应
struct LoginAck
{
int32_t err_code; // 错误码
string access_token; // 访问令牌
int32_t session; // 会话
};我在以上代码中定义了3个消息ID和与其对应的3个消息结构,在发送的时候需要给每个消息结构的成员赋值,然后按一些约定的序列化方法把消息结构编码到字节流,整数是直接编码内存大小,字符串先编码长度再编码内容(不包括’\0’),struct依次编码每个成员,vector和map等容器先编码大小再逐个编码每一个元素。
所以我们再为每个消息结构定义encodeTo/decodeFrom函数来实现编解码,如下代码所示:
// protocol.cpp
typedef std::vector<char> Buffer;
// 编码LoginReq到buffer
void LoginReq::encodeTo(Buffer& buf)
{
encodeString(this->user, buf);
encodeString(this->token, buf);
encodeNumber(this->unix_time, buf);
encodeString(this->lang, buf);
encodeString(this->client_os, buf);
encodeString(this->app_version, buf);
}
// 从buffer中解码LoginReq
void LoginReq::decodeFrom(Buffer& buf, int& pos)
{
decodeString(this->user, buf, pos);
decodeString(this->token, buf, pos);
decodeNumber(&this->unix_time, buf, pos);
decodeString(this->lang, buf, pos);
decodeString(this->client_os, buf, pos);
decodeString(this->app_version, buf, pos);
}
上面的简版序列化方式在业务层的使用示例大致如下面的代码所示,完整的示例见gitee
int main()
{
LoginReq req;
req.user = &#34;user001&#34;;
req.token = &#34;pnyuza0h2cdkvxvh54v3dn&#34;;
req.unix_timestamp = 1615004452;
req.lang = &#34;zh-CN&#34;;
req.client_os = &#34;Windows 10&#34;;
req.app_version = &#34;1.0.1&#34;;
printLoginReq(req); // 打印每个成员
Buffer buf;
req.encodeTo(buf); // 编码到buffer
// TODO: 把buffer发送到网络
LoginReq req2;
int pos = 0;
req2.decodeFrom(buf, pos); // 从buffer中解码
cout << &#34;after decode:&#34; << endl;
printLoginReq(req2);
return 0;
}上述代码有几个细节,比如:
- 如何方便的添加和删除字段,并保证前向版本的兼容性;
- 如何支持其他编程语言方便地反序列化;
对问题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里
// message.proto
syntax = &#34;proto3&#34;; // 使用protobuf3语法
package protocol; // 命名空间
// 消息ID枚举
enum MessageID {
MSG_NONE = 0;
MSG_LOGIN_REQUEST = 1001; // 登录请求
MSG_LOGIN_REPLY = 1002; // 登录响应
}
// 登录请求
message LoginReq {
string user = 1; // 账号
string token = 2; // 令牌
int64 timestamp = 3; // 时间戳
string language = 4; // 区域和语言
string client_os = 5; // iOS, Android, Web
string device_type = 6; // Windows, Android, iOS
string app_version = 7; // 客户端版本号
}
message LoginAck {
int32 err_code = 1; // 错误码
string access_token = 2; // 访问令牌
int32 session = 3; // 会话
}使用protoc把proto文件编译成C++代码
protoc --cpp_out=. message.proto
下一步就是如何解析protobuf消息,假定我们的网络代码会返回一个包含消息ID和二进制字节流的buffer,业务层根据消息ID把字节流反序列化为具体的protobuf消息结构。
第一步我们从一个switch/case开始,把消息ID和消息结构的对应关系写在源码里,如下所示:
// 解析消息结构
Message* parseMessageV1(MessageID msgid, Buffer& buf)
{
Message* msg = nullptr;
switch (msgid)
{
case MSG_LOGIN_REQUEST:
msg = new LoginReq(); // 如果消息ID是1001,则创建LoginReq对象
break;
case MSG_LOGIN_REPLY:
msg = new LoginAck(); // 如果消息ID是1002,则创建LoginAck对象
break;
default:
return nullptr;
}
// 使用protobuf提供ParseFromArray方法解码消息
if (msg->ParseFromArray(buf.data(), (int)buf.size()))
{
return msg;
}
delete msg;
return nullptr;
}代码逻辑非常简单明了,根据消息ID创建消息对象,然后把指针赋给基类Message*,再解析后续字节流。只是带来了一个明显的缺点就是,随着后面协议的增加,这个switch/case会变得非常冗长。
这个问题在于如何把消息ID和消息结构方便地关联起来,switch/case只是一种关联形式,当拿到一个消息ID地时候可以很自然地用对应地消息结构进行下一步解析,在语言级别我们可以利用一点C++的宏技巧把消息ID和消息结构映射起来,把解码消息的操作通用化,如下代码所示:
// 使用宏映射消息ID和消息名称
#define GEN_MESSAGE_MAP(XX) \
XX(MSG_LOGIN_REQUEST, LoginReq) \
XX(MSG_LOGIN_REPLY, LoginAck)
// 根据消息ID创建一个具体的消息对象
// 因为protobuf所有的message都会继承自protobuf::Message基类,所以可以返回基类指针
Message* createMessageBy(MessageID msgid)
{
// 这个switch/case经过宏展开以后跟上面的switch/case其实是一样的
switch (msgid)
{
#define XX(msgid, msgname) case msgid: return new msgname;
GEN_MESSAGE_MAP(XX)
#undef XX
};
return nullptr;
}
// 解析消息结构
Message* parseMessageV2(MessageID msgid, Buffer& buf)
{
auto msg = createMessageBy(msgid);
if (msg != nullptr)
{
if (msg->ParseFromArray(buf.data(), (int)buf.size()))
{
return msg;
}
delete msg;
}
return nullptr;
}完整的示例见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
// 消息名称hash和消息descriptor的映射
static std::unordered_map<uint32_t, const Descriptor*> registry;
// 初始化关联字典
void initProtoRegistryV1()
{
const DescriptorPool* pool = DescriptorPool::generated_pool();
DescriptorDatabase* db = pool->internal_generated_database();
if (db == nullptr) {
return;
}
std::vector<std::string> file_names;
db->FindAllFileNames(&file_names); // 遍历得到所有proto文件名
for (const std::string& filename : file_names)
{
const FileDescriptor* fileDescriptor = DescriptorPool::generated_pool()->FindFileByName(filename);
if (fileDescriptor == nullptr)
{
continue;
}
int msgcount = fileDescriptor->message_type_count();
for (int i = 0; i < msgcount; i++)
{
const Descriptor* descriptor = fileDescriptor->message_type(i);
if (descriptor != nullptr)
{
const std::string& name = descriptor->full_name();
if (startsWith(name, &#34;protocol&#34;)) // 指定命名空间
{
// 约定消息名称中:Req结尾代表请求, Ack结尾代表响应,Ntf结尾代表通知
// 则含有指定后缀的消息才会自动加入关联
if (hasSuffix(name)) {
uint32_t hash = fnvHash(name);
registry[hash] = descriptor;
}
}
}
}
}
}
// 通过hash找到descriptor指针,再用descriptor指针创建具体的消息对象
google::protobuf::Message* createMessage(uint32_t hash)
{
auto iter = registry.find(hash);
if (iter == registry.end())
{
return nullptr;
}
const Message* protoType = MessageFactory::generated_factory()->GetPrototype(iter->second);
if (protoType != nullptr)
{
return protoType->New();
}
return nullptr;
}在程序启动阶段调用initProtoRegistry()初始化所有消息ID和消息结构关联的字典,当从网络层读取到消息ID和消息
结构字节流的时候,把消息ID作为参数调用createMessage()返回消息结构对象,然后再使用protobuf内置的ParseFromArray()方法解析消息字节流到消息结构中。
到此,整个通信协议的开发流程已经非常简化了,大部分项目能做到这一步也已经是很不错了。
对于上面的方案,主要是有几个不尽人意的地方:
- 消息名称不能随便修改,因为改动了消息ID(也就是名称的hash)就会变化,会影响到兼容性;
- 消息ID不能指定范围,比如我做了一个系统,希望接受的消息ID在范围1000-10000之间,不在此区间的就直接丢弃;
- 在调试的时候,收到一个消息ID,它通常很大,我们很难在肉眼层面去debug它是不是一个合理的消息ID值;
所以还有第二个选择,使用protobuf的MessageOption。
我们在定义proto文件的时候,还是使用枚举作为消息ID,这样我们可以控制消息ID的范围,然后我们再使用MessageOption手动给每个Message对象指定消息ID,在注册消息ID的关联字典的时候,把MessageOption里指定的消息ID与消息Descriptor关联,其它都与上面的使用消息名称的hash方式一致。
import &#34;google/protobuf/descriptor.proto&#34;;
// 定义消息ID的option
extend google.protobuf.MessageOptions { MessageID MsgID = 50002; }
// 消息ID枚举
enum MessageID {
MSG_NONE = 0;
MSG_LOGIN_REQUEST = 1001;
MSG_LOGIN_REPLY = 1002;
}
// 登录请求
message LoginReq {
option (MsgID) = MSG_LOGIN_REQUEST;
string user = 1; // 账号
string token = 2; // 令牌
int64 unix_time = 3; // 时间戳
string language = 4; // 区域和语言
string client_os = 5; // iOS, Android, Web
string device_type = 6; // Windows, Android, iOS
string app_version = 7; // 客户端版本号
}
// 登录返回
message LoginAck {
option (MsgID) = MSG_LOGIN_REPLY;
int32 err_code = 1; // 错误码
string access_token = 2; // 访问令牌
int32 session = 3; // 会话
}在初始化消息字典的时候进行注册:
// 注册消息ID关联字典
void initProtoRegistryV2()
{
const DescriptorPool* pool = DescriptorPool::generated_pool();
DescriptorDatabase* db = pool->internal_generated_database();
if (db == nullptr) {
return;
}
std::vector<std::string> file_names;
db->FindAllFileNames(&file_names); // 遍历得到所有proto文件名
for (const std::string& filename : file_names)
{
const FileDescriptor* fileDescriptor = pool->FindFileByName(filename);
if (fileDescriptor == nullptr)
{
continue;
}
int msgcount = fileDescriptor->message_type_count();
for (int i = 0; i < msgcount; i++)
{
const Descriptor* descriptor = fileDescriptor->message_type(i);
if (descriptor != nullptr)
{
const std::string& name = descriptor->full_name();
if (startsWith(name, &#34;protocol&#34;)) { // 指定命名空间
// 约定消息名称中:Req结尾代表请求, Ack结尾代表响应,Ntf结尾代表通知
// 则含有指定后缀的消息才会自动加入关联
if (hasSuffix(name)) {
auto opts = descriptor->options();
protocol::MessageID v = opts.GetExtension(protocol::MsgID);
registry[v] = descriptor;
}
}
}
}
}
}
// 根据ID创建消息结构还是一样
google::protobuf::Message* createMessageV2(protocol::MessageID msgId)
{
auto iter = registry2.find(msgId);
if (iter == registry2.end())
{
return nullptr;
}
const Message* protoType = MessageFactory::generated_factory()->GetPrototype(iter->second);
if (protoType != nullptr)
{
return protoType->New();
}
return nullptr;
}使用MessageOption后序列化代码已经非常简化了,只需要在启动代码里调用initProtoRegistryV2()注册消息ID和消息结构的关联字典即可。
参考链接:protobuf在游戏通信协议中的使用 |
|