通协议中的消息
对游戏项目而言,我们通常会使用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定义出来,如下代码所示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
// 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函数来实现编解码,如下代码所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
int main() { LoginReq req; req.user = "user001"; req.token = "pnyuza0h2cdkvxvh54v3dn"; req.unix_timestamp = 1615004452; req.lang = "zh-CN"; req.client_os = "Windows 10"; req.app_version = "1.0.1"; printLoginReq(req); // 打印每个成员 Buffer buf; req.encodeTo(buf); // 编码到buffer // TODO: 把buffer发送到网络 LoginReq req2; int pos = 0; req2.decodeFrom(buf, pos); // 从buffer中解码 cout << "after decode:" << 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里
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// message.proto syntax = "proto3"; // 使用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++代码
1 |
protoc --cpp_out=. message.proto |
下一步就是如何解析protobuf消息,假定我们的网络代码会返回一个包含消息ID和二进制字节流的buffer,业务层根据消息ID把字节流反序列化为具体的protobuf消息结构。
第一步我们从一个switch/case开始,把消息ID和消息结构的对应关系写在源码里,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// 解析消息结构 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和消息结构映射起来,把解码消息的操作通用化,如下代码所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// 使用宏映射消息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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
// 消息名称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, "protocol")) // 指定命名空间 { // 约定消息名称中: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方式一致。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import "google/protobuf/descriptor.proto"; // 定义消息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; // 会话 } |
在初始化消息字典的时候进行注册:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
// 注册消息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, "protocol")) { // 指定命名空间 // 约定消息名称中: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和消息结构的关联字典即可。
发表评论
要发表评论,您必须先登录。