术→技巧, 研发

Google Protocol Buffers使用指南

钱魏Way · · 0 次浏览

Protocol Buffers简介

Protocol Buffers 是一种语言中立,平台无关,可扩展的序列化数据的格式,可用于通信协议,数据存储等。序列化是将结构数据或对象转换成能够被存储和传输(例如网络传输)的格式,同时应当要保证这个序列化结果在之后(可能在另一个计算环境中)能够被重建回原来的结构数据或对象。

在序列化结构化数据的机制中,ProtoBuf是灵活、高效、自动化的,相对常见的XML、JSON,描述同样的信息,ProtoBuf序列化后数据量更小、序列化/反序列化速度更快、更简单。

数据交互XML、JSON、Protobuf格式比较

  • json: 一般的web项目中,最流行的主要还是json。因为浏览器对于json数据支持非常好,有很多内建的函数支持。
  • xml: 在webservice中应用最为广泛,但是相比于json,它的数据更加冗余,因为需要成对的闭合标签。json使用了键值对的方式,不仅压缩了一定的数据空间,同时也具有可读性。
  • protobuf:是后起之秀,是谷歌开源的一种数据格式,适合高性能,对响应速度有要求的数据传输场景。因为profobuf是二进制数据格式,需要编码和解码。数据本身不具有可读性。因此只能反序列化之后得到真正可读的数据。
XML JSON Protocol Buffers
数据保存方式 文本 文本 二进制
可读性 较好 较好 不可读
解析速度 一般
语言支持 所有语言 所有语言 所有语言
使用范围 文件存储、数据交互 文件存储、数据交互 文件存储、数据交互

相对于其它protobuf更具有优势

  • 序列化后体积相比Json和XML很小,适合网络传输
  • 支持跨平台多语言
  • 消息格式升级和兼容性还不错
  • 序列化反序列化速度很快,快于Json的处理速速
  • 由于传输的过程中使用的是二进制,没有结构描述文件,无法解析内容,安全性更高

劣势:

  • 由于传输过程使用的是二进制,自解释性较差,需要原有的结构描述文件才能解析

实际数据对比(与JSON):

  • 序列化速度:比JSON快20-100倍
  • 数据大小:序列化后体积小3倍

在一个需要大量的数据传输的场景中,如果数据量很大,那么选择protobuf可以明显的减少数据量,减少网络IO,从而减少网络传输所消耗的时间。

Protocol Buffers的产生背景

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 用于数据的通用语言。据Google官方文档介绍,现在Google内部已经有48,162个消息类型定义在12,183个proto文件中。它们既用于 RPC 系统,也用于在各种存储系统中持久存储数据。

gRPC中的Protocol Buffers

在嵌入式系统中,很少需要使用到 RPC (Remote Procedure Call)远程方法调用,因为在大部分情况下,实现一个产品功能的所有进程、线程都是运行在同一个硬件设备中的。但是在一些特殊的场景中,RPC 调用还是很有市场的,比如:在计算密集型产品中,需要调用算力更强的中央服务器提供的算法函数;因此,利用 RPC 来利用远程提供的服务,相对于其他的机制来说,有更多的优势。

RPC 是什么?

RPC (Remote Procedure Call)从字面上理解,就是调用一个方法,但是这个方法不是运行在本地,而是运行在远端的服务器上。也就是说,客户端应用可以像调用本地函数一样,直接调用运行在远端服务器上的方法。

下面这张图描述了 RPC 调用的基本流程:

假如,我们的应用程序需要调用一个算法函数来获取运动轨迹:

int getMotionPath(float *input, int intputLen, float *output, int outputLen)

如果计算过程不复杂,可以把这个算法函数和应用程序放在本地的同一个进程中,以源代码或库的方式提供计算服务,如下图:

但是,如果这个计算过程比较复杂,需要耗费一定的资源(时间和空间),本地的 CPU 计算能力根本无法支撑,那么就可以把这个函数放在 CPU 能力更强的服务器上。

此时,调用过程如下图这样:

从功能上来看,应用程序仍然是调用远程服务器上的一个方法,也就是虚线部分。但是由于他们运行在不同的实体设备上,更不是在同一个进程中,因此,如果想调用成功就一定需要利用网络来传输数据。

初步接触 RPC 的朋友可能会提出:那我可以在应用程序中把算法需要的输入数据打包好,通过网络发送给算法服务器;服务器计算出结果后,再打包好返回给应用程序就可以了。这句话说的非常对,从功能上来说,这个描述过程就是 RPC 所需要做的所有事情。不过,在这个过程中,有很多问题需要我们来手动解决:

  • 如何处理通信问题?TCP or UDP or HTTP?或者利用其他的一些已有的网络协议?
  • 如何把数据进行打包?服务端接收到打包的数据之后,如何还原数据?
  • 对于特定领域的问题,可以专门写一套实现来解决,但是对于通用的远程调用,怎么做到更灵活、更方便?

为了解决以上这几个问题,于是 RPC 远程调用框架就诞生了!

图中的绿色背景部分,就是 RPC 框架需要做的事情。

对于应用程序来说,Client 端代理就相当于是算法服务的“本地代理人”,至于这个代理人是怎么来处理刚才提到的那几个问题、然后从真正的算法服务器上得到结果,这就不需要应用程序来关心了。

结合文章的第一张图中,从应用程序的角度看,它只是执行了一个函数调用,然后就立刻得到了结果,这中间的所有步骤,全部是 RPC 框架来处理,而且能够灵活的处理各种不同的请求、响应数据。

需要解决什么问题?

既然我们是介绍 RPC 框架,那么需要解决的问题就是一个典型的 RPC 框架所面对问题,如下:

  • 解决函数调用时,数据结构的约定问题;
  • 解决数据传输时,序列化和反序列化问题;
  • 解决网络通信问题;

这 3 个问题是所有的 RPC 框架都必须解决的,这是最基本的问题,其他的考量因素就是:速度更快、成本更低、使用更灵活、易扩展、向后兼容、占用更少的系统资源等等。

另外还有一个考量因素:跨语言。比如:客户端可以用 C 语言实现,服务端可以用 C/C++、Java或其他语言来实现,在技术选型时这也是非常重要的考虑因素。

有哪些开源实现?

从上面的介绍中可以看出来,RPC 的最大优势就是降低了客户端的函数调用难度,调用远程的服务就好像在调用本地的一个函数一样。因此,各种大厂都开发了自己的 RPC 框架,例如:

  • Google 的 gRPC
  • Facebook 的 thrift
  • 腾讯的 Tars
  • 百度的 BRPC

另外,还有很多小厂以及个人,也会发布一些 RPC 远程调用框架(tinyRPC,forestRPC,EasyRPC等等)。每一家 RPC 的特点,感兴趣的小伙伴可以自行去搜索比对,这里对 gRPC 多说几句,我们刚才主要聊了 protobuf,其实它只是解决了序列化的问题,对于一个完整的 RPC 框架,还缺少网络通信这个步骤。gRPC 就是利用了 protobuf,来实现了一个完整的 RPC 远程调用框架,其中的通信部分,使用的是HTTP协议。

Protocol Buffer的特点

二进制传输格式

Protobuf是二进制传输格式,这意味着数据以二进制形式传输。与原始字符串相比,这提高了传输速度,因为它占用的空间和带宽更少。由于数据已压缩,因此CPU使用率也将降低。 唯一的缺点是Protobuf文件或数据的可读性不如JSON或XML 如果平台不像Rich Client那样支持二进制消息,则Protobuf中具有将二进制数据序列化为字符串的功能。

上下文和数据分离

在JSON和XML中,数据和上下文不是分开的,而在Protobuf中,它们是分开的。考虑一个JSON示例:

{ 
  "first_name":"Arun",
  "last_name":"Kurian"
}

在此示例中,传输的数据具有一个对象,其具有两个属性first_name和last_name,值分别为Arun和Kurian。 这是高度可读的,但是会占用更多空间。在这里,每条JSON消息必须每次都提供这两部分。随着我们数据的增长,传输时间将大大增加。

但是对于Protobufs,情况就不同了。我们首先在这样的配置文件中定义一条消息:

{
  string first_name = 1;
  string last_name = 2;  
}

此配置文件包含上下文信息。数字只是字段的标识符。不必担心消息格式是否有些混乱-我们稍后将详细研究它。通过使用此配置,我们可以将数据编码为: 124Arun226Kurian 这里124Arun,

  • 1表示字段标识,
  • 2标识数据类型(String)
  • 4是长度 当然必须承认这可读性比JSON差远了,但是与JSON数据相比,这将占用很少的空间。

消息格式

正如我们之前所看到的,数据是根据称为消息的配置以Protobuf的形式传输的。消息保存在.proto文件中。让我们看一个消息示例:

syntax = "proto3";
message Person {
  uint64 id = 1;
  string email = 2;
  bool is_active = 3;
  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }
   message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
   }

  repeated PhoneNumber phones = 4;
}

从上面的示例中,我们可以看到一条消息以message关键字声明,后跟用户定义的消息名称。文字或组件在大括号内声明。每个文字字段可以分为四个部分。他们是:

字段规则

在proto2 的Protobuf的版本,有规则required,optional和repeated,在名字段类型或数据类型之前。在proto3中对此进行了优化,仅repeated保留了规则。如果该字段表示相同类型的元素数组,则称该字段为重复字段。如果不重复该字段,则不应添加任何规则。

字段类型

字段可以容纳的数据类型分为三类。 第一个是标量数据类型,例如字符串和数字。第二种是enum数据类型。在我们的示例中,这是PhoneType。最终的数据类型是嵌入式消息(如PhoneNumber本例中的)。 像在JSON和XML中一样,当使用消息类型时,我们可以构建消息的层次结构来表示任何类型的数据。在的Protobuf提供的标量数据类型float,int32,int64,uint32,uint64,sint32,sint64,fixed32,fixed64,sfixed32,sfixed6,bool,string,和bytes。

字段名称

在Protobuf中命名字段时,要遵循一些约定,因为Protoc编译器会假定这些约定,因为它会根据.proto所选语言为文件生成代码。第一个约定是字段名称应全部小写。其次,如果字段名称中有多个单词,则应将它们用下划线分隔。

字段标签

字段标签是字段的数字表示形式,这使我们能够在定义中具有定义丰富的字段名称,而无需通过实际发送它们。 使用字段标签时,需要考虑某些事项。首先,字段标签在消息中必须唯一。其次,它们必须是整数。第三,如果要将字段从已经使用的定义中删除,则必须声明其标签reserved以防止重新定义它。例:reserved 8;

从原始文件生成代码

Protobuf最重要的组件是protoc编译器。protoc的安装如下:

Mac

brew tap homebrew/versions
brew install protobuf

Ubuntu

sudo apt install protobuf-compiler

安装之后需要测试

#测试
protoc --versions

解释协议从.proto文件到不同语言生成代码的方式确实具有挑战性,因为各种生成代码的语法会有所不同。因此,这里只用JavaScript演示。我建议大家参考您喜欢的语言的官方文档。 首先,让我们创建一个小.proto文件。

syntax = "proto3";
message Person {
  uint64 id = 1;
  string email = 2;
}

生成代码:

protoc --proto_path=src --js_out=js_out_folder src/person.proto

结果类似:

proto.Person = function(opt_data) {
  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
};
goog.inherits(proto.Person, jspb.Message);
proto.Person.prototype.getId = function() {
  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0));
};
proto.Person.prototype.setId = function(value) {
  return jspb.Message.setProto3IntField(this, 1, value);
};
proto.Person.prototype.getEmail = function() {
  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
};
proto.Person.prototype.setEmail = function(value) {
  return jspb.Message.setProto3StringField(this, 2, value);
};

我们可以看到Person创建了一个类,并为字段id和定义了getter和setter方法email。 根据语言,语法会有所不同。

结论

我们已经看到,协议缓冲区的设计比常规数据传输格式(如JSON或XML)更加高效和快速。数据压缩可以提高速度并减少CPU使用率。我不能断言Protobuf是JSON的简单替代品,但它解决了JSON的许多问题,尤其是在使用微服务架构时。 一种折衷方案是大大降低了数据的可读性。与优点相比,这并不是一个很大的缺点。

Protocol Buffer 编码原理

在讨论 Protocol Buffer 编码原理之前,必须先谈谈 Varints 编码。

Base 128 Varints 编码

Varint 是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。

Varint 中的每个字节(最后一个字节除外)都设置了最高有效位(msb),这一位表示还会有更多字节出现。每个字节的低 7 位用于以 7 位组的形式存储数字的二进制补码表示,最低有效组首位。

如果用不到 1 个字节,那么最高有效位设为 0 ,如下面这个例子,1 用一个字节就可以表示,所以 msb 为 0.

0000 0001

如果需要多个字节表示,msb 就应该设置为 1 。例如 300,如果用 Varint 表示的话:

1010 1100 0000 0010

如果按照正常的二进制计算的话,这个表示的是 88068(65536 + 16384 + 4096 + 2048 + 4)。

那 Varint 是怎么编码的呢?

下面代码是 Varint int 32 的编码计算方法。

char* EncodeVarint32(char* dst, uint32_t v) {
  // Operate on characters as unsigneds
  unsigned char* ptr = reinterpret_cast<unsigned char*>(dst);
  static const int B = 128;
  if (v < (1<<7)) {
    *(ptr++) = v;
  } else if (v < (1<<14)) {
    *(ptr++) = v | B;
    *(ptr++) = v>>7;
  } else if (v < (1<<21)) {
    *(ptr++) = v | B;
    *(ptr++) = (v>>7) | B;
    *(ptr++) = v>>14;
  } else if (v < (1<<28)) {
    *(ptr++) = v | B;
    *(ptr++) = (v>>7) | B;
    *(ptr++) = (v>>14) | B;
    *(ptr++) = v>>21;
  } else {
    *(ptr++) = v | B;
    *(ptr++) = (v>>7) | B;
    *(ptr++) = (v>>14) | B;
    *(ptr++) = (v>>21) | B;
    *(ptr++) = v>>28;
  }
  return reinterpret_cast<char*>(ptr);
}
300 = 100101100

由于 300 超过了 7 位(Varint 一个字节只有 7 位能用来表示数字,最高位 msb 用来表示后面是否有更多字节),所以 300 需要用 2 个字节来表示。

Varint 的编码,以 300 举例:

if (v < (1<<14)) {
    *(ptr++) = v | B;
    *(ptr++) = v>>7;
}
1. 100101100 | 10000000 = 1 1010 1100
2. 110101100 取出末尾 7 位 = 010 1100
3. 100101100 >> 7 = 10 = 0000 0010
4. 1010 1100 0000 0010 (最终 Varint 结果)

Varint 的解码算法应该是这样的:(实际就是编码的逆过程)

  • 如果是多个字节,先去掉每个字节的 msb(通过逻辑或运算),每个字节只留下 7 位。
  • 逆序整个结果,最多是 5 个字节,排序是 1-2-3-4-5,逆序之后就是 5-4-3-2-1,字节内部的二进制位的顺序不变,变的是字节的相对位置。

解码过程调用 GetVarint32Ptr 函数,如果是大于一个字节的情况,会调用 GetVarint32PtrFallback 来处理。

inline const char* GetVarint32Ptr(const char* p,
                                  const char* limit,
                                  uint32_t* value) {
  if (p < limit) {
    uint32_t result = *(reinterpret_cast<const unsigned char*>(p));
    if ((result & 128) == 0) {
      *value = result;
      return p + 1;
    }
  }
  return GetVarint32PtrFallback(p, limit, value);
}

const char* GetVarint32PtrFallback(const char* p,
                                   const char* limit,
                                   uint32_t* value) {
  uint32_t result = 0;
  for (uint32_t shift = 0; shift <= 28 && p < limit; shift += 7) {
    uint32_t byte = *(reinterpret_cast<const unsigned char*>(p));
    p++;
    if (byte & 128) {
      // More bytes are present
      result |= ((byte & 127) << shift);
    } else {
      result |= (byte << shift);
      *value = result;
      return reinterpret_cast<const char*>(p);
    }
  }
  return NULL;
}

至此,Varint 处理过程读者应该都熟悉了。上面列举出了 Varint 32 的算法,64 位的同理,只不过不再用 10 个分支来写代码了,太丑了。(32位 是 5 个 字节,64位 是 10 个字节)

64 位 Varint 编码实现:

char* EncodeVarint64(char* dst, uint64_t v) {
  static const int B = 128;
  unsigned char* ptr = reinterpret_cast<unsigned char*>(dst);
  while (v >= B) {
    *(ptr++) = (v & (B-1)) | B;
    v >>= 7;
  }
  *(ptr++) = static_cast<unsigned char>(v);
  return reinterpret_cast<char*>(ptr);
}

原理不变,只不过用循环来解决了。

64 位 Varint 解码实现:

const char* GetVarint64Ptr(const char* p, const char* limit, uint64_t* value) {
  uint64_t result = 0;
  for (uint32_t shift = 0; shift <= 63 && p < limit; shift += 7) {
    uint64_t byte = *(reinterpret_cast<const unsigned char*>(p));
    p++;
    if (byte & 128) {
      // More bytes are present
      result |= ((byte & 127) << shift);
    } else {
      result |= (byte << shift);
      *value = result;
      return reinterpret_cast<const char*>(p);
    }
  }
  return NULL;
}

读到这里可能有读者会问了,Varint 不是为了紧凑 int 的么?那 300 本来可以用 2 个字节表示,现在还是 2 个字节了,哪里紧凑了,花费的空间没有变啊?!

Varint 确实是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。比如对于 int32 类型的数字,一般需要 4 个 byte 来表示。但是采用 Varint,对于很小的 int32 类型的数字,则可以用 1 个 byte 来表示。当然凡事都有好的也有不好的一面,采用 Varint 表示法,大的数字则需要 5 个 byte 来表示。从统计的角度来说,一般不会所有的消息中的数字都是大数,因此大多数情况下,采用 Varint 后,可以用更少的字节数来表示数字信息。

300 如果用 int32 表示,需要 4 个字节,现在用 Varint 表示,只需要 2 个字节了。缩小了一半!

Message Structure 编码

protocol buffer 中 message 是一系列键值对。message 的二进制版本只是使用字段号(field’s number 和 wire_type)作为 key。每个字段的名称和声明类型只能在解码端通过引用消息类型的定义(即 .proto 文件)来确定。这一点也是人们常常说的 protocol buffer 比 JSON,XML 安全一点的原因,如果没有数据结构描述 .proto 文件,拿到数据以后是无法解释成正常的数据的。

由于采用了 tag-value 的形式,所以 option 的 field 如果有,就存在在这个 message buffer 中,如果没有,就不会在这里,这一点也算是压缩了 message 的大小了。

当消息编码时,键和值被连接成一个字节流。当消息被解码时,解析器需要能够跳过它无法识别的字段。这样,可以将新字段添加到消息中,而不会破坏不知道它们的旧程序。这就是所谓的 “向后”兼容性。

为此,线性的格式消息中每对的“key”实际上是两个值,其中一个是来自.proto文件的字段编号,加上提供正好足够的信息来查找下一个值的长度。在大多数语言实现中,这个 key 被称为 tag。

注意上图中,3 和 4 已经被废弃了,所以 wire_type 取值目前只有 0、1、2、5。

key 的计算方法是 (field_number << 3) | wire_type,换句话说,key 的最后 3 位表示的就是 wire_type。

举例,一般 message 的字段号都是 1 开始的,所以对应的 tag 可能是这样的:

000 1000

末尾 3 位表示的是 value 的类型,这里是 000,即 0 ,代表的是 varint 值。右移 3 位,即 0001,这代表的就是字段号(field number)。tag 的例子就举这么多,接下来举一个 value 的例子,还是用 varint 来举例:

96 01 = 1001 0110  0000 0001
       → 000 0001  ++  001 0110 (drop the msb and reverse the groups of 7 bits)
       → 10010110
       → 128 + 16 + 4 + 2 = 150

可以 96 01 代表的数据就是 150 。

message Test1 {
  required int32 a = 1;
}

如果存在上面这样的一个 message 的结构,如果存入 150,在 Protocol Buffer 中显示的二进制应该为 08 96 01 。

额外说一句,type 需要注意的是 type = 2 的情况,tag 里面除了包含 field number 和 wire_type ,还需要再包含一个 length,决定 value 从那一段取出来。(具体原因见 Protocol Buffer 字符串 这一章节)

Signed Integers 编码

从上面的表格里面可以看到 wire_type = 0 中包含了无符号的 varints,但是如果是一个无符号数呢?

一个负数一般会被表示为一个很大的整数,因为计算机定义负数的符号位为数字的最高位。如果采用 Varint 表示一个负数,那么一定需要 10 个 byte 长度。

为何 32 位和 64 位的负数都需要 10 个 byte 长度呢?

void CodedOutputStream::WriteVarint32SignExtended(int32 value) {
 WriteVarint64(static_cast<uint64>(value));
}

因为源码里面是这么规定的。32 位的有符号数都会转换成 64 位无符号来处理。至于源码为什么要这么规定呢,猜想可能是怕 32 位的负数转换会有溢出的可能。(只是猜想)

为此 Google Protocol Buffer 定义了 sint32 这种类型,采用 zigzag 编码。将所有整数映射成无符号整数,然后再采用 varint 编码方式编码,这样,绝对值小的整数,编码后也会有一个较小的 varint 编码值。

Zigzag 映射函数为:

  • Zigzag(n) = (n << 1) ^ (n >> 31), n 为 sint32 时
  • Zigzag(n) = (n << 1) ^ (n >> 63), n 为 sint64 时

按照这种方法,-1 将会被编码成 1,1 将会被编码成 2,-2 会被编码成 3,如下表所示:

需要注意的是,第二个转换 (n >> 31) 部分,是一个算术转换。所以,换句话说,移位的结果要么是一个全为0(如果n是正数),要么是全部1(如果n是负数)。

当 sint32 或 sint64 被解析时,它的值被解码回原始的带符号的版本。

Non-varint Numbers

Non-varint 数字比较简单,double 、fixed64 的 wire_type 为 1,在解析时告诉解析器,该类型的数据需要一个 64 位大小的数据块即可。同理,float 和 fixed32 的 wire_type 为5,给其 32 位数据块即可。两种情况下,都是高位在后,低位在前。

说 Protocol Buffers 压缩数据没有到极限,原因就在这里,因为并没有压缩 float、double 这些浮点类型。

字符串

wire_type 类型为 2 的数据,是一种指定长度的编码方式:key + length + content,key 的编码方式是统一的,length 采用 varints 编码方式,content 就是由 length 指定长度的 Bytes。

举例,假设定义如下的 message 格式:

message Test2 {
  optional string b = 2;
}

设置该值为”testing”,二进制格式查看:

12 07 74 65 73 74 69 6e 67

74 65 73 74 69 6e 67 是“testing”的 UTF8 代码。

此处,key 是16进制表示的,所以展开是:

12 -> 0001 0010,后三位 010 为 wire type = 2,0001 0010 右移三位为 0000 0010,即 tag = 2。

length 此处为 7,后边跟着 7 个bytes,即我们的字符串”testing”。

所以 wire_type 类型为 2 的数据,编码的时候会默认转换为 T-L-V (Tag – Length – Value)的形式。

嵌入式 message

假设,定义如下嵌套消息:

message Test3 {
  optional Test1 c = 3;
}

设置字段为整数150,编码后的字节为:

1a 03 08 96 01

08 96 01 这三个代表的是 150,上面讲解过,这里就不再赘述了。

1a -> 0001 1010,后三位 010 为 wire type = 2,0001 1010 右移三位为 0000 0011,即 tag = 3。

length 为 3,代表后面有 3 个字节,即 08 96 01 。

需要转变为 T – L – V 形式的还有 string, bytes, embedded messages, packed repeated fields (即 wire_type 为 2 的形式都会转变成 T – L – V 形式)

Optional 和 Repeated 的编码

在 proto2 中定义成 repeated 的字段,(没有加上 [packed=true] option ),编码后的 message 有一个或者多个包含相同 tag 数字的 key-value 对。这些重复的 value 不需要连续的出现;他们可能与其他的字段间隔的出现。尽管他们是无序的,但是在解析时,他们是需要有序的。在 proto3 中 repeated 字段默认采用 packed 编码(具体原因见Packed Repeated Fields这一章节)

对于 proto3 中的任何非重复字段或 proto2 中的可选字段,编码的 message 可能有也可能没有包含该字段号的键值对。

通常,编码后的 message,其 required 字段和 optional 字段最多只有一个实例。但是解析器却需要处理多对一的情况。对于数字类型和 string 类型,如果同一值出现多次,解析器接受最后一个它收到的值。对于内嵌字段,解析器合并(merge)它接收到的同一字段的多个实例。就如 MergeFrom 方法一样,所有单数的字段,后来的会替换先前的,所有单数的内嵌 message 都会被合并(merge),所有的 repeated 字段,都会串联起来。这样的规则的结果是,解析两个串联的编码后的 message,与分别解析两个 message 然后 merge,结果是一样的。例如:

MyMessage message;
message.ParseFromString(str1 + str2);

等价于

MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);

这种方法有时是非常有用的。比如,即使不知道 message 的类型,也能够将其合并。

Packed Repeated Fields

在 2.1.0 版本以后,protocol buffers 引入了该种类型,其与 repeated 字段一样,只是在末尾声明了 [packed=true]。类似 repeated 字段却又不同。在 proto3 中 Repeated 字段默认就是以这种方式处理。对于 packed repeated 字段,如果 message 中没有赋值,则不会出现在编码后的数据中。否则的话,该字段所有的元素会被打包到单一一个 key-value 对中,且它的 wire_type=2,长度确定。每个元素正常编码,只不过其前没有标签 tag。例如有如下 message 类型:

message Test4 {
  repeated int32 d = 4 [packed=true];
}

构造一个 Test4 字段,并且设置 repeated 字段 d 3个值:3,270和86942,编码后:

22 // tag 0010 0010(field number 010 0 = 4, wire type 010 = 2)
06 // payload size (设置的length = 6 bytes)
03 // first element (varint 3)
8E 02 // second element (varint 270)
9E A7 05 // third element (varint 86942)

形成了 Tag – Length – Value – Value – Value …… 对。

只有原始数字类型(使用varint,32位或64位)的重复字段才可以声明为“packed”。

有一点需要注意,对于 packed 的 repeated 字段,尽管通常没有理由将其编码为多个 key-value 对,编码器必须有接收多个 key-pair 对的准备。这种情况下,payload 必须是串联的,每个 pair 必须包含完整的元素。

Protocol Buffer 解析器必须能够解析被重新编译为 packed 的字段,就像它们未被 packed 一样,反之亦然。这允许以正向和反向兼容的方式将[packed = true]添加到现有字段。

Field Order

编码/解码与字段顺序无关,这一点由 key-value 机制保证。

如果消息具有未知字段,则当前的 Java 和 C++ 实现在按顺序排序的已知字段之后以任意顺序写入它们。当前的 Python 实现不会跟踪未知字段。

Protocol Buffer 的使用

Protocol Buffer的使用流程总体可以分为三步:

  • 根据业务创建并定义proto文件
  • 使用Google Protocol Buffer 提供的工具生成对应语言的源文件
  • 将源文件拷贝到工程中,使用Protocol Buffer提供的库序列化或反序列化数据

定义消息类型

先来看一个非常简单的例子。假设你想定义一个“搜索请求”的消息格式,每一个请求含有一个查询字符串、你感兴趣的查询结果所在的页数,以及每一页多少条查询结果。可以采用如下的方式来定义消息类型的.proto文件:

syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}
  • 该文件的第一行指定您正在使用proto3语法:如果您不这样做,protobuf 编译器将假定您正在使用proto2。这必须是文件的第一个非空的非注释行。
  • 所述SearchRequest消息定义指定了三个字段(名称/值对),一个用于要在此类型的消息中包含的每个数据片段。每个字段都有一个名称和类型。

指定字段类型

在上面的示例中,所有字段都是标量类型:两个整数(page_number和result_per_page)和一个字符串(query)。但是,您还可以为字段指定合成类型,包括枚举和其他消息类型。

分配标识号

正如上述文件格式,在消息定义中,每个字段都有唯一的一个数字标识符。这些标识符是用来在消息的二进制格式中识别各个字段的,一旦开始使用就不能够再改变。注:[1,15]之内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为那些频繁出现的消息元素保留 [1,15]之内的标识号。切记:要为将来有可能添加的、频繁出现的标识号预留一些标识号。

最小的标识号可以从1开始,最大到2^29 – 1, or 536,870,911。不可以使用其中的[19000-19999]的标识号, Protobuf协议实现中对这些进行了预留。如果非要在.proto文件中使用这些预留标识号,编译时就会报警。

指定字段规则

消息字段可以是以下之一:

  • singular:格式良好的消息可以包含该字段中的零个或一个(但不超过一个)。
  • repeated:此字段可以在格式良好的消息中重复任意次数(包括零)。将保留重复值的顺序。

在proto3中,repeated数字类型的字段默认使用packed编码。packed您可以在协议缓冲区编码中找到有关编码的更多信息。

添加更多消息类型

可以在单个.proto文件中定义多种消息类型。如果要定义多个相关消息,这很有用 – 例如,如果要定义与SearchResponse消息类型对应的回复消息格式,可以将其添加到相同的消息.proto:

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

message SearchResponse {
 ...
}

添加注释

要为.proto文件添加注释,请使用C / C ++ – 样式//和/* … */语法。

/ * SearchRequest表示搜索查询,带有分页选项
 *表明响应中包含哪些结果。* /

message SearchRequest {
  string query = 1;
  int32 page_number = 2; //我们想要哪个页码?
  int32 result_per_page = 3; //每页返回的结果数。
}

保留字段

如果通过完全删除字段或将其注释来更新消息类型,则未来用户可以在对类型进行自己的更新时重用字段编号。如果以后加载相同的旧版本,这可能会导致严重问题.proto,包括数据损坏,隐私错误等。确保不会发生这种情况的一种方法是指定已删除字段的字段编号(和/或名称,这也可能导致JSON序列化问题)reserved。如果将来的任何用户尝试使用这些字段标识符,协议缓冲编译器将会抱怨。

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}

请注意,您不能在同一reserved语句中混合字段名称和字段编号。

你的生成是什么.proto?

当您在终端上运行协议缓冲区编译器时.proto,编译器会生成您所选语言的代码,您需要使用您在文件中描述的消息类型,包括获取和设置字段值,将消息序列化为输出流,并从输入流解析您的消息。

  • 对于C ++,编译器会从每个文件生成一个.h和一个.cc文件.proto,并为您文件中描述的每种消息类型提供一个类。
  • 对于Java,编译器生成一个.java文件,其中包含每种消息类型的类,以及Builder用于创建消息类实例的特殊类。
  • Python有点不同 – Python编译器生成一个模块,其中包含每个消息类型的静态描述符,.proto然后与元类一起使用,以在运行时创建必要的Python数据访问类。
  • 对于Go,编译器会为.pb.go文件中的每种消息类型生成一个类型的文件。
  • 对于Ruby,编译器生成一个.rb包含消息类型的Ruby模块的文件。
  • 对于Objective-C,编译器从每个文件生成一个h和一个pbobjc.m文件.proto,其中包含文件中描述的每种消息类型的类。
  • 对于C#,编译器会.cs从每个文件生成一个文件.proto,其中包含文件中描述的每种消息类型的类。

您可以按照所选语言的教程了解有关为每种语言使用API的更多信息。

标量值类型

标量消息字段可以具有以下类型之一 – 该表显示.proto文件中指定的类型,以及自动生成的类中的相应类型:

.proto type notes C ++ type Java type Python type [2] Type Ruby type C# type PHP type
double double double float float64 float double float
float float float float FLOAT32 float float float
INT32 使用可变长度编码。编码负数的效率低 – 如果您的字段可能有负值,请改用sint32。 INT32 INT INT INT32 Fixnum or Bignum (as needed) INT Integer
Int64 使用可变长度编码。编码负数的效率低 – 如果您的字段可能有负值,请改用sint64。 Int64 long int / long [3] Int64 TWINS long Integer/string[5]
UINT32 使用可变长度编码。 UINT32 int [1] int / long [3] UINT32 Fixnum or Bignum (as needed) UINT Integer
UINT64 使用可变长度编码。 UINT64 Long [1] int / long [3] UINT64 TWINS ULONG Integer/string[5]
SINT32 使用可变长度编码。签名的int值。这些比常规int32更有效地编码负数。 INT32 INT INT INT32 Fixnum or Bignum (as needed) INT Integer
sint64 使用可变长度编码。签名的int值。这些比常规int64更有效地编码负数。 Int64 long int / long [3] Int64 TWINS long Integer/string[5]
fixed32 总是四个字节。如果值通常大于2 28,则比uint32更有效。 UINT32 int [1] int / long [3] UINT32 Fixnum or Bignum (as needed) UINT Integer
fixed64 总是八个字节。如果值通常大于2 56,则比uint64更有效。 UINT64 Long [1] int / long [3] UINT64 TWINS ULONG Integer/string[5]
sfixed32 总是四个字节。 INT32 INT INT INT32 Fixnum or Bignum (as needed) INT Integer
sfixed64 总是八个字节。 Int64 long int / long [3] Int64 TWINS long Integer/string[5]
Boolean Boolean Boolean Boolean Boolean TrueClass / FalseClass Boolean Boolean
string 字符串必须始终包含UTF-8编码或7位ASCII文本。 string string str / unicode[4] string String (UTF-8) string string
byte 可以包含任意字节序列。 string Byte string Strait []byte String (ASCII-8BIT) Byte string string

在协议缓冲区编码中序列化消息时,您可以找到有关如何编码这些类型的更多信息。

  • 在Java中,无符号的32位和64位整数使用它们的带符号对应表示,最高位只是存储在符号位中。
  • 在所有情况下,将值设置为字段将执行类型检查以确保其有效。
  • 64位或无符号32位整数在解码时始终表示为long,但如果在设置字段时给出int,则可以为int。在所有情况下,该值必须适合设置时表示的类型。见[2]。
  • Python字符串在解码时表示为unicode,但如果给出了ASCII字符串,则可以是str。
  • Integer用于64位计算机,字符串用于32位计算机。

默认值

解析消息时,如果编码消息不包含特定的singular元素,则解析对象中的相应字段将设置为该字段的默认值。这些默认值是特定于类型的:

  • 对于字符串,默认值为空字符串。
  • 对于字节,默认值为空字节。
  • 对于bools,默认值为false。
  • 对于数字类型,默认值为零。
  • 对于枚举,默认值是第一个定义的枚举值,该值必须为0。
  • 对于消息字段,未设置该字段。它的确切值取决于语言。

重复字段的默认值为空(通常是相应语言的空列表)。

请注意,对于标量消息字段,一旦解析了消息,就无法确定字段是否显式设置为默认值(例如,是否设置了布尔值false)或者根本没有设置:您应该记住这一点在定义消息类型时。例如,false如果您不希望默认情况下也发生这种行为,那么在设置为时,没有一个布尔值可以启用某些行为。还要注意的是,如果一个标消息字段被设置为默认值,则这个值不应该被序列化传输。

有关默认值如何在生成的代码中工作的更多详细信息,请参阅所选语言的生成代码指南。

枚举

在定义消息类型时,您可能希望其中一个字段只有一个预定义的值列表。例如,假设你想添加一个 corpus字段每个SearchRequest,其中语料库可以 UNIVERSAL,WEB,IMAGES,LOCAL,NEWS,PRODUCTS或VIDEO。您可以非常简单地通过enum为每个可能的值添加一个常量来定义消息定义。

在下面的示例中,我们添加了一个带有所有可能值的enum调用Corpus,以及一个类型的字段Corpus:

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
  }
  Corpus corpus = 4;
}

如您所见,Corpus枚举的第一个常量映射为零:每个枚举定义必须包含一个映射到零的常量作为其第一个元素。这是因为:

  • 必须有一个零值,以便我们可以使用0作为数字默认值。
  • 零值必须是第一个元素,以便与proto2语义兼容,其中第一个枚举值始终是默认值。

您可以通过为不同的枚举常量指定相同的值来定义别名。为此,您需要将allow_alias选项设置为true,否则协议编译器将在找到别名时生成错误消息。

enum EnumAllowingAlias {
  option allow_alias = true;
  UNKNOWN = 0;
  STARTED = 1;
  RUNNING = 1;
}
enum EnumNotAllowingAlias {
  UNKNOWN = 0;
  STARTED = 1;
  // RUNNING = 1;  // Uncommenting this line will cause a compile error inside Google and a warning message outside.
}

枚举器常量必须在32位整数范围内。由于enum值在线上使用varint编码,因此负值效率低,因此不推荐在enum中使用负数。,如上例所示,enum也可以在外部定义 – 这些可以在.proto文件的任何消息定义中重用。您还可以使用enum语法将一个消息中声明的类型用作另一个消息中的字段类型。如:MessageType.EnumType

当对一个使用了枚举的.proto文件运行protocol buffer编译器的时候,生成的代码中将有一个对应的enum(对Java或C++来说),或者一个特殊的EnumDescriptor类(对 Python来说),它被用来在运行时生成的类中创建一系列的整型值符号常量(symbolic constants)。

在反序列化期间,将在消息中保留无法识别的枚举值,但是当反序列化消息时,如何表示这种值取决于语言。在支持具有超出指定符号范围的值的开放枚举类型的语言中,例如C ++和Go,未知的枚举值仅作为其基础整数表示存储。在具有封闭枚举类型(如Java)的语言中,枚举中的大小写用于表示无法识别的值,并且可以使用特殊访问器访问基础整数。在任何一种情况下,如果消息被序列化,则仍然会使用消息序列化无法识别的值。

有关如何enum在应用程序中使用消息的详细信息,请参阅所选语言的生成代码指南。

保留值

如果通过完全删除枚举条目或将其注释掉来更新枚举类型,则未来用户可以在对类型进行自己的更新时重用该数值。如果以后加载相同的旧版本,这可能会导致严重问题.proto,包括数据损坏,隐私错误等。确保不会发生这种情况的一种方法是指定已删除条目的数值(和/或名称,这也可能导致JSON序列化问题)reserved。如果将来的任何用户尝试使用这些标识符,协议缓冲编译器将会抱怨。您可以使用max关键字指定保留的数值范围达到最大可能值。

enum Foo {
  reserved 2, 15, 9 to 11, 40 to max;
  reserved "FOO", "BAR";
}

请注意,您不能在同一reserved语句中混合字段名称和数值。

使用其他消息类型

您可以使用其他消息类型作为字段类型。例如,假设在每一个 SearchResponse消息中包含Result消息,此时可以在相同的.proto文件中定义一个Result消息类型,然后在SearchResponse消息中指定一个Result类型的字段,如:

message SearchResponse {
  repeated Result results = 1;
}

message Result {
  string url = 1;
  string title = 2;
  repeated string snippets = 3;
}

导入定义

在上面的示例中,Result消息类型在同一文件中定义SearchResponse- 如果要用作字段类型的消息类型已在另一个.proto文件中定义,该怎么办?

您可以.proto通过导入来使用其他文件中的定义。要导入其他.proto人的定义,请在文件顶部添加import语句:

import "myproject / other_protos.proto";

默认情况下,您只能使用直接导入.proto文件中的定义。但是,有时您可能需要将.proto文件移动到新位置。.proto现在,您可以.proto在旧位置放置一个虚拟文件,以使用该import public概念将所有导入转发到新位置,而不是直接移动文件并在一次更改中更新所有调用站点。import public任何导入包含该import public语句的proto的人都可以传递依赖关系。例如:

// new.proto
// All definitions are moved here
// old.proto
//This is the proto that all clients are importing.
import public "new.proto";
import "other.proto";
// client.proto
import "old.proto";
//您使用old.proto和new.proto中的定义,但不使用other.proto

协议编译器使用-I/ –proto_pathflag 在协议编译器命令行中指定的一组目录中搜索导入的文件 。如果没有给出标志,它将查找调用编译器的目录。通常,您应该将–proto_path标志设置为项目的根目录,并对所有导入使用完全限定名称。

使用proto2消息类型

可以导入proto2消息类型并在proto3消息中使用它们,反之亦然。但是,proto2枚举不能直接用于proto3语法(如果导入的proto2消息使用它们就可以了)。

嵌套类型

您可以在其他消息类型中定义和使用消息类型,如下例所示 – 此处Result消息在消息中定义SearchResponse:

message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  repeated Result results = 1;
}

如果要在其父消息类型之外重用此消息类型,请将其称为:Parent.Type

message SomeOtherMessage {
  SearchResponse.Result result = 1;
}

您可以根据需要深入嵌套消息:

message Outer {                  // Level 0
  message MiddleAA {  // Level 1
    message Inner {   // Level 2
      int64 ival = 1;
      bool  booly = 2;
    }
  }
  message MiddleBB {  // Level 1
    message Inner {   // Level 2
      int32 ival = 1;
      bool  booly = 2;
    }
  }
}

更新消息类型

如果现有的消息类型不再满足您的所有需求 – 例如,您希望消息格式具有额外的字段 – 但您仍然希望使用使用旧格式创建的代码,请不要担心!在不破坏任何现有代码的情况下更新消息类型非常简单。请记住以下规则:

  • 请勿更改任何现有字段的字段编号。
  • 如果添加新字段,则使用“旧”消息格式按代码序列化的任何消息仍可由新生成的代码进行解析。您应该记住这些元素的默认值,以便新代码可以正确地与旧代码生成的消息进行交互。同样,您的新代码创建的消息可以由旧代码解析:旧的二进制文件在解析时只是忽略新字段。有关详细信息,请参阅“ 未知字段”部分
  • 只要在更新的消息类型中不再使用字段编号,就可以删除字段。您可能希望重命名该字段,可能添加前缀“OBSOLETE_”,或者保留字段编号,以便您的未来用户.proto不会意外地重复使用该编号。
  • int32,uint32,int64,uint64,和bool都是兼容的-这意味着你可以改变这些类型到另一个的一个场不破坏forwards-或向后兼容。如果从导线中解析出一个不符合相应类型的数字,您将获得与在C ++中将该数字转换为该类型相同的效果(例如,如果将64位数字作为int32读取,它将被截断为32位)。
  • sint32并且sint64彼此兼容但与其他整数类型不兼容。
  • stringbytes只要字节是有效的UTF-8 ,它们是兼容的。
  • bytes如果字节包含消息的编码版本,则嵌入消息是兼容的。
  • fixed32与兼容sfixed32,并fixed64用sfixed64。
  • enum与兼容int32,uint32,int64,和uint64电线格式条款(注意,如果他们不适合的值将被截断)。但请注意,在反序列化消息时,客户端代码可能会以不同方式对待它们:例如,enum将在消息中保留未识别的proto3 类型,但在反序列化消息时如何表示这种类型取决于语言。Int字段总是保留它们的价值。
  • 将单个值更改为新成员oneof是安全且二进制兼容的。oneof如果您确定没有代码一次设置多个字段,则将多个字段移动到新字段可能是安全的。将任何字段移动到现有字段oneof并不安全。

未知字段

未知字段是格式良好的协议缓冲区序列化数据,表示解析器无法识别的字段。例如,当旧二进制文件解析具有新字段的新二进制文件发送的数据时,这些新字段将成为旧二进制文件中的未知字段。

最初,proto3消息在解析期间总是丢弃未知字段,但在3.5版本中,我们重新引入了保存未知字段以匹配proto2行为。在版本3.5及更高版本中,未知字段在解析期间保留并包含在序列化输出中。

Any

Any类型消息允许你在没有指定他们的.proto定义的情况下使用消息作为一个嵌套类型。一个Any含有任意的序列化消息bytes,以充当一个全局唯一标识符和解析到该消息的类型的URL一起。要使用该Any类型,您需要导入google/protobuf/any.proto。

import "google/protobuf/any.proto";

message ErrorStatus {
  string message = 1;
  repeated google.protobuf.Any details = 2;
}

给定消息类型的默认类型URL是。 type.googleapis.com/packagename.messagename

不同的语言实现将支持运行时库佣工类型安全的方式打包和解包的任何值-例如,在Java中,任何类型都会有特殊pack()和unpack()存取,而在C ++中有PackFrom()和UnpackTo()方法:

// Storing an arbitrary message type in Any.
NetworkErrorDetails details = ...;
ErrorStatus status;
status.add_details()->PackFrom(details);

// Reading an arbitrary message from Any.
ErrorStatus status = ...;
for (const Any& detail : status.details()) {
  if (detail.Is<NetworkErrorDetails>()) {
    NetworkErrorDetails network_error;
    detail.UnpackTo(&network_error);
    ... processing network_error ...
  }
}

目前,正在开发用于处理Any类型的运行时库。

如果您已熟悉proto2语法,则Any类型将替换扩展。

Oneof

如果您有一个包含许多字段的消息,并且最多只能同时设置一个字段,则可以使用oneof功能强制执行此行为并节省内存。

除了一个共享内存中的所有字段之外,其中一个字段类似于常规字段,并且最多可以同时设置一个字段。设置oneof的任何成员会自动清除所有其他成员。您可以使用特殊case()或WhichOneof()方法检查oneof中的哪个值(如果有),具体取决于您选择的语言。

使用Oneof

要在您中定义oneof,请.proto使用oneof关键字后跟您的oneof名称,在这种情况下test_oneof:

message SampleMessage {
  oneof test_oneof {
    string name = 4;
    SubMessage sub_message = 9;
  }
}

然后,将oneof字段添加到oneof定义中。您可以添加任何类型的字段,但不能使用repeated字段。

在生成的代码中,oneof字段与常规字段具有相同的getter和setter。您还可以使用特殊方法检查oneof中的值(如果有)。您可以在相关API参考中找到有关所选语言的oneof API的更多信息。

Oneof 特性

  • 设置oneof字段将自动清除其它oneof字段的值. 所以设置多次后,只有最后一次设置的字段有值.
SampleMessage message;
message.set_name("name");
CHECK(message.has_name());
message.mutable_sub_message();   // Will clear name field.
CHECK(!message.has_name());
  • 如果解析器在线路上遇到同一个oneof的多个成员,则在解析的消息中仅使用看到的最后一个成员。
  • oneof不支持repeated。
  • 反射API对oneof 字段有效。
  • 如果使用C++,需确保代码不会导致内存泄漏. 下面的代码会崩溃, 因为sub_message 已经通过set_name()删除了
SampleMessage message;
SubMessage* sub_message = message.mutable_sub_message();
message.set_name("name");      // Will delete sub_message
sub_message->set_...            // Crashes here 
  • 在C++中,如果你使用Swap()两个oneof消息,每个消息,两个消息将拥有对方的值,例如在下面的例子中,msg1会拥有sub_message并且msg2会有name。
SampleMessage msg1;
msg1.set_name("name");
SampleMessage msg2;
msg2.mutable_sub_message();
msg1.swap(&msg2);
CHECK(msg1.has_sub_message());
CHECK(msg2.has_name());

向后兼容性问题

添加或删除其中一个字段时要小心。如果检查oneof返回的值None/ NOT_SET,这可能意味着oneof尚未设置或已在不同版本的oneof的被设置为一个字段。没有办法区分,因为没有办法知道线上的未知字段是否是其中一个成员。

标签重用问题

  • 将字段移入或移出oneof:在序列化和解析消息后,您可能会丢失一些信息(某些字段将被清除)。但是,您可以安全地将单个字段移动到新的oneof中,并且如果已知只有一个字段被设置,则可以移动多个字段。
  • 删除oneof字段并将其添加回:在序列化和解析消息后,这可能会清除当前设置的oneof字段。
  • 拆分或合并oneof:这与移动常规字段有类似的问题。

Maps(映射)

如果要在数据定义中创建关联映射,协议缓冲区提供了一种方便的快捷方式语法:

map < key_type ,value_type > map_field = N ;

其中key_type可以是任何整数或字符串类型(因此,除了浮点和bytes类型之外的任何标量类型)。请注意,枚举不是有效的key_type。value_type可以是任何类型。

因此,例如,如果要创建项目映射,每个Projecct使用一个string作为key,则可以像下面这样定义它:

map <string ,Project> projects = 3 ;
  • map字段不能repeated。
  • 序列化后的顺序和map迭代器的顺序是不确定的,所以你不要期望以固定顺序处理Map
  • 当为.proto文件产生生成文本格式的时候,map会按照key 的顺序排序,数值化的key会按照数值排序。
  • 从序列化中解析或者融合时,如果有重复的key则后一个key不会被使用,当从文本格式中解析map时,如果存在重复的key,则解析可能会失败。
  • 如果为映射字段提供键但没有值,则字段序列化时的行为取决于语言。在C ++,Java和Python中,类型的默认值是序列化的,而在其他语言中没有任何序列化。

生成Map的API目前可用于所有proto3支持的语言。

向后兼容性

映射语法在线上等效于以下内容,因此不支持映射的协议缓冲区实现仍可处理您的数据:

message MapFieldEntry {
  key_type key = 1;
  value_type value = 2;
}

repeated MapFieldEntry map_field = N;

任何支持映射的协议缓冲区实现都必须生成和接受上述定义可以接受的数据。

您可以向.proto文件添加package可选说明符,以防止协议消息类型之间的名称冲突。

package foo.bar;
message Open { ... }

然后,您可以在定义消息类型的字段时使用包说明符:

message Foo {
  ...
  foo.bar.Open open = 1;
  ...
}

包说明符影响生成的代码的方式取决于您选择的语言:

  • 在C ++中,生成的类包含在C ++命名空间中。例如,Open将在命名空间中foo::bar。
  • 在Java中,该包用作Java包,除非您option java_package在.proto文件中明确提供了该包。
  • 在Python中,package指令被忽略,因为Python模块是根据它们在文件系统中的位置进行组织的。
  • 在Go中,该包用作Go包名称,除非您option go_package在.proto文件中明确提供。
  • 在Ruby中,生成的类包含在嵌套的Ruby命名空间内,转换为所需的Ruby大写形式(首字母大写;如果第一个字符不是字母,PB_则前置)。例如,Open将在命名空间中Foo::Bar。
  • 在C#中,包转换为PascalCase后用作命名空间,除非您option csharp_namespace在.proto文件中明确提供。例如,Open将在命名空间中Bar。

包和名称解析

Protocol buffer语言中类型名称的解析与C++是一致的:首先从最内部开始查找,依次向外进行,每个包会被看作是其父类包的内部类。当然对于 (foo.bar.Baz)这样以“.”分隔的意味着是从最外围开始的。

protobuf 编译器通过解析导入的.proto文件来解析所有类型名称。每种语言的代码生成器都知道如何使用该语言引用每种类型,即使它具有不同的范围规则。

定义服务

如果要将消息类型与RPC(远程过程调用)系统一起使用,则可以在.proto文件中定义RPC服务接口,protobuf 编译器将使用您选择的语言生成服务接口代码和存根。因此,例如,如果要定义RPC服务请求方法为:SearchRequest和返回方法为:SearchResponse,可以.proto按如下方式在文件中定义它:

service SearchService {
  rpc Search(SearchRequest)returns(SearchResponse);
}

与协议缓冲区一起使用的最简单的RPC系统是gRPC:一种由Google开发的,平台中立的开源RPC系统。gRPC特别适用于protobuf,并允许在您的.proto文件中使用特殊的protobuf 编译器插件直接生成相关的RPC代码。如果您不想使用gRPC,也可以将protobuf与您自己的RPC实现一起使用。您可以在Proto2语言指南中找到更多相关信息。还有一些正在进行的第三方项目使用Protocol Buffers开发RPC实现。

JSON映射

Proto3支持JSON中的规范编码,使得在系统之间共享数据变得更加容易。在下表中逐个类型地描述编码。

如果JSON编码数据中缺少值null,或者其值为,则在解析为协议缓冲区时,它将被解释为适当的默认值。如果字段在协议缓冲区中具有默认值,则默认情况下将在JSON编码数据中省略该字段以节省空间。实现可以提供用于在JSON编码的输出中发出具有默认值的字段的选项。

proto3 JSON JSON示例 笔记
message object {“fooBar”: v, “g”: null,…} 生成JSON对象。消息字段名称映射到小写驼峰并成为JSON对象键。如果json_name指定了field选项,则指定的值将用作键。解析器接受小写驼峰名称(或json_name选项指定的名称)和原始proto字段名称。null是所有字段类型的可接受值,并将其视为相应字段类型的默认值。
eunm String “FOO_BAR” 使用proto中指定的枚举值的名称。解析器接受枚举名称和整数值。
map object {“k”: v, …} 所有键都转换为字符串。
repeated V array [v, …] null 被接受为空列表[]。
bool true,false true, false
string string “Hello World!”
bytes base64 string “YWJjMTIzIT8kKiYoKSctPUB+” JSON值将是使用带填充的标准base64编码编码为字符串的数据。接受带有/不带填充的标准或URL安全base64编码。
int32,fixed32,uint32 string 1, -10, 0 JSON值将是十进制数。接受数字或字符串。
int64,fixed64,uint64 string “1”, “-10” JSON值将是十进制字符串。接受数字或字符串。
float,double number 1.1, -10.0, 0, “NaN”,”Infinity” JSON值将是一个数字或一个特殊字符串值“NaN”,“Infinity”和“-Infinity”。接受数字或字符串。指数表示法也被接受。
any object {“@type”: “url”, “f”: v, … } 如果Any包含具有特殊JSON映射的值,则将按如下方式进行转换:。否则,该值将转换为JSON对象,并将插入该字段以指示实际的数据类型。{“@type”: xxx, “value”: yyy}”@type”
Timestamp string “1972-01-01T10:00:20.021Z” 使用RFC 3339,其中生成的输出将始终被Z标准化并使用0,3,6或9个小数位。也接受“Z”以外的偏移。
Duration string “1.000340012s”, “1s” 生成的输出始终包含0,3,6或9个小数位,具体取决于所需的精度,后跟后缀“s”。接受的是任何小数位(也没有),只要它们符合纳秒精度并且后缀“s”是必需的。
Struct object { … } 任何JSON对象。见。struct.proto
Wrapper types various types 2, “2”, “foo”, true,”true”, null, 0, … 包装器在JSON中使用与包装基元类型相同的表示形式,除了null在数据转换和传输期间允许和保留的表示形式。
FieldMask string “f.fooBar,h” 见。field_mask.proto
ListValue array [foo, bar, …]
Value value 任何JSON值
NullValue null JSON null
Empty object {} An empty JSON object

JSON选项

proto3 JSON实现可以提供以下选项:

  • 使用默认值发出字段:默认情况下,proto3 JSON输出中省略了具有默认值的字段。实现可以提供覆盖此行为的选项,并使用其默认值输出字段。
  • 忽略未知字段:默认情况下,Proto3 JSON解析器应拒绝未知字段,但可以提供忽略解析中未知字段的选项。
  • 使用proto字段名称而不是小写驼峰名称:默认情况下,proto3 JSON打印机应将字段名称转换为小写驼峰并将其用作JSON名称。实现可以提供使用proto字段名称作为JSON名称的选项。Proto3 JSON解析器需要接受转换后的小写驼峰名称和proto字段名称。
  • 将枚举值发送为整数而不是字符串:默认情况下,在JSON输出中使用枚举值的名称。可以提供选项以使用枚举值的数值。

选项

.proto文件中的各个声明可以使用许多选项进行注释。选项不会更改声明的整体含义,但可能会影响在特定上下文中处理它的方式。可用选项的完整列表在中定义google/protobuf/descriptor.proto。

一些选项是文件级选项,这意味着它们应该在顶级范围内编写,而不是在任何消息,枚举或服务定义中。一些选项是消息级选项,这意味着它们应该写在消息定义中。一些选项是字段级选项,这意味着它们应该写在字段定义中。选项也可以写在枚举类型,枚举值,服务类型和服务方法上; 但是,目前没有任何有用的选择。

以下是一些最常用的选项:

  • java_package(文件选项):用于生成的Java类的包。如果.proto文件中没有给出显式选项java_package,则默认情况下将使用proto包(使用文件中的“package”关键字指定 .proto )。但是,proto包通常不能生成好的Java包,因为proto包不会以反向域名开头。如果不生成Java代码,则此选项无效。
option java_package =“com.example.foo”;
  • java_multiple_files (文件选项):导致在包级别定义顶级消息,枚举和服务,而不是在.proto文件之后命名的外部类中。
option java_multiple_files = true;
  • java_outer_classname(file option):要生成的最外层Java类(以及文件名)的类名。如果.proto文件中没有指定 java_outer_classname,则通过将.proto文件名转换为驼峰格式(因此proto 成为FooBar.java)来构造类名。如果不生成Java代码,则此选项无效。
  option java_outer_classname =“Ponycopter”;
  • optimize_for(文件选项):可以设置为SPEED,CODE_SIZE或LITE_RUNTIME。这会以下列方式影响C ++和Java代码生成器(可能还有第三方生成器):
    • SPEED(默认值):protobuf 编译器将生成用于对消息类型进行序列化,解析和执行其他常见操作的代码。此代码经过高度优化。
    • CODE_SIZE:protobuf 编译器将生成最少的类,并依赖于基于反射的共享代码来实现序列化,解析和各种其他操作。因此生成的代码比使用SPEED小得多,但操作会更慢。类仍将实现与SPEED模式完全相同的公共API 。此模式在包含非常大数量的.proto文件的应用程序中最有用,并且不需要所有文件都非常快速。
    • LITE_RUNTIME:protobuf 编译器将生成仅依赖于“lite”运行时库(libprotobuf-lite而不是libprotobuf)的类。精简版运行时比整个库小得多(大约小一个数量级),但省略了描述符和反射等特定功能。这对于在移动电话等受限平台上运行的应用程序尤其有用。编译器仍然会像在SPEED模式中一样生成所有方法的快速实现。生成的类将仅实现MessageLite每种语言的接口,该接口仅提供完整Message接口的方法的子集。
option optimize_for = CODE_SIZE;
  • cc_enable_arenas(文件选项):为C ++生成的代码启用竞技场分配。
  • objc_class_prefix(文件选项):设置Objective-C类前缀,该前缀预先添加到此.proto的所有Objective-C生成的类和枚举中。没有默认值。您应该使用Apple建议的3-5个大写字符之间的前缀。请注意,Apple保留所有2个字母的前缀。
  • deprecated(字段选项):如果设置为true,则表示该字段已弃用,新代码不应使用该字段。在大多数语言中,这没有实际效果。在Java中,这成为一个@Deprecated注释。将来,其他特定于语言的代码生成器可能会在字段的访问器上生成弃用注释,这将导致在编译尝试使用该字段的代码时发出警告。如果任何人都没有使用该字段,并且您希望阻止新用户使用该字段,请考虑使用保留语句替换字段声明。
  int32 old_field = 6 [deprecated = true];

自定义选项

Protocol Buffers还允许您定义和使用自己的选项。这是大多数人不需要的高级功能。如果您确实认为需要创建自己的选项,请参阅Proto2语言指南以获取详细信息。请注意,创建自定义选项使用的扩展名仅允许用于proto3中的自定义选项。

生成您的类

根据实际工作需要,生成以下对应语言的自定义消息类型Java,Python,C ++,Go, Ruby, Objective-C,或C#的.proto文件,你需要运行protobuf 编译器protoc上.proto。如果尚未安装编译器,请下载该软件包并按照自述文件中的说明进行操作。对于Go,您还需要为编译器安装一个特殊的代码生成器插件:您可以在GitHub上的golang / protobuf存储库中找到这个和安装说明。

Protobuf 编译器的调用如下:

protoc --proto_path = IMPORT_PATH --cpp_out = DST_DIR --java_out = DST_DIR --python_out = DST_DIR --go_out = DST_DIR --ruby_out = DST_DIR --objc_out = DST_DIR --csharp_out = DST_DIR  path / to / file .proto

IMPORT_PATH指定.proto解析import指令时在其中查找文件的目录。如果省略,则使用当前目录。可以通过–proto_path多次传递选项来指定多个导入目录; 他们将按顺序搜索。 可以用作简短的形式。 -I=*IMPORT_PATH*–proto_path您可以提供一个或多个输出指令:

  • –cpp_out生成C ++代码DST_DIR。
  • –java_out生成Java代码DST_DIR。
  • –python_out生成Python代码DST_DIR。
  • –go_out生成Go代码DST_DIR。
  • –ruby_out生成Ruby代码DST_DIR。
  • –objc_out生成Objective-C代码DST_DIR。
  • –csharp_out生成C#代码DST_DIR。
  • –php_out生成PHP代码DST_DIR。

为了方便起见,如果DST_DIR结束于.zip或.jar,编译器会将输出写入具有给定名称的单个ZIP格式存档文件。.jar输出还将根据Java JAR规范的要求提供清单文件。请注意,如果输出存档已存在,则会被覆盖; 编译器不够智能,无法将文件添加到现有存档中。

您必须提供一个或多个.proto文件作为输入。.proto可以一次指定多个文件。虽然文件是相对于当前目录命名的,但每个文件必须位于其中一个文件中,IMPORT_PATH以便编译器可以确定其规范名称。

参考链接:

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注