找回密码
 立即注册
查看: 222|回复: 0

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

[复制链接]
发表于 2022-5-27 09:20 | 显示全部楼层 |阅读模式
通协议中的消息
对游戏项目而言,我们通常会使用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 = "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里
// 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++代码
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, "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方式一致。
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;        // 会话
}在初始化消息字典的时候进行注册:
// 注册消息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和消息结构的关联字典即可。
参考链接:protobuf在游戏通信协议中的使用
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-27 02:46 , Processed in 0.149128 second(s), 25 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表