如何用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定义出来,如下代码所示

// 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);
}

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

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. 如何支持其他编程语言方便地反序列化;

对问题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_DISCONNECT_NOTIFY = 1000;   // 下线通知
    MSG_LOGIN_REQUEST = 1001;       // 登录请求
    MSG_LOGIN_REPLY = 1002;         // 登录响应
}

// 下线通知
message DisconnectNotify {
    int32 err_code = 1;     // 错误码
    string reason = 2;      // 原因(重复登录或者被踢下线)
}

// 登录请求
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_DISCONNECT_NOTIFY:
        msg = new DisconnectNotify(); // 如果消息ID是1000,则创建DisconnectNotify对象
        break;
    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_DISCONNECT_NOTIFY, DisconnectNotify) \
    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;
}

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对象生成消息结构,有了消息结构就可以做消息解析了。

大致实现代码如下

// 消息名称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()方法解析消息字节流到消息结构中。

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

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

  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方式一致。

import "google/protobuf/descriptor.proto";

// 定义消息ID的option
extend google.protobuf.MessageOptions { MessageID MsgID = 50002; }

// 消息ID枚举
enum MessageID {
    MSG_NONE = 0;
    MSG_DISCONNECT_NOTIFY = 1000;
    MSG_LOGIN_REQUEST = 1001;
    MSG_LOGIN_REPLY = 1002;
}

// 下线通知
message DisconnectNtf {
    option (MsgID) = MSG_DISCONNECT_NOTIFY;
    
    int32 err_code = 1;     // 错误码
    string reason = 2;      // 原因(重复登录或者被踢下线)
}

// 登录请求
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和消息结构的关联字典即可。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 193,968评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,682评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,254评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,074评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,964评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,055评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,484评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,170评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,433评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,512评论 2 308
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,296评论 1 325
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,184评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,545评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,150评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,437评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,630评论 2 335

推荐阅读更多精彩内容