《Unity3D高级编程之进阶主程》第六章,网络层(五) - 剖析数据协议原理

协议包的格式,json, msgpack, protobuf 以及自定义格式

项目的网路层在建设中,除了选择传输协议TCP,UDP,以及应用层协议HTTP方式外,还需要选择在传输过程中的业务层协议格式。前面我们分析了TCP,UDP,HTTP的原理与应用,这里我们来了解下在传输层和应用层之上的业务层中,网络数据传输格式的选择以及它们的利弊。我们将在这里剖析JSON,MessagePack,Protobuf的原理,包括它们都是由什么组成的,怎么序列化的,以及怎么反序列化,通过对原理和底层的剖析使我们对网络数据协议的理解更加透彻清晰。

我们从最常见的JSON格式开始,一步步深入了解业务层协议的规则与背后的原理,一步步剖析复杂的数据格式与底层实现。

===

JSON

JSON原本是JavaScript 对象表示法(JavaScript Object Notation),后来慢慢在被大家所接受普及开来成为一种协议数据的格式。它是存储和交换文本信息的语法,类似于 XML 但又比 XML 更小、更快,更易解析。

JSON 本身是轻量级的文本数据交换格式由字符串组成,它独立于语言且具有自我描述性,这些特性导致它非常容易被人理解。与同是纯文本类型格式的XML相比较,JSON不需要结束标签,JSON更短,JSON解析和读写的速度更快,在JavaScript中能够使用内建的 JavaScript eval() 方法进行解析,JSON还可以使用数组,且不使用保留字(&,<,>,’,”)。

我们来看看 JSON 的语法规则,JSON 数据的书写格式是:名称/值对。名称/值对包括字段名称(在双引号中),后面写一个冒号,然后是值:

"firstName" : "John"

JSON数据由逗号分隔,它的值可以是数字、字符串、真假逻辑值、数组、对象,我们来看看它们在文本中的具体格式:

数字(整数或浮点数)

{
  "number" : 1,
  "number2" : 11.5
}

字符串(在双引号中)

{
  "str1" : "1",
  "str2" : "11"
}

逻辑值(true 或 false)

{
  "logic1" : true,
  "logic2" : false
}

数组(在方括号中)

{
   "array1" : [1,2,3],
   "array2" : [{"str1",1},{"str2",2},{33,44}]
 }

对象(在花括号中)

{
  "obj1" : {1, "str1", true},
  "obj2" : {"str2", 2, false},
  "obj3" : null
}

其中对象在花括号中书写,其对象可以包含多个名称/值对:

{ "firstName":"John" , "lastName":"Doe" }

数组在方括号中书写,数组可包含多个对象:

{
    "employees": [
        { "firstName":"John" , "lastName":"Doe" },
        { "firstName":"Anna" , "lastName":"Smith" },
        { "firstName":"Peter" , "lastName":"Jones" }
    ]
}

JSON 文件的文件类型通常是 “xxx.json” 用来扩展名用来说明是json格式的文本文件。在HTTP协议中还定义了Json格式的MINE类型以方便终端逻辑识别,JSON 文本的 MIME 类型是 “application/json” (MIME (Multipurpose Internet Mail Extensions) 是描述消息内容类型的因特网标准。其他MIME 消息包含文本、图像、音频、视频以及其他应用程序专用的数据。

我们在平时的编程中 JSON 解析器也比较多,例如simpleJson,MiniJson,DataContractJsonSerializer,JArray,JObject等等,都是非常通用高效的插件,也可以自己’造轮子‘做一个JSON解析器,做时也要多考虑下效率和性能方面的问题。

自定义二进制流协议格式

大部分的网络协议都具有一定的通用性,JSON是最典型的案例,其他的包括 XML,MessagePack,Protobuf都是相对通用的,但我们所要说的自定义二进制流协议则不是,理论上说它完全不通用,其原因是它被设计出来就不需要顾及通用性。

我们在存储一串数据的时候,无论这串数据里包含了哪些数据以及哪些数据类型,当我们拿着这串数据解析的时候我们应该首先知道数据如何解析,这是定义协议格式的目标。简单说就是,当我们收到一串数据的时候,我们用什么样的规则知道这串数据里的内容的,这就是协议规则的目标。JSON就制定了这么一个规则,这个规则以字符串 KEY-VALUE 简单配对的形式,以及一些辅助的符号‘{’,’}’,’[’,’]’组合而成,这个规则比较通用且易于理解,这使得任何人拿到JSON数据都能一眼知道里面有什么数据。

自定义二进制协议格式则不具有通用性,并不是任何人拿到数据都能知道里面装的是什么的,有且只有两端协定的双方才知道该如何解析收到的数据,对于破解自定义二进制流的内容也只有靠猜因为协议格式只有制定时的双方才知道(虽然猜的难度也不是很大,很多外挂都靠经验猜测数据内容)。

一个自定义二进制流协议格式,分成三部分:

数据大小|协议编号|具体数据

用代码结构可以表示为:

class Mssage
{
  uint Size;
  uint CommandID;
  byte[] Data;
}

数据大小、协议编号、具体数据,这三者构成了一个完整的协议内容,当然很多时候command id 可以放入具体的数据中去。

现在假设我们客户端有这样一个数据结构需要传输到服务端去:

class TestMsg
{
  int test1;
  float test2;
  bool test3;
}

服务端拿到数据时,其实是完全不知道当前拿到的数据是什么,也不知道数据是否完整,有可能只拿到一半的数据,或者一部分的数据。因此首先我们要确定的是,我们收到的数据包它的完整的大小有多大,只有知道完整的包体大小才能确定我当前收到的数据在大小上是否完整,我们是要等待继续接受后面的数据,还是现在就可以进行解析操作了。

为了确定包的完整性,我们必须先向二进制流中读取4个byte,组合成一个无符号整数,整数总共32位,也就是说我们的数据包的大小最大可以为2的32次减1个byte,这个整数让我们知道了接下来数据的大小。例如我现在接收到了20个byte后,读取了前4个byte,组成一个整数后这个整数为24,说明后面16个byte是一个不完整的包体,我应该继续等待后续的数据到来。

其次我们要确定的是收到的数据包是属于哪个协议格式。于是我们再读取4个byte大小的数据,组成一个无符号整数CommandID,用来确定协议号。假如这个无符号整数的协议号为1002,就代表是接下来的数据是编号为1002的协议的数据格式。假设我们上面这个TestMsg类就是协议号1002的数据体,那么接下来连着这个协议号的所有数据直到包体大小的末尾都是这个TestMsg的数据,我们可以提取后解析为该类实例。

在解析这个具体数据的时候,我们需要根据生成这个数据的顺序来解析,写入数据的顺序和读取数据的顺序是一致的。假设在生成这个二进制流数据时,我们的顺序是,先推入test1变量,再推入test2变量,再推入test3变量。其中test1变量为4个byte的整数,test2变量为4个byte的浮点数,test3变量为1个byte的布尔值,于是就有了如下byte数组结构:

xxxx|xxxx|x
这样一个形状的二进制流,每个‘x’为一个byte,前两次4个byte组成一个int和float数据,最后1个byte组成布尔数据,‘’只是为了解释说明用的分隔符不存在于数据内,这个数据是由9个byte组成,其中前4个byte为test1,中间4个byte为test2,后面1个btye为test3。

在向网络传输的中整个数据包TestMsg的格式为如下:

13|1002|test1|test2|test3
上述格式中13为接下来的数据包大小,1002位协议编号,test1test2test3为具体数据。我们在解析的时候也需要按照生成时的顺序来解析,先读取前4个byte组成一个整数赋值给test1,接着再读取4个byte组成一个浮点数赋值给test2,接着再读取1个byte赋值给test3,完成数据解析。

对于数组形式的数据则要在原来的基础上多增加一个长度标志,比如 int[]类型数据,在生成时先推入代表长度的无符号整数数据,再连续推入所有数组内容,在解析的时候做同样的反向操作,先读取4个byte的长度标志,再对连续读取N个具体数据,N为提取的长度。我们举例int[]为3个整数数组则二进制为如下效果:

xxxx|xxxx|xxxx|xxxx

前4个byte为长度数据,接着3次4个byte为数组内的整数数据。

自定义二进制流协议格式为最不通用的格式,但可以成为最节省流量的协议方式,因为每个数据都可以用最小的方式进行定义,比如协议号不需要4个byte,2个byte大小代表2的16次-1也就是65535就够用了,长度有可能也不需要4个byte,只要2个甚至1个byte就够用了,有些数据不需要4个byte组成int整数,只需要2个byte数组ushort就够用了,甚至有些可以组合起来使用,比如协议结构中有4个bool,可以拼成一个byte来传递,这些都可以完全由我们来控制包体的大小不受到任何规则的限制,这也是自定义二进制协议格式最吸引人的地方。

自定义二进制流协议格式最大的缺点是不通用和难更新,当我们需要更换一个协议格式的时候,旧的协议格式就无法解析了,特别是当新的协议解析旧的协议时就会报错。不过我们也可以做些补救这种问题的措施,为了能让旧的协议格式还能继续使用,我们在每个数据头部都加入一个2个byte的整数代表版本号,由版本号来决定该读取哪个版本的协议,这样旧的协议也照样可以兼容新的协议,只是处理起来的时候需要注意些初始化问题,那些旧协议没有的而新协议有的数据则要尽可能的初始化成默认值以免造成逻辑报错。

MessagePack

MessagePack 是一个介乎于JSON和自定义二进制流之间的协议格式,他的理念是 ‘It’s like JSON. but fast and small.’ 。

与JSON相同的是MessagePack也有采用Key-Value形式的Map映射类型,不同的是MessagePack用byte形式存储data部分的数据,包括整数、浮点数、布尔值等,并且在Map映射类型外加入了更多独立类型(非KEY-VALUE形式)的数据类型,其中也包括了自定义二进制流的数据类型。

Map映射类型在MessagePack中也是比较常用的数据类型,它是比较通用的存储形式类型,也因为通用性被很多程序员所喜爱。在使用过JSON的程序员知道JSON易懂且易用,MessagePack使用起来能和JSON用起来一样,并且数据大小比JSON小,解析速度又比JSON快,这也是作者所说的 “It’s like JSON. but fast and small.”。

非Map类型的数据部分其实和自定义二进制流的存储方式差不多,只是把自定义二进制数据流中的‘数据大小数据’的形式改为了‘类型数据’,比如我们存储一个4个byte的32位的整数:
+--------+--------+--------+--------+--------+
|  0xd2  |ZZZZZZZZ|ZZZZZZZZ|ZZZZZZZZ|ZZZZZZZZ|
+--------+--------+--------+--------+--------+

第一个byte的值0xd2代表32位整数类型,它表示后面4个byte组合起来是整数类型的数据。再比如32位的浮点数的存储格式:

+--------+--------+--------+--------+--------+
|  0xca  |XXXXXXXX|XXXXXXXX|XXXXXXXX|XXXXXXXX|
+--------+--------+--------+--------+--------+

第一个byte的值0xca代表32位浮点数类型,它表示后面4个byte组合起来是浮点数类型的数据。以此类推,nil,bool,8位无符号整数,16位无符号整数,32位无符号整数,64位无符号整数,8位有符号整数,16位有符号整数,32位有符号整数,64位有符号整数等,以及32位浮点数,64位浮点数,都用这种类似的方式表示。

其实用MessagePack并不是冲着这些单独的数据类型去的,因为这些单独的数据类型完全可以用自定义二进制流代替,我们关心的是它的Map类型数据的格式定义。我们先来看看,MessagePack的Map类型的存储机制和Json有什么区别,它为什么就比JSON快,为什么就比JSON小,它是如何存储和解析的。

在Map之前我们看看数组类型的格式:

+--------+--------+--------+~~~~~~~~~~~~~~~~~+
|  0xdc  |YYYYYYYY|YYYYYYYY|    N objects    |
+--------+--------+--------+~~~~~~~~~~~~~~~~~+

第一个byte的值0xdc代表是个总共可以存储16位长度的数组,也就是它的最大存放个数为2的16次-1个元素的数组,后面2个byte组合起来成为一个无符号的整数代表后面有多少个元素,接着后面N就是相同类型的元素的数据。

假设说这N个元素是32位整数类型的数据,那么上述的数组类型具体格式就是如下:

+--------+--------+--------+~~~~~~~~~~~~~~~~~+
|  0xdc  |00000000|00000011|  0xd2|00001001|0xd2|00001101|...(3 objects)
+--------+--------+--------+~~~~~~~~~~~~~~~~~+
我们看这个数组中指定了数组类型,0xdc就代表数组,以及后面2个byte拼起来表示数组元素的个数为11,接下来的数据就是单个元素的数据,即有11个整数数据组成的数组,每个数据都以‘类型数据’格式的存储。其实Map类型就是数组类型的变种,我们在数组类型基础上每个元素,多加了个KEY字符串就成了Map类型的数据格式,我们来看下Map的具体格式:
+--------+--------+--------+~~~~~~~~~~~~~~~~~+
|  0xde  |YYYYYYYY|YYYYYYYY|   N*2 objects   |
+--------+--------+--------+~~~~~~~~~~~~~~~~~+

第一个byte的值0xde代表的是最大个数为16位(即2的16次减1个)的map类型数据,接着2个byte组合起来表示有多少个元素,最后部分N乘2个元素为数据元素,以每2个元素为一个Key-Value组合,第一元素一定是字符串Key,第二个元素为任意的单独数据类型。

我们用官方的例子来分析下,例如一个JSON类型的数据为:

{"compact":true, "schema":0}

这个数据在MessagePack中是以Map类型数据存在的其格式为:

82|A7|'c'|'o'|'m'|'p'|'a'|'c'|'t'|C3|A6|'s'|'c'|'h'|'e'|'m'|'a'|00|

数据中头部的‘82’这个数据,前半个byte的值即8代表是个最多拥有15个元素的map类型数据,后半个byte的值即2,代表总共有2个元素。接着第二个数据‘A7’,‘A’为前半个byte的值代表接下去是个31个字符以内的字符串,后半个byte值为7代表这个字符串拥有7个字符。接着7个元素都是字符元素及Key位置的字符串。接着‘C3’是Key-Value的Value类型数据部分,这个Value是一个bool型的ture值。接着‘A6’开始为第二个Key-Value数据组合,其中A为前半个byte代表是接下去是个31个元素以内的字符串,后半个byte为6代表这个字符串有6个长度大小。接着6个元素都是字符作为Key数据。最后的‘00’,前面0为前半个byte,表示类型为7位以内的整数,接着的0位后半个byte,代表数据为0。这样整个数据分析下来,MessagePack数据与Json的{“compact”:true, “schema”:0}数据对应上。

MessagePack整个Map就是以这种“类型数据”或者”类型大小数据”的方式存储。由于存储的方式是顺序,所以在解析的时候不需要排序,不需要解析符号和类型,数据的类型直接可以用byte来表示,能用byte存储绝不用字符串形式存储,如能减少byte使用个数的尽量减少byte的使用个数,如能合并的尽量合并为一个byte。因此MessagePack对于JSON来说,减少了大量的解析,同时也减少了大量的数据占用空间,使得MessagePack能比起JSON来更快并且更小,就像它自己所说的那样 ‘It’s like JSON. but fast and small.’。

Protocol Buffer

虽然Proto3在Proto2之上又做了更多的改进,但我们这里仍以Proto2为基准来讲解Protocal buffer的内在机制。MessagePack在JSON之上做了很多数据空间和序列化以及反序列化上的优化,其实可以看做是把JSON和自定义二进制的混合的做法,既吸收了JSON这种Key-Value(键值对)简单易懂通用性的优点,又吸收了自定义二进制流格式序列化与反序列化性能高和存储空间小的特点。不过话说回来,MessagePack的Map形式数据存储格式毕竟是Key-Value形式的,其Key值仍然使用了字符串,还是逃脱不了字符串string占用太多存储空间的弊端。

Google Protocol Buffer 的出现就弥补了MessagePack的这个缺点,但是Google Protocol Buffer也有自身不可忽视的缺点,我们来看究竟Google Protocol Buffer是怎么的一种数据协议。

Google Protocol Buffer( 简称 Protobuf) 是 Google 公司内部的混合语言数据标准,它们用于 RPC 系统和持续数据存储系统。Protobuf 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域,是语言无关、平台无关、可扩展的序列化结构数据格式。常有人推崇说 Protocol Buffer 比JSON、MessagePack要好,那么它究竟好在哪里呢?我们就来分析下。

我们选择数据协议的目的主要关注的点是,它是否能更简单易上手,序列化与反序列化数据性能是否高效,存储空间占用是否更小,更改协议后的兼容性是否能更强。对于这些特点,Protocol Buffer 是否能都做到,下面我们就来的对它剖析一番。

Protocol Buffer消息定义

Protobuf 的消息定义需要创建一个文件然后把消息结构写进入,然后再通过Protobuf生成工具将定义好的消息文件生成为指定语言的程序文件,我们在编程时可以通过调用这些生成的程序去序列化和反序列化Protobuf。

我们先来创建一个扩展名为.proto的文件,假设文件名为 MyMessage.proto,并将以下内容存入该文件中。

message LoginReqMessage {
  required int64 acct_id = 1;
  required string passwd = 2;
}

上述消息定义是一个简单的登陆消息定义,我们来说明下里面的结构。

  1. message是消息定义的关键字,等同于C#中的struct/class。

  2. LoginReqMessage为消息的名字,等同于结构体名或类名。

  3. required前缀表示该字段为必要字段。即在序列化和反序列化之前该字段必须已经被赋值。

与required相似的功能还存在另外两个类似的关键字,optional和repeated。optional表示该字段为可选字段,即在序列化和反序列化前可以不进行赋值。相比于optional,repeated主要用于表示数组字段,它代表数组。(required和optional字段已经在Protobuf3中取消,所有未定义类型都是optional)

  1. int64和string分别表示64位长整型和字符串型的消息字段。

其实在Protobuf中存在一张类型对照表,这张对照表用于Protobuf中的数据类型与其他编程语言(C#/Java/C++)中所用类型的一一对应。该对照表中还将给出在不同的数据场景下,使用哪种数据类型更为高效。

  1. acct_id 和 passwd 分别表示消息字段名,等同于C#中的域变量名。

  2. 标签数字 1 和 2 表示不同字段序列化后在二进制数据中的布局位置。

LoginReqMessage结构的实例数据在序列化时,acct_id先被推入数据流中再是passwd,passwd 字段在序列化后的数据一定位于 acct_id 之后。我们需要注意数字标签的值代表二进制流中的位置,该值在同一message中不能重复。

另外Protocol Buffer有个优化规则我们需要在定制消息的注意,它在标签值为 1 到 15 的字段上序列化时会对其进行优化,即标签值和类型信息仅占有一个byte,标签范围在 16 到 2047 的则占有两个byte,而Protocol Buffer可以支持的字段数量则为2的29次方减1个即536870911个数据变量。鉴于此优化规则,我们在设计消息结构时,可以尽可能考虑让repeated类型的字段标签位于1到15之间,这样便可以有效的节省序列化后的字节大小。

多层嵌套Protocol Buffer

除了定义单个消息,我们也可以在同一个.proto文件中定义多个message,这样便可以实现多层嵌套消息的定义,我们来看看具体案例:

    message Person {
      required string name = 1;
      required int32 id = 2;
      optional string email = 3;

      enum PhoneType {
        MOBILE = 0;
        HOME = 1;
        WORK = 2;
      }

      message PhoneNumber {
        required string number = 1;
        optional PhoneType type = 2 [default = HOME];
      }

      repeated PhoneNumber phones = 4;
      repeated float weight_recent_months = 100 [packed = true];
    }

    message AddressBook {
      repeated Person people = 1;
    }

我们在proto文件中定义了三个消息结构和一个枚举结构,其中AddressBook消息的定义中包含了Person消息类型作为其字段变量,Person又包含了PhoneNumber消息类型作为字段变量,这与我们平时编程的数据结构嵌套方式很相似。除了这些数据结构被集中定义在一个proto文件中以外,它们也可以被分开来定义在各自.proto文件中。

由于Protocol Buffer提供了另外一个关键字,‘import’关键字,它相当于 C++ 的Include,这样我们在编写Proto结构时便可以将很多通用的message定义在同一个.proto文件中,而每个模块功能的消息体定义可以自己管理分别定义在自己独立的proto文件中或者以其他更清晰的方式分开定义,最后我们可以通过import关键字以动态导入的方式将需要的结构体文件导入进来,如:

    message Person {
      required string name = 1;
      required int32 id = 2;
      optional string email = 3;

      enum PhoneType {
        MOBILE = 0;
        HOME = 1;
        WORK = 2;
      }

      message PhoneNumber {
        required string number = 1;
        optional PhoneType type = 2 [default = HOME];
      }

      repeated PhoneNumber phones = 4;
      repeated float weight_recent_months = 100 [packed = true];
    }

例如上述我们先写好一个常用的数据结构体消息,将它放入Person.proto文件中。

    import "myproject/Person.proto"

    message AddressBook {
      repeated Person people = 1;
    }

然后再我们写我们自己模块里的结构消息,定义好自己的需要的数据类型字段,再将Person这个proto文件里的所有消息结构都导入进来。通过‘import’我们可以轻松而且清晰的表达项目中的数据分块与分层。

限定符 required、optional、repeated 的规则

在Protobuf2中有这3个限定符,并且在每个消息中必须至少有一个required类型的字段,保证数据中一定有至少一个数据。

required限定符表示该字段为必要字段。即在序列化和反序列化之前数据中该字段必须已经被赋值。而每个消息中可以包含0个或多个optional类型的字段。optional限定符表示该字段为可选字段,即在序列化和反序列化前可以不进行赋值,如果没有赋值则表示该数据为空。repeated限定符则表示的字段可以包含0个或多个重复的数据,即数组类型符号。注意 repeated 代表是重复的数据,等价于我们常使用的数组和列表,并且可以不赋值,如果不赋值则表示0个数组数据。

Protocol Buffer 原理-序列化和反序列化

Protocol Buffer 是怎么识别和存储数据的是理解它序列化和反序列的关键。JSON 和 MessagePack 都使用了字符串Key键值作为映射到程序变量的连接桥梁,用变量的字符串名字去查看对应的Key键值是否存在,这样避免不了因Key键值字符串太多的空间浪费。

Protocol Buffer 则用数字编号来作为Key键值与变量映射的连接桥梁,每个变量都必须有个不重复的标签号(即数字编号),用Protobuf结构中的变量字段后面跟着的数字编号来映射到数据中的数字编号,进而读取数据。Protocol Buffer为每个结构变量都定义了一个标签号(即数字编号),这个数字编号就代表了程序变量与指定编号数据的映射关系。

其实有了这个规则还不够,因为程序在读取的时候,是不知道某个变量到底对应哪个标签号的,比如上面的Person的 name 变量,在程序里的 name 变量并不知道到底自己该读取哪个编号的数据的,除非在程序里写死说 name 变量就读取编号为1的数据。Protobuf 就是使用了这种简单粗暴的方法,‘在程序里写死’的这种方式让事情变得更简单。‘在程序里写死’这种粗暴的方式最讲究周边工具了,因为‘在程序里写死’本身是件危险的事,然而如果这个程序是我们通过工具生成的话就会好很多,它相当于我们使用了一些规则并配置了一些数据让生成的程序符合我们的预期并可以随时通过配置来改变它们。Protobuf 的周边工具就为很多种语言定制了生成序列化和反序列化程序代码的工具,我们可以视 .proto 文件为配置文件,Protobuf 根据 .proto 配置文件来生成序列化程序文件。我们只需要通过提供.proto文件就能生成不同语言的程序代码,其代码中的变量的读取与存储编号就是通过周边工具的方式‘写死’在程序中,我们所说的这些代码都是通过周边工具生成的,而我们只需要关心.proto文件中的结构就可以了。

简要总结下,当 Protobuf 生成的用于序列化和反序列化的代码在读数据的时候,通过 .proto 文件中的内容把变量名与数字编号‘写死’绑定在代码中,一旦读取到某个编号的数据时,就把该编号的数据解析给指定变量,例如前面我们提过的Protobuf数据结构案例中,当程序读取到编号为1的数据时,就会把数据写入 name 变量中去,当 name 变量需要写入到数据文件时都是先将编号1这个数字写入进去,而编写这些操作的代码由Protobuf工具完成我们无需担心。

我们来看个具体的例子,我们使用上面提到的 AddressBook 数据结构来序列化一个 Protocol Buffer 数据。

我们将数据序列化代码为:

AddressBook address_book;
Person person = address_book.add_people();
person.set_id(1);
person.set_name("Jack");
person.set_email("Jack@qq.com");
Person.PhoneNumber phone_number = person->add_phones();
phone_number.set_number("123456");
phone_number.set_type(Person.HOME);
phone_number = person.add_phones();
phone_number.set_number("234567");
phone_number.set_type(Person.MOBILE);

person->add_weight_recent_months(50);
person->add_weight_recent_months(52);
person->add_weight_recent_months(54);

//将数据写入数据流中
address_book->WriteStream(stream);

上述代码生成出来的二进制数据流如下:

0a    // (1 << 3) + 2 = 0a,1为people的标签号,2为嵌入结构对应的repeated类型号
3c    // 0x3c = 60,表示接下来60个字节为Person的数据

// 下面进入到 repeated Person 数组的数据结构
0a    // (1 << 3) + 2 = 0a,Person的第一个字段name的标签号为1,2为string(字符串)对应的类型号
04    // name字段的字符串长度为4
4a 61 63 6b    // "Jack" 的ascii编码

10    // (2 << 3) + 0 = 10,字段id的标签号为2,0为int32对应的类型号
01    // id的整型数据为1

1a    // (3 << 3) + 2 = 1a,字段email的标签号为3,2为string对应的类型号
0b    // 0x0b = 11 email字段的字符串长度为11
4a 61 63 6b 40 71 71 2e 63 6f 6d        // "Jack@qq.com"

    //第1个PhoneNumber,嵌套message
    22    // (4 << 3) + 2 = 22,phones字段,标签号为4,2为嵌套结构对应的类型号
    0a    // 0a = 10,接下来10个字节为PhoneNumber的数据
    0a    // (1 << 3) + 2 =  0a, PhoneNumber的number,标签号为1,2为string对应的类型号
    06    // number字段的字符串长度为6
    31 32 33 34 35 36    // "123456"
    10   // (2 << 3) + 0 = 10,PhoneType type字段,0为enum对应的类型号
    01   // HOME,enum被视为整数

    // 第2个PhoneNumber,嵌套message
    22 0a 0a 06 32 33 34 35 36 37 10 00  //信息解读同上,最后的00为MOBILE

a2 06   // 1010 0010 0000 0110 varint方式,weight_recent_months的key
        //  010 0010  000 0110 → 000 0110 0100 010 little-endian存储
        // (100 << 3) + 2 = a2 06,100为weight_recent_months的标签号
        //  2为 packed repeated field的类型号
0c    // 0c = 12,后面12个字节为float的数据,每4个字节一个数据
00 00 48 42 // float 50
00 00 50 42 // float 52
00 00 58 42 // float 54

上述二进制数据是一个紧凑的byte数组,我们在剖析时将它拆解开来,这样会更加清晰。第一行的0a由 (1 « 3) + 2 = 0a 生成,其中1为people的标签号,2为嵌入结构对应的repeated类型号。紧跟其后的第二行中3c 表示 0x3c = 60,代表接下来有60个字节大小的数据是 Person 的数据。于是接下来下面进入到 repeated Person 数组的数据结构内容。其遇到的第一个数据 0a 由 (1 « 3) + 2 = 0a 生成而来,1代表Person的第一个字段name的标签号为1,2代表string(字符串)对应的类型号为2。后面的 04 表示为name字段的字符串长度为4,其后紧跟着就是 name 字段字符串的具体数据了,4a 61 63 6b 即为 “Jack” 的ascii编码。后面的数据 10 由 (2 « 3) + 0 = 10 生成而来,其中2代表字段id的标签号为2,0代表int32对应的类型号为2。接着就是int32的数据内容,即 01 表示数据结构中字段变量id的整型数据为1。接下来由是一串email字符串内容,先标志标签号+类型号,再紧跟一个数据大小,再是字符串的具体数据。

再后面就是 PhoneNumber 的数组类型,先是22 表示标签号与类型号, (4 « 3) + 2 = 22,4代表phones字段标签号为4,2代表嵌套结构对应的类型号为2。接着 0a = 10,表示接下来10个字节为PhoneNumber的数据。接着 0a 由 (1 « 3) + 2 = 0a 生成而来,1代表PhoneNumber的number标签号为1,2代表string对应的类型号为2。接着是字符串长度 06 表示number字段的字符串长度为6。接着 31 32 33 34 35 36 为字符串 “123456” 的ascii编码。后面是一个 enum 类型,10 由 (2 « 3) + 0 = 10 生成而来,2代表PhoneType type字段的标签号,0为enum对应的类型号。接着是enum的具体数据,01 表示枚举值HOME,所有的enum被视为整数。第二个PhoneNumber也是同样的数据格式。

最后这些数据为浮点数数组weight_recent_months字段,a2 06 由(100 « 3) + 2 = a2 06 生成而来,100为weight_recent_months的标签号,2为 packed repeated field的类型号。后面的 0c 是 0c = 12的意思,表示后面的12个字节为float的数组数据,每4个字节一个数据。于是接下来三个数据,每个数据4个字节都直接表示浮点数,00 00 48 42 表示float 50,00 00 50 42 表示 float 52,00 00 58 42 表示 float 54。

这个例子来自《Protocol Buffers:阅读一个二进制文件》整个二进制数据分析下来都是遵循了简单的规则,即,标签号 + 类型号,其头部的标识和数据大小标识,作为可选标识放入具体数据,即如下格式:

标签号 + 类型号|数据大小|具体数据
如果具体数据中再嵌套不同种类的数据,也同样遵循 ‘标签号 + 类型号数据大小具体数据’ 这样的规则。
前面我们剖析了下序列化的数据内容,接下来我们看看反序列化的过程。

二进制数据流中反序列化为程序对象数据过程中,标签号与变量的映射关系是由程序‘写死’在代码中的,我们仍然拿上面的protobuf结构来举例,我们重点看看其中 Person 结构的反序列过程:

public void MergeFrom(pb::CodedInputStream input) {
  uint tag;
  while ((tag = input.ReadTag()) != 0) {
    switch(tag) {
      default:
        _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
        break;
      case 1: {
        name = input.ReadString();
        break;
      }
      case 2: {
        id = input.ReadInt32();
        break;
      }
      case 3: {
        email = input.ReadString();
        break;
      }
      case 4: {
        phones_.AddEntriesFrom(input, _repeated_phones_codec);
        break;
      }
      case 100: {
        
        weight_recent_months_.AddEntriesFrom(input, _repeated_weight_recent_months_codec);
        break;
      }
    }
  }
}

上述Protocol Buffer生成的代码我们了解到,所有的对象变量都通过.proto文件中的标签号来识别数据是否与该变量有映射关系,当拿到具体数据时,先用标签号来判定映射到的是哪个变量名,再针对该变量的类型读取数据并赋值。

Protocol Buffer 更改数据结构后的兼容问题

在实际的开发中会存在这样一种应用场景,即消息格式因为某些需求的变化而不得不进行必要的修改,但是有些仍然使用原有消息格式的应用程序由于各种原因玩家暂时不能愿意升级客户端程序,这便要求我们在更新消息格式时要遵守一定的规则,从而可以保证基于新老消息格式的新老客户端程序中都能够顺利运行。我们应该注意的规则如下:

  1. 不要修改已经存在字段的标签号,即.proto文件中结构消息变量字段后面的编号数字不应该被轻易改变,这保证旧数据协议能够继续从数据中读取指定标签号的正确数据。如果我们更改了标签号,则新老数据不能在新旧客户端中被兼容。

  2. 任何新添加的字段必须使用optional和repeated限定符,这保证在旧数据无法加入新数据字段的情况下,新的协议数据还能够在旧数据协议之下顺利解析。如果我们不使用optional或repeated标签则无法保证新老程序在互相传递消息时的消息兼容性。

  3. 在原有的消息中,不能移除已经存在的required字段,虽然optional和repeated类型的字段可以被移除,但是他们之前使用的标签号必须被保留,不能被新的字段重用。因为旧协议在执行时还是会在旧的标签号中加入自己的数据,新协议如果使用了旧的标签号,就会导致新旧协议数据解析错误的问题。

  4. int32、uint32、int64、uint64和bool等类型之间是兼容的,sint32和sint64是兼容的,string和bytes是兼容的,fixed32和sfixed32,以及fixed64和sfixed64之间是兼容的,这意味着如果想修改原有字段的类型时,为了保证兼容性,只能将其修改为与其原有类型兼容的类型,否则就将打破新老消息格式的兼容性。

Protocol Buffer 的优点

Protobuf 全程使用二进制流形式,用整数代替了Key键值来映射变量,比 XML、Json、MessagePack它们更小也更快。

我们可以定义随意的创建自己的.proto文件在里面编写自己的数据结构,然后使用Protobuf代码生成工具生成的protobuf代码,用于读写我们需要序列化和反序列化的protobuf数据结构。我们甚至可以在无需重新部署程序的情况下更新我们的数据结构,只需使用 Protobuf 对数据结构进行一次重新描述,就可利用各种不同语言或从各种不同数据流中对我们的protobuf数据轻松读写。

使用 Protobuf 也无需学习复杂的文档对象模型,因为Protobuf 的编程模式比较友好简单易学,同时它拥有良好的文档和示例,对于喜欢简单易用的工具的人来说,Protobuf 比其他的技术更加有吸引力。

Protobuf 语义也更清晰,无需类似 XML,JSON 解析器的东西,简化了解析的操作,减少了解析的消耗。

Protobuf 数据使用二进制形式,把原来在JSON,XML里用字符串存储的数字换成用byte存储,大量减少了浪费的存储空间。与MessagePack相比,Protobuf减少了Key的存储空间,让原本用字符串来表达Key的方式换成了用整数表达方式,不但减少了存储空间也加快了反序列化的速度。

Protocol Buffer 的不足

Protbuf 与 XML、Json类型的数据格式相比也有不足之处,它功能简单无法用来表示复杂的数据概念。XML和Json已经成为多种行业标准的编写工具,明文的表达方式让数据格式显得更加友好,Protobuf 只是运用在数据传输与存储上,在各领域的通用性上还差很多。由于 XML和Json 具有某种程度上的自解释性,它可以被人直接读取和编辑,Protobuf 却不行,它以二进制的方式存储,除非你有 .proto 定义,否则你没法直接读出 Protobuf 的任何内容。

参考文献:

《Protocol Buffers:阅读一个二进制文件》

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第六章,网络层(五) - 剖析数据协议原理

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)