网络层 - 剖析数据协议原理 - Protobuf

高效的数据压缩编码方式 Protobuf
Protocol Buffers(3):阅读一个二进制文件

一. protocol buffers 是什么?

Protocol buffers 是一种语言中立,平台无关,可扩展的序列化数据的格式,可用于通信协议,数据存储等

Protocol buffers 在序列化数据方面,它是灵活的,高效的。
相比于 XML 来说,Protocol buffers 更加小巧,更加快速,更加简单。一旦定义了要处理的数据的数据结构之后,就可以利用 Protocol buffers 的代码生成工具生成相关的代码。甚至可以在无需重新部署程序的情况下更新数据结构。
只需使用 Protobuf 对数据结构进行一次描述,即可利用各种不同语言或从各种不同数据流中对你的结构化数据轻松读写。

Protocol buffers 很适合做数据存储或 RPC 数据交换格式。
可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。

二. 为什么要发明 protocol buffers ?

大家可能会觉得 Google 发明 protocol buffers 是为了解决序列化速度的,其实真实的原因并不是这样的。

protocol buffers 最先开始是 google 用来解决索引服务器 request/response 协议的。

没有 protocol buffers 之前,google 已经存在了一种 request/response 格式,用于手动处理 request/response 的编组和反编组。
它也能支持多版本协议,不过代码比较丑陋:

if (version == 3) {
   ...
 } else if (version > 4) {
   if (version == 5) {
     ...
   }
   ...
 }

如果非常明确的格式化协议,会使新协议变得非常复杂。
因为开发人员必须 确保请求发起者与处理请求的实际服务器之间的所有服务器都能理解新协议,然后才能切换开关以开始使用新协议。

这也就是每个服务器开发人员都遇到过的低版本兼容、新旧协议兼容相关的问题。

protocol buffers 为了解决这些问题,于是就诞生了。protocol buffers 被寄予一下 2 个特点:

  • 可以很容易地引入新的字段,并且不需要检查数据的中间服务器可以简单地解析并传递数据,而无需了解所有字段。
  • 数据格式更加具有自我描述性,可以用各种语言来处理(C++, Java 等各种语言)

这个版本的 protocol buffers 仍需要自己手写解析的代码。

不过随着系统慢慢发展,演进,protocol buffers 目前具有了更多的特性:

  • 自动生成的序列化和反序列化代码避免了手动解析的需要。(官方提供自动生成代码工具,各个语言平台的基本都有)
  • 除了用于 RPC(远程过程调用)请求之外,人们开始将 protocol buffers 用作持久存储数据的便捷自描述格式(例如,在Bigtable中)。
  • 服务器的 RPC 接口可以先声明为协议的一部分,然后用 protocol compiler 生成基类,用户可以使用服务器接口的实际实现来覆盖它们。

protocol buffers 现在是 Google 用于数据的通用语言。在撰写本文时,谷歌代码树中定义了 48162 种不同的消息类型,包括 12183 个 .proto 文件。它们既用于 RPC 系统,也用于在各种存储系统中持久存储数据。

小结
protocol buffers 诞生之初是为了解决服务器端新旧协议(高低版本)兼容性问题,名字也很体贴,“协议缓冲区”。
只不过后期慢慢发展成用于传输数据。

三. Protocol Buffer

MessagePack在JSON之上做了优化,其实可以看做是,把JSON和自定义二进制的合并做法,
既汲取了JSON这种KEY-VALUE(键值对)通用性的优点,
又汲取了自定义二进制流格式无需解析和存储空间小的特点。

不过MessagePack的Map毕竟是kEY-VALUE形式的KEY值还是使用了字符串类型,
它的KEY还是逃脱不了字符串string占用太多存储空间的弊端。

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

常有人推崇说 Protocol Buffer 比JSON、MessagePack要好
那么它究竟好在哪里呢?我们就来分析下,为什么有这么多人推崇它。

我们选择数据协议的目的主要关注的点是,它是否能更简单上手,解析数据是否能更快,存储空间是否能更小,通用性是否能更强。
对于这些特点,Protocol Buffer 是否能都做到,还是说它只是在部分几个方面做到了,下面我们来透彻的对它剖析。

Protocol Buffer消息定义

创建扩展名为.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主要用于表示数组字段。

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

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

  1. acct_id 和 passwd 分别表示消息字段名,等同于C#中的域变量名。
  2. 标签数字 1 和 2 表示不同的字段在序列化后的二进制数据中的布局位置。

在该例中,passwd 字段编码后的数据一定位于 acct_id 之后。需要注意的是该值在同一message中不能重复。

对于Protocol Buffer而言,
标签值为 1 到 15 的字段在编码时可以得到优化,即标签值和类型信息仅占有一个byte,
标签范围是 16 到 2047 的将占有两个bytes,
而Protocol Buffer可以支持的字段数量则为2的29次方减1。

有鉴于此,我们在设计消息结构时,可以尽可能考虑让repeated类型的字段标签位于1到15之间,这样便可以有效的节省编码后的字节数量。

嵌套Protocol Buffer

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;
    }
  1. AddressBook消息的定义中包含另外一个消息类型作为其字段Person,Person又包含了另一个消息类型作为字段PhoneNumber。
  2. 其中的 AddressBook 和 Person 被定义在同一个.proto文件中,也可以被分开来定义在各自的.proto文件中。

Protocol Buffer提供了另外一个关键字import,相当于 C++ 的Include,这样我们便可以将很多通用的message定义在同一个.proto文件中,而其他各模块功能的消息体定义在其他文件中,再通过import的方式将需要的结构体文件中定义的消息包含进来,如:

import "myproject/CommonMessages.proto"

限定符 required、optional、repeated 的规则

  1. 在每个消息中必须至少有一个required类型的字段,保证数据中一定有至少一个数据。

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

  3. 每个消息中可以包含0个或多个optional类型的字段。

  4. optional表示该字段为可选字段,即在序列化和反序列化前可以不进行赋值,如果没有赋值则表示该数据为空。

  5. repeated表示的字段可以包含0个或多个重复的数据。注意,是重复的数据,可以等价于我们常使用的数组和列表,并且可以不赋值,则表示0个数据。

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

Protocol Buffer 是怎么识别和存储数据的,是序列化和反序列的关键。

JSON 和 MessagePack 都使用了字符串的KEY作为映射到程序变量的关键字
变量和字符串用比较字符串的是否相等来判断是否为该变量,避免不了字符串太多而浪费空间。

Protocol Buffer 则用 数字编号来作为KEY的关键字
每个变量都必须有个不能重复的标签号(即数字编号),
用变量后面跟着的数字编号来映射到数据中的数字编号,进而读取数据。

Protocol Buffer为每个变量都定义了一个标签号(即数字编号),这个数字编号就代表了程序变量与指定编号数据的映射关系。

有了这个规则还不够,因为程序在读取的时候,是不知道某个变量到底对应哪个标签号的,
比如上面的Person的 name 变量,在程序里的 name 变量是不知道到底该读取哪个编号的数据的,除非在程序里写死。

Protocol Buffer 就是使用了这种简单粗暴的方法,‘在程序里写死’的这种方式让事情变得更简单。

在程序里写死’这种粗暴的方式最讲究周边工具了,Protocol Buffer就为很多种语言定制了生成序列化和反序列化程序代码的工具。
只需要通过提供.proto文件就能生成相应语音的程序代码,在代码中把编号‘写死’,这一切代码都是自动生成的,我们只需要关心.proto文件中的结构。

以就是说,当Protocol Buffer生成的解析代码在读数据的时候,一旦读取到编号为1的数据时,就把数据解析给 name 这个程序变量,这些都写死在代码中,而代码由Protocol Buffer工具生成。

我们使用上面提到的 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);

生成出来的二进制数据流如下:

0a    // (1 << 3) + 2 = 0a,1为people的标签号,2为嵌入结构对应的类型号
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

整个数据看下来都是遵循了简单的规则,
即,标签号 + 类型号,最为头部标识,数据大小标识,作为可选标识,最后放入 具体数据

标签号 + 类型号|数据大小|具体数据

具体数据中再嵌套不同种类的数据,也同样遵循 ‘标签号 + 类型号|数据大小|具体数据’ 这样的规则。

二进制数据流中反序列化为程序对象数据,我们重点看看其中 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. 不要修改已经存在字段的标签号,即变量后面的数字,保证旧数据协议能够继续从数据中读取指定标签号的正确数据。

  2. 任何新添加的字段必须是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它们更小、更快、也更简单。

我们可以定义自己的数据结构,然后使用Protobuf代码生成器生成的代码来读写这个数据结构。甚至可以在无需重新部署程序的情况下更新数据结构。只需使用 Protobuf 对数据结构进行一次描述,即可利用各种不同语言或从各种不同数据流中对你的结构化数据轻松读写。

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

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

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

Protocol Buffer 的不足

Protbuf 与 XML 相比也有不足之处。它功能简单无法用来表示复杂的概念。

XML 已经成为多种行业标准的编写工具,Protobuf 只是运用在数据传输与存储上,在通用性上还差很多。

由于 XML 具有某种程度上的自解释性,它可以被人直接读取编辑,在这一点上 Protobuf 不行,它以二进制的方式存储,除非你有 .proto 定义,否则你没法直接读出 Protobuf 的任何内容。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值