Protocol Buffers简介

这个玩意就是Google定义的一种序列化的协议格式,具有语言无关性、平台无关性、可扩展性等优点,类似于XML或Json,下面简称为Protobuf。

Protbuf的适用场景

先看个例子

一个角色对象拥有2个属性,Id和name

如果使用XML来表示

<role>
    <id>1</id>
    <name>小流氓</name>
</role>

如果用Json来表示

{"id":1; "name":"小流氓"}

Protobuf

08 01 12 09 E5 B0 8F E6 B5 81 E6 B0 93

要说哪个最好,我认为这个没得比,他们不是一个起点,XML和Json是基于文本,Protobuf是基于二进制

关注一下我们所关心的点,Protobuf 支持多种数据类型,如list,支持嵌套,体积最小,解析速度快,可读性差,所以用他来做协议最好不过了.

我们可以定义自己的数据结构,然后使用代码生成器生成的代码来读写这个数据结构。

当然,Protobuf并不是在任何时候都比XML更合适。

例如 策划数据,对策划数据的大小没有太苛刻的要求时,还有XML比较合适,他是自解释的,可读性好,便于排错等优点,而Protobuf仅在拥有 .proto 文件时才有意义。

个人认为适用protobuf的场合:

  1. 多语言的项目,因为Protobuf与语言无关。定义好描述文件,可以生成对应的代码,可以减少连调的成本。
  2. 通信协议,对消息大小很敏感的。那么Protobuf就适合了,消息空间相对xml和json等节省很多。
  3. 存取数据,需要序列化操作时,如果你想新的数据结构向后兼容,而你的旧数据可以向前兼容,而且Protobuf的序列化和反序列化的效率非常高。


网络游戏之通信协议应用

以前网络通信协议的存在的问题

先来看个QQ2010的通信协议:

02 1E 0D 00 91 5B AB 90 D8 85 0A 02 00 00 00 01
01 01 00 00 64 1F 5C 6E 23 B6 95 1E 43 08 CC 35
0A CE 6C C2 5E 25 A0 3A 1A AC 54 20 4C 4D 02 88
BC D2 06 6B 17 6F FF F0 6C B4 46 8A 9A 82 C6 17
8E C8 78 FC 1F B2 BA B7 BE C6 F0 32 FF AE 50 FF
E1 EF 2A 59 B8 36 03

Header: 02     //Protocol Start Flag
        1E 0D   //Client Version
        00 91   //Opcode
        5B AB   //自增长的Id
90 D8 85 0A //QQ号
        02 00 00 00 01 01 01 00 00 64 1F   //Unknown filling, 1F differs among versions
Body:   5C 6E 23 B6 95 1E 43 08 CC 35 0A CE 6C C2 5E 25 //128bit Key of TEA
        A0 3A 1A AC 54 20 4C 4D 02 88 BC D2 06 6B 17 6F 
        FF F0 6C B4 46 8A 9A 82 C6 17 8E C8 78 FC 1F B2 
        BA B7 BE C6 F0 32 FF AE 50 FF E1 EF 2A 59 B8 36 //48bytes Encrypted Code of TEA
Tail:   03      //Protocol End Flag

大体可以看出此协议是按约定的位来解析的,客户端发给服务器的协议中有版本号,操作码,自增长Id,内容等

为了兼容以前的版本,当其中的占位符不够时,可能会有IF的写法,如:

if (version == 1) {
//TODO
}else if (version == 2) {
 //TODO
}

通信协议因此变得越来越复杂,因为开发者必须确保,发出请求的人和接受请求的人必须同时兼容,并且在一方开始使用新协议时,另外一方也要可以接受。

Protobuf设计可用于解决这一类问题:
可以自行修改描述文件,并且可以在多种语言中使用
optional可选属性,很方便添加新属性,兼容以前的属性,默认值可以替代新属性初始值。

定义游戏网关的协议格式

			GatewayMessage协议              RpcMessage协议
Client ------------------------> Gateway -----------------> Logic

Client <----------------------  Gateway <------------------- Logic
			GatewayMessage协议              RpcMessage协议

首先定义一个 msg.proto 文件很简单,内容如下:

package com.ztgame.noark.protobuf.msg;

message GatewayMessage {
    required int32  opcode      = 1;
	optional int32  counter     = 2;//计数器(包id自增校验,可以解决WPE)
	optional string checkCode   = 3;//包校验码可以消灭包拦截篡改
	optional int32  codec       = 4;//(codec >> index) & 1 第一位为是否加密 第二位为是否压缩
	required bytes  context     = 5;//内容
}

编译文件:

protoc.exe --java_out=src/main/java --proto_path=src/main/java/com/ztgame/noark/protobuf/msg src/main/java/com/ztgame/noark/protobuf/msg/msg.proto

生成Java类文件

GatewayMessage部分代码
    // required int32 opcode = 1;
    boolean hasOpcode();
    int getOpcode();
    
    // optional int32 counter = 2;
    boolean hasCounter();
    int getCounter();
    
    // optional string checkCode = 3;
    boolean hasCheckCode();
    String getCheckCode();
    
    // optional int32 codec = 4;
    boolean hasCodec();
    int getCodec();
    
    // required bytes context = 5;
    boolean hasContext();

客户端发给网关时的数据如下所示:包长+上面我们所定义的协议内容

 BEFORE DECODE (302 bytes)       AFTER DECODE (300 bytes)
 +--------+---------------+      +---------------+
 | Length | Protobuf Data |----->| Protobuf Data |
 | 0xAC02 |  (300 bytes)  |      |  (300 bytes)  |
 +--------+---------------+      +---------------+

网关服务器处理过程

client连接到gateway,发送封包数据。

gateway收到 包长度 + GatewayMessage数据。

取出长度解析GatewayMessage对象

分析Opcode 对包频率控制可以消灭变速齿轮

如果存在counter值,校验包计数器的自增长值,可以消灭WPE

如果存在checkCode值,校验校验码可以消灭包拦截篡改

如果存在codec值,进行运算(codec >> index) & 1 第一位为是否加密 第二位为是否压缩

解析成功,将解密后的内容直接转发相对应的是逻辑服务器
Opcode >> 12 如果为0 则转给逻辑服务器 1转给Session等

主要用于保持和Client的连接,转发游戏封包

网关主要有以下用途:

  • 1:分担了网络IO资源

  • 2:同时,也分担了网络消息包的加解密,压缩解压等CPU密集的操作。

  • 3:隔离了client和内部服务器组,对client来说,它只需要知道网关的相关信息即可(IP和port)。

  • 4:client由于一直和网关保持常连接,所以切换场景服务器等操作对client来说是透明的。

  • 5:维护玩家登录状态。


逻辑服务器处理过程

Logic收到网关转发的消息为服务器之间的异步RPC消息,此消息也是Protobuf协议。

msg.proto文件中添加

package com.ztgame.noark.protobuf.msg;

message RpcMessage {
	required int32 opcode = 1;
	optional int64 roleId = 2;//如果网关已绑定此操作者的角色Id
	optional int64 sessionId = 3;//没有登录前,由SessionId来维护
	required bytes context = 4;//内容
}

编译文件:

protoc.exe --java_out=src/main/java --proto_path=src/main/java/com/ztgame/noark/protobuf/msg src/main/java/com/ztgame/noark/protobuf/msg/msg.proto

生成Java类文件

    // required int32 opcode = 1;
    boolean hasOpcode();
    int getOpcode();
    
    // optional int64 roleId = 2;
    boolean hasRoleId();
    long getRoleId();
    
    // optional int64 sessionId = 3;
    boolean hasSessionId();
    long getSessionId();
    
    // required bytes context = 4;
    boolean hasContext();

逻辑服务器解析出Rpc转发过来的协议取出此内容,按Opcode解析成相对应的协议

message C_Login_Packet {
	required string username = 1;
	required string password = 2;
}

C_Login_Packet 部分代码
    // required string username = 1;
    boolean hasUsername();
    String getUsername();
    
    // required string password = 2;
    boolean hasPassword();
    String getPassword();

分析Opcode可使用Protobuf所生成的Java的API读取客户端传来的数据

    byte[] data = Rpc协议里的context;
    C_Login_Packet packet = C_Login_Packet.parseFrom(data);
    String username = packet.getUsername();
    String password = packet.getPassword();
Required是必须的

在用required修饰符时一定要谨慎,一但被required修饰,该值就必须要进行传递,在版本升级或兼容时可能存在问题;

每个字段属性都有唯一的标识(id) (1-2^29)

:不可以使用其中的[19000-19999]的标识号, Protobuf协议实现中对这些进行了预留。

变量采用的是可变长的编码方式

[1,15]之内的标识号在编码的时候会占用一个字节。

[16,2047]之内的标识号则占用2个字节。应该为那些频繁出现的消息元素保留[1,15]之内的标识号。

Optional是可选项
[default = value] -> 为该属性设置一个默认值 (默认值是不需编码的)
如:optional int32 gender = 2[default = true];
[deprecated =false/true]->标识该域是否已经被弃用
如:optional int32 old_field = 6[deprecated=true];
Required 数组
required bytes context = 4;//内容
[packed =false / true]->采用更紧凑的编码方式
如:repeated int32 test = 4[packed=true];

模拟上面客户端所发的封包

//申明一个登录协议,有一个用户名和一个密码
C_Login_Packet.Builder login = C_Login_Packet.newBuilder();
login.setUsername("username").setPassword("password");


//发送时由此包装,假如登录协议的Opcode为0x01
//这里没有设计加密和压缩等校验属性,由此可体验Protobuf的可选属性的优点
GatewayMessage.Builder msg = GatewayMessage.newBuilder();
msg.setOpcode(0x01);
msg.setContext(login.build().toByteString());
//最后真正要发的数据
byte[] bs = msg.build().toByteArray();

封包数据分析

使用封包拦截工具可以获得如下数据

08 01 2A 14 0A 08 75 73 65 72 6E 61 6D 65 12 08 70 61 73 73 77 6F 72 64
属性字段计算方式 (field_number << 3) | wire_type

分析如下:

08 //网关消息中的第一个field_number
01 //opcode值
2A //网关消息中的内容field_number
14 //内容是数组,所以这里是数组长度,注意这里的14是十六进制的,所以是20个长度
0A //第一个field_number
08 //属性长度
75 73 65 72 6E 61 6D 65 //username
12 //第二个field_number 
08 //属性长度
70 61 73 73 77 6F 72 64//password

由此可以看出网关其实只解析了部分数据,所以他的优势就出来了

封装好包装类库,以后的开发只需关注业务逻辑协议,此协议可以自定义:比如现在需要一个创建角色协议

message C_Create_Role_Packet {
	required string name = 1;
}
message S_Create_Role_Packet {
	required int32 retCode = 1;
}

如果现在需求改了,创建角色时,需要选择性别
只需要添加一行代码 
message C_Create_Role_Packet {
	required string name = 1;
	optional bool gender = 2 [default = true];
}
message S_Create_Role_Packet {
	required int32 retCode = 1;
}

此协议可兼容以前的协议,

  • 如果客户端和服务器协议都是新的 或都是 旧的,没有问题
  • 如果客户端协议为新的,服务器为旧的,此时创建角色时,服务器会按以前的处理
  • 如果客户端协议为旧的,服务器为新的,此时创建角色的性别会是新协议的默认值

注意:
在使用Protobuf定义协议时,一定要注意message的兼容性是首要的,只有在非常必要时,才使用required关键词;

建议:
对于常用的值可以选择域为1-15的标识号,根据可能出现的期望值选择合适的数据类型;如果明确是数字,就不要用String,如果明确是有负数的,可以考虑sint32或sint64
如果已发布版本后,可能会想要改进Protobuf的定义。如果你想新的数据结构向后兼容,而你的旧数据可以向前兼容,那么你还是需要遵守有些规则。

在新版本的Protobuf中:
  • 必须不可以改变已经存在的属性标识Id。
  • 必须不可以增加或删除(required)属性。
  • 可以删除可选(optional)或重复(repeated) 属性。
  • 可以添加新的可选或重复字段,但是必须使用新的标签数字,必须是之前的字段所没有用过的。

如果你遵从这些规则,旧代码会很容易的读取新的消息,并简单的忽略新加的字段。而对旧的被删除的可选字段也会简单的使用他们的缺省值,被删除的重复字段会自动为空。新的代码也会透明的读取旧的消息。然而,需要注意的是新的可选消息不会在旧的消息中显示,所以你需要使用 has_ 严格的检查他们是否存在,或者在 .proto 文件中提供一个缺省值。如果没有缺省值,就会有一个类型相关的默认缺省值:对于字符串就是空字符串;对于布尔型则是false;对于数字类型默认为0。

详细的Protobuf编码请看下面链接
https://developers.google.com/protocol-buffers/docs/encoding?hl=zh-CN

转载请注明原地址: http://blog.noark.xyz/article/2013/1/14/protobuf之通信协议/