搞定Protocol Buffers (上)- 使用篇

光华路程序猿

共 21329字,需浏览 43分钟

 · 2021-03-22

友情提示 因本文篇幅较长 如果觉得有用 建议收藏 需要时翻来看看。

详细原理部分 下篇见。

因为工作中gRPC使用非常频繁,而gRPC的默认序列化编码采用的也是Protocol Buffers。业界也盛传其效率及其高效:

  1. 序列化和反序列化速度快
  2. 数据压缩体积较小

关于Protocol Buffers计划写两篇:使用篇与原理篇。今天这篇使用篇大致分三个部分:俯瞰protocol bufferes基本工作机制、proto语法(大体是将官方网站翻译了一下)、protoc编译器命令。

什么是Protocol Buffers

借用官方说法:

Protocol buffers are Google's language-neutral, platform-neutral, extensible mechanism for serializing structured data – think XML, but smaller, faster, and simpler.

翻译过来就是:

  1. 语言无关
  2. 平台无关
  3. 具有可拓展机制
  4. 对结构化数据进行序列化
  5. 相比XML,更小更快更简单

protocol buffers 分为编译器protoc和运行时(以go为例protoc-gen-go)两部分。运行时就是不同语言对应的库, 以 Golang 为例:go get github.com/golang/protobuf/protoc-gen-go。用过Protocol Buffers的应该都知道,你只需要维护.proto文件即可,Protocol Buffers的编译器可以帮助你根据.proto文件生成指定语言的代码,你只需要调用生成的代码即可完成数据的序列化和反序列化。故而protocol buffers的使用通过分为两步:

  1. 编写.proto文件,并使用编译器编译指定语言的代码。

5ee7bb0cec911579beb0c658fa250c25.webp

protocol buffer
  1. 利用对应语言运行时库,进行序列化和反序列化传输。以gRPC服务为例

74f050f5c717d3dce4c3650b32308322.webp

protocol buffers runtime

编译器如何安装?

1. 源码编译方式

首先打开https://github.com/protocolbuffers/protobuf/releases选择你想要的版本,进行源代码的下载。

然后进入源码根目录执行以下命令

 ./autogen.sh
 ./configure
 make
 make install

2. 直接下载编译好的二进制文件(推荐)

同样打开https://github.com/protocolbuffers/protobuf/releases,选择你想要的版本和适用的平台即可。

3. 如果你用的mac book,可以使用homebrew直接安装

brew install protobuf

验证安装是否成功

protoc --version
libprotoc 3.15.5

proto语法


proto语法目前分proto2proto3两个版本,本着学新不学旧原则,这里仅介绍proto3语法

proto文件基本格式

syntax = "proto3";

package domain; // 声明所在包

import "demo.proto";

option go_package = "github.com/leoshus/pb-demo/proto;domain";// 声明go文件所属包

message A {
...
}
enum B {
...
}
  • syntax="proto3" 指明使用proto3版本,不写默认为proto2

  • package domain 表示的是proto语法中的包的概念,避免proto文件相互import中同名结构冲突。所以当导入非本包的结构时需要加package name作为前缀。

  • import 根据protoc --proto_path=指定目录查找,不指定默认从当前工作目录查找。

  • option go_package 以go为例,go_package表示生成go代码的所属的包,便于正确引用。

接下来,来详细看看proto支持的数据类型

消息类型

消息类型是最常用的类型,语法规范字段规则 字段类型 字段名称(推荐下划线分割形式) = 字段编号

syntax = "proto3";

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

消息类型需要注意的是:

  1. 字段类型可以是简单的标量类型,也可以是复杂类型如枚举类型或其他自定义的消息类型。
  2. 同一消息结构中每个字段都有唯一的编号。有几个细节需要掌握下。
    1. 用来在消息二进制格式中标识字段。所以一旦使用不要去修改它
    2. 编码方面:编号取值1-15消耗一个字节,16-2047需要消耗2个字节。所以尽量让频繁使用的字段分配小的字段编号。也可以考虑未来扩展提前预留部分编号。
    3. 19000-19999为保留编号 不能使用。不过应该没人会搞这么大的结构体。。。
  3. repeated修饰的标量数值类型默认使用packed编码。

ps: 关于编码问题,后面单独分析。

保留字段

如果你需要完全删除一个字段,可以使用reserved字段声明。如果被复用,编译器会进行提示,防止后续有人误用带来各种问题。

message Foo {
reserved 2, 15, 9 to 11;
reserved "foo", "bar";
}
// 注意:字段名和字段编号不能同时放在一个reserved语句中

标量值类型

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

.proto TypeNotesC++ TypeJava TypePython Type[2]Go TypeRuby TypeC# TypePHP TypeDart Type
double
doubledoublefloatfloat64Floatdoublefloatdouble
float
floatfloatfloatfloat32Floatfloatfloatdouble
int32使用可变长度编码。负数编码效率低下–如果你的字段可能具有负值,请改用sint32。int32intintint32Fixnum or Bignum (as required)intintegerint
int64使用可变长度编码。负数编码效率低下–如果你的字段可能具有负值,请改用sint64。int64longint/long[3]int64Bignumlonginteger/string[5]Int64
uint32使用可变长度编码。uint32int[1]int/long[3]uint32Fixnum or Bignum (as required)uintintegerint
uint64使用可变长度编码。uint64long[1]int/long[3]uint64Bignumulonginteger/string[5]Int64
sint32使用可变长度编码。有符号的int值。与常规int32相比,它们更有效地对负数进行编码。int32intintint32Fixnum or Bignum (as required)intintegerint
sint64使用可变长度编码。有符号的int值。与常规int64相比,它们更有效地编码负数。int64longint/long[3]int64Bignumlonginteger/string[5]Int64
fixed32始终为四个字节。如果值通常大于2的28次方,则比uint32更有效。uint32int[1]int/long[3]uint32Fixnum or Bignum (as required)uintintegerint
fixed64始终为八个字节。如果值通常大于2的56次方,则比uint64更有效。uint64long[1]int/long[3]uint64Bignumulonginteger/string[5]Int64
sfixed32始终4个字节int32intintint32Fixnum or Bignum (as required)intintegerint
sfixed64始终8个字节int64longint/long[3]int64Bignumlonginteger/string[5]Int64
bool
boolbooleanboolboolTrueClass/FalseClassboolbooleanbool
string字符串必须始终包含UTF-8编码或7位ASCII文本,并且不能超过2的32次方。stringStringstr/unicode[4]stringString (UTF-8)stringstringString
bytes字符串必须始终包含UTF-8编码或7位ASCII文本,并且不能超过2的32次方。stringByteStringstr[]byteString (ASCII-8BIT)ByteStringstringList

默认值

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

  • 对于字符串,默认值为空字符串。
  • 对于字节,默认值为空字节。
  • 对于布尔值,默认值为false。
  • 对于数字类型,默认值为零。
  • 对于枚举,默认值为第一个定义的枚举值,必须为0。
  • 对于消息字段,未设置该字段。它的具体值取决于语言。有关详细信息,请参见生成的代码指南。

重复字段的默认值是空的(通常是使用适当语言的空列表)。

需要注意的是,对于标量消息字段,一旦解析了一条消息,就无法知道该字段是被显式设置为默认值(例如,布尔值是否设置为false)还是根本没有设置:你应该在定义消息类型时要注意。而且,如果将标量消息字段设置为其默认值,则该值将不会序列化。

枚举值

当你需要定义一个字段取值为一个预定义的值列表之一时,可以使用枚举值定义字段类型。例如 查询请求SearchRequest中添加文献类型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定义在SearchRequest内部,则在SearchRequest可直接使用,其他结构如果想使用内部定义的枚举类型,需要使用_MessageType_._EnumType_语法,如SearchRequest.Corpus。当然也可以将CorpusSearchRequest并列定义,这样其他结构就能直接使用Corpus

你应该注意到上面的枚举的第一个常数UNIVERSAL = 0;映射为零。实际上每个枚举类型定义都必须包含一个零值并且需要放在第一个字段位置。主要的原因是:

  1. 必须有一个零值,这样就可以使用0作为默认值
  2. 零值必须放到第一个位置是为了兼容proto2的语法

此外,你还可以为枚举常量值定义别名,但是前提是你需要设置allow_alias选项为true,否则编译器会报错。

message MyMessage1 {
enum EnumAllowingAlias {
option allow_alias = true;
UNKNOWN = 0;
STARTED = 1;
RUNNING = 1;
}
}
message MyMessage2 {
enum EnumNotAllowingAlias {
UNKNOWN = 0;
STARTED = 1;
// RUNNING = 1; //不注释这一行会引起编译错误
}
}

枚举常量值必须是32-bit的整数。但是因为enum值采用的是varint编码,负数占用空间较多并不高效,所以不建议枚举常量值使用负数

保留值

与消息类型类似的是,枚举类型也提供了保留值的功能,避免删除的枚举常量被复用,导致不可预知的错误。

enum Foo {
reserved 2, 15, 9 to 11, 40 to max;
reserved "FOO", "BAR";
// reserved 2, "FOO" //这是错误的
}

跟保留字段一样,保留值定义时 枚举常量名称不能和枚举值放到一个reserved语句中。

使用其他消息类型

你可以使用其他的消息类型作为字段类型,例如,你可以在同一个proto文件中定义SearchResponseResult,然后在SearchResponse中定义类型为Result的字段。

message SearchResponse {
repeated Result results = 1;
}

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

导入定义

注意这个特性在Java中无效

上面的例子是引用双方的消息类型定义都在一个proto文件中,那么如果你想要使用一个已经在另一个proto文件中定义的消息类型该怎么办呢?

你可以使用import关键字导入对应proto文件,例如

import "myproject/other_protos.proto";

默认情况下,你只能直接使用通过proto文件导入的定义。然而有时候你可能需要移动proto文件到一个新的位置。此时,你可以选择在原有位置中定义一个假的proto文件,通过使用import public将引用中转到新的proto文件中。import public依赖关系可以通过任何定义了import public语句的proto文件进行传递。例如:

新的proto文件

// new.proto
// 所有定义移动到这里

旧proto文件

// old.proto
// This is the proto that all clients are importing.
import public "new.proto";
import "other.proto";

引用旧proto文件的定义的proto

// client.proto
import "old.proto";
// You use definitions from old.proto and new.proto, but not other.proto

编译器查找import文件是根据protoc命令行中-I/--proto_path的指定目录。如果没有指定这个参数,则从调用编译器的目录中查找。通常你需要定义--proto_path指向你的工程根目录,并且proto文件中的import必须使用全称。

使用proto2的消息类型

proto2proto3定义的消息类型是可以相互引用的。但是proto2中定义的枚举类型不能直接用在proto3语法中。

内嵌类型

除了枚举类型可以内嵌外,你可以在消息类型定义中内嵌另一个消息类型的定义并使用它。比如:

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

更新一个消息类型

当你现有proto文件中定义的消息字段类型不再满足你的需求(比如,你希望消息格式具有一个额外的字段,但你仍然想使用旧proto文件创建的代码)。也就是如何不破坏现有代码更新消息的字段类型呢?其实很简单,只要遵循下面的规则即可:

  • 不要修改现有字段的字段编号
  • 如果新增字段,仍然可以使用新生成的代码来解析使用旧proto格式下生成的代码进行序列化的消息,不过你需要记住这些元素的默认值,以便新代码和旧代码生成的消息正确交互。同理,由新代码序列化的消息也可以由旧代码解析(旧的二进制文件在解析时只是简单忽略新增的字段)。
  • 只要更新后的消息类型不再使用字段号,就可以删除字段。你可能想要重命名该字段编号,或添加前缀"OBSOLETE_",或保留该字段编号,以便将来.proto的用户不会意外重用该字段编号。
  • int32,uint32,int64,uint64,bool之间是互相兼容的。也就是说,你可以从这几个类型中的任意类型之间互相修改,不会破坏向前或向后的兼容性。如果从wire中解析出一个对应类型不匹配的数字,则会将数字强制转换为该类型(类似C++,比如如果将64位数字读取位int32,则它将被截断为32位)。
  • sint32sint64之间是互相兼容的,但是跟其他整数类型并不兼容
  • 只要字节是有效UTF-8stringbytes也是兼容的
  • 如果字节包含消息的编码版本,则内嵌消息和bytes也是兼容的
  • 对于stringbytes以及消息字段来说,optionalrepeated是兼容的。给定repeated字段的序列化数据作为输入,如果期望此字段是optional,则如果它是基本类型,则将采用最后一个输入值;如果是消息类型,则将合并所有输入元素。注意:这对于数字类型(包括布尔值和枚举)通常是不安全。repeated的数字类型会以packed格式进行格式化。当期望使用可选字段来解析时将无法正常工作。
  • enumint32uint32int64以及uint64之间是互相兼容的(注意:如果类型不匹配值会截断)。但是需要注意的是,客户端代码在反序列化消息时可能会以不同的方式对待它们:例如,无法识别的proto3枚举类型将保留在消息中,但是在反序列化消息时如何表示则取决于具体语言。Int类型的字段始终保留其值。
  • 改变单值类型数据为新的oneof数据的一个成员是安全的并且二进制兼容。如果你能保证多个字段同时最多只存在一个时,将这些字段放进一个新的oneof类型中也可能是安全的。移动任何字段到一个已经存在的oneof中都是不安全的。

未知字段

未知字段是格式正确的协议缓冲区序列化数据但是解析器无法识别的字段。比如,当旧的二进制文件使用由新增了字段的二进制文件发送的数据解析时,这些新增的字段对于旧的二进制文件就是未知字段。

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

Any

Any消息类型可以让你的消息用做内嵌类型,而不需要知道他们的.proto定义。Any包含任意序列化消息(以字节为单位)以及URL,URL作为消息的类型并解析为该消息的类型的全局唯一标识符。要使用Any,你需要导入google/protobuf/any.proto

import "google/protobuf/any.proto";

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

给定消息类型的默认类型URLtype.googleapis.com/_packagename_.messagename_

不同的语言实现将支持运行时库帮助程序以类型安全的方式打包和解压缩任何值。比如,Java中,Any类型将具有特殊的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 ...
  }
}

当前,正在开发用于处理任何类型的运行时库。

如果你已经熟悉proto2语法,Any可以持有任意proto3消息,就类似于proto2消息允许extensions一样。

Oneof

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

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

然后,将你的oneof字段添加到oneof定义中。你可以添加除了maprepeated类型数据外的任何类型的字段。

在你生成的代码中,oneof 字段具有与常规字段相同的gettersetter。你还将获得一种特殊的方法来检查oneof中的哪个值被设置了(如果对应语言支持的话)。

oneof特性

  • 设置oneof字段将自动清除oneof的所有其他成员。因此,如果你设置了oneof中的多个字段,则只有你最后设置的字段仍然有值。
SampleMessage message;
message.set_name("name");
CHECK(message.has_name());
message.mutable_sub_message(); // name字段值被清除
CHECK(!message.has_name());
  • 如果解析器在wire上遇到相同oneof的多个成员时,则在解析的消息中仅使用最后看到的成员。
  • oneof不能被repeated修饰
  • 反射API使用于oneof字段
  • 如果你将oneof字段设置为默认值(例如将oneof字段int32设置为0)则该值将在wire上序列化。
  • 如果你使用的是C++,请确保你的代码不会导致内存崩溃。以下示例代码将会崩溃,因为通过调用set_name()方法已经删除了sub_message
SampleMessage message;
SubMessage* sub_message = message.mutable_sub_message();
message.set_name("name");      // 将删除 sub_message
sub_message->set_...            // 这里崩溃了
  • 还是在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的不同版本的字段。由于无法知道wire上的未知字段是否是oneof的成员,因此无法分辨两者之间的区别。

Tag重用问题:

  • 将字段移入或移出oneof: 在消息已经被序列化并且解析,你可能丢失一些信息(一些字段将被清除)。尽管如此,你可以安全地将单个字段移动到一个新oneof中,并且如果已知多个字段只设置会一个字段,则可以移动多个字段进一个新的oneof
  • 删除一个oneof然后再加回来:在消息已经被序列化并且解析,这可能会清除当前设置的oneof的字段值。
  • 分离或合并oneof: 这跟移动常规字段类似。

Maps

如果你想创建关联映射作为数据定义的一部分,则protocol buffers定义了一种方便快捷方式的语法:

map<key_type, value_type> map_field = N;
  • key_type可以是任意整型或字符串类型(除了浮点类型和字节之外的,任何标量类型)。注意的是:枚举不是有效的key_type.
  • value_type可以是除了map以外的任何类型

所以,比如你想创建一个projects的映射,其中每个Project消息都与一个字符串的键关联,则可以这样定义它:

map<string, Project> projects = 3;
  • map字段不能被repeated修饰
  • wire格式化的顺序和map迭代器的顺序是不确定的,所以你不能依赖map项的特定顺序。
  • .proto生成文本格式时,map按照key排序。数字键按照数字排序。
  • 当从wire解析或合并时,如果存在重复的键,则使用最后看到的键。从文本解析map时,如果键重复,则解析可能失败。
  • 如果映射字段提供了键但没有值,则序列化字段时的行为取决于语言。在C++,JavaPython中,序列化的时类型的默认值,而其他语言不会序列化。

向后兼容性

map语法序列化后等同于如下内容,故而即使是不支持map语法的protocol buffers实现也是可以处理你的数据。

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

repeated MapFieldEntry map_field = N;

任何支持map的protocol buffers实现都必须生成并接受可以被上述定义接受的数据。

Packages

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

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

然后你可以在定义消息类型的字段时使用包声明符。

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

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

  • 在C++中,生产的类包装在一个C++命名空间中。比如,上面的Open将会封装在命名空间foo::bar
  • 在Java中,package将会被用于java的包,除非在你的.proto文件中显示提供一个option java_package
  • 在Python中,package指令会被忽略,因为Python模块的组织是根据他们在文件系统中的位置
  • 在Go中,package会被用于Go的包名,除非在你的.proto文件中显示提供一个option go_package
  • 在Ruby中,生产的类被封装在内嵌的Ruby命名空间中,转换为所需的Ruby大写样式(第一个字母大写,如果首字符不是字母,则使用PB_作为前缀)。比如,Open会封装在命名空间Foo::Bar
  • 在C#中,package转化为PascalCase后作为命名空间,除非你在你的.proto显示提供一个option sharp_namespace。比如,Open将会在命名空间Foo.Bar

包和名称解析

protocol buffer语言中类型名称的解析类似C++:首先搜索最内层的范围,然后是下一个最里面的,以此类推,每个包都被认为是其父包的“内部”。以"."开头(例如.foo.bar.Baz)表示从最外面的范围开始搜索。

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

定义服务

如果你要将消息类型在RPC(Remote Procedure Call)系统中使用,你可以在.proto文件中定义一个RPC服务接口。而且protocol buffer编译器将根据你选择的语言生成服务接口代码和stubs。因此,例如,如果你想要定义一个包含入参为SearchRequest并且返回值为SearchResponse的方法的RPC服务时,则可以在.proto文件中对其进行定义,如下所示:

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

protocol buffer一起使用的最简单的RPC系统是gRPC:这是Google开发的与语言和平台无关的开源RPC系统。gRPCprotocol buffers配合使用特别好,它让你可以使用特殊的protocol buffer编译器插件直接从.proto文件中生成相关的RPC代码。

如果你不想使用gRPC,也可以使用protocol buffer用于自己的RPC实现,你可以从proto2语言指南中找到更多信息

还有一些第三方开发的RPC实现使用protocol buffer。参考第三方插件wiki查看这些实现的列表。

JSON Mapping

proto3支持JSON中的规范编码,从而在系统之间共享数据更加容易。下表中按类型对编码进行了描述。

如果JSON编码数据中缺少了某个值,或者该值为null,则在解析为protocol buffer时,它将被解释为适当的默认值。如果字段在protocol buffer中具有默认值,则默认情况下会在JSON编码的数据中将其省略以节省空间。具体实现可以提供在 JSON编码中可选的默认值。

proto3JSONJSON exampleNotes
messageobject{"fooBar": v, "g": null, …}生成JSON对象。消息字段名称被映射到首字母消息驼峰格式并且成为JSON对象键。如果指定json_name字段选项,则使用指定的值作为键。解析器接受首字母小写驼峰格式或json_name指定值和原始原型字段名称。null是所有字段类型的可接受值,并被视为相应字段类型的默认值。
enumstring"FOO_BAR"使用在proto中指定的枚举值的名称。解析器接受枚举名称和整数值。
map<K,V>object{"k": v, …}所有key转换为字符串
repeated Varray[v, …]空列表[]被接受为null
booltrue, falsetrue, false
stringstring"Hello World!"
bytesbase64 string"YWJjMTIzIT8kKiYoKSctPUB+"JSON值将是使用带有填充的标准base64编码编码为字符串。接受带/不带填充的标准或URL安全base64编码。
int32, fixed32, uint32number1, -10, 0JSON值为一个十进制数字。可以接受数字或字符串。
int64, fixed64, uint64string"1", "-10"JSON值为一个十进制数字。可以接受数字或字符串。
float, doublenumber1.1, -10.0, 0, "NaN", "Infinity"JSON值为数字或特殊字符串值“ NaN”,“ Infinity”和“ -Infinity”之一。可以接受数字或字符串。指数表示法也被接受。-0被认为等效于0。
Anyobject{"@type": "url", "f": v, … }如果Any包含具有特殊JSON映射的值,则将其转换如下:{“ @type”:xxx,“ value”:yyy}。否则,该值将转换为JSON对象,并且将插入“ @type”字段以指示实际的数据类型。
Timestampstring"1972-01-01T10:00:20.021Z"使用RFC 3339,其中生成的输出将始终进行Z归一化,并使用0、3、6或9个小数位。也可以接受“ Z”以外的偏移。
Durationstring"1.000340012s", "1s"生成的输出始终包含0、3、6或9个小数位数,具体取决于所需的精度,后跟后缀“ s”。可接受的任何小数位数(也无),只要它们适合纳秒精度,并且后缀“ s”是必需的。
Structobject{ … }任何JSON对象。参见struct.proto。
Wrapper typesvarious types2, "2", "foo", true, "true", null, 0, …包装器在JSON中使用与包装后的原始类型相同的表示形式,不同之处在于在数据转换和传输期间允许并保留null。
FieldMaskstring"f.fooBar,h"See field_mask.proto.
ListValuearray[foo, bar, …]
Valuevalue
Any JSON value. Check google.protobuf.Value for details.
NullValuenull
JSON null
Emptyobject{}An empty JSON object

JSON选项

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

  • 设置字段的默认值:默认情况下,proto3 JSON输出中会省略具有默认值的字段。一种实现可以提供一个选项,用其默认值覆盖此行为并输出字段。
  • 忽略未知字段:Proto3 JSON解析器默认情况下应拒绝未知字段,但可以提供在解析时忽略未知字段的选项。
  • 使用原型字段名而不是小写的驼峰名称:默认情况下,proto3 JSON打印器应将字段名称转换为首字母小写的驼峰格式并将其作为JSON的名称。一种实现可以提供一个选项,使用原型字段名出作为JSON名称。Proto3 JSON解析器必须接受转换后的首字母小写驼峰格式名称和原型字段名出。
  • 设置枚举类型值为整型而不是字符串:默认情况下,JSON输出中使用枚举值的名称。可以提供一个选项来使用枚举值的数字值替换名称值。

选项

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

一些选项是文件级别的,这意味着它们应该书写在最外层,而不应该在任何消息、枚举或服务中定义。一些选项是消息级别的选项,这意味着它们应该写在消息定义中。一些选项是字段级别的,意味着它们应该在字段定义中编写。选项也可以卸载枚举类型、枚举值、oneof、服务类型和服务方法中。但是,到目前为止,没有一种有效的选项能作用于任意的类型。

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

  • java_package(文件选项):为你生成的代码设置包路径。如果.proto文件中没有显示提供java_package选项,则默认情况下使用proto的包,即package关键字指定的内容。但是,proto文件的包定义通常并不是很好适用于Java的包定义。因为proto包定义不以反向域名开头。如果不生成Java代码,则这个选项无效。
option java_package = "com.example.foo";
  • java_outer_classname(文件选项): 指定你要生成的最外层Java类的类名(以及文件名)。如果在.proto文件中没有显示指定java_outer_classname,则通过将.proto文件名转换为驼峰式大小写来构造类名。(例如,foo_bar.proto变成FooBar.java)。如果不生成Java代码,则此选项无效。
option java_outer_classname = "Ponycopter";
  • java_mutiple_files(文件选项): 默认值为 false。如果为false,则只会为此.proto文件以及所有Java类、枚举等生成一个.java文件。最外层定义的消息、服务和枚举生成的消息将嵌套在生成的Java文件中。如果为true,则会将单独为每个Java类、枚举等生成.java 文件并且这些生成的Java文件中也不会存在嵌套。如果不生成Java代码,则此选项无效。
option java_multiple_files = true;
  • optimize_for(文件选项):可以被设置为SPEED,CODE_SIZE或者LITE_RUNTIME。这会通过以下方式影响C++Java代码生成器(可能还有第三方生成器):

    option optimize_for = CODE_SIZE;
    int32 old_field = 6 [deprecated = true];
    • cc_enable_arenas(文件选项):为C++生成的代码启用arena allocation
    • objc_class_prefix(文件选项):设置Objective-C前缀,该前缀将附加到所有Objective-C生成的类以及来自此.proto的枚举。没有默认值。你应该使用Apple推荐的3-5个大写字母作为前缀。注意:所有的2个字母的前缀均被Apple保留。
    • deprecated(文件选项):如果设置为true,表明字段以及废弃了,不应该被新代码使用。在大多数语言中,这没有实际的影响。在Java中,这个选项将变成@Deprecated注解。将来,其他特定语言的代码生成器可能会在字段的访问器上生成弃用注释,这反过来将导致在编译尝试使用该字段的代码时发出警告。如果该字段未被任何人使用,并且你想阻止新用户使用该字段,请考虑使用reserved替换该字段声明。
    • SPEED(默认值):protocol buffers编译器将生成用于对消息类型进行序列化,解析和执行其他常见操作的代码。此代码已高度优化。
    • CODE_SIZE: protocol buffers编译器将生成最少的类,并将依赖于基于反射的共享代码来实现序列化,解析和其他各种操作。因此,生成的代码比使用SPEED的代码小得多,但是操作会更慢。类仍将实现与在SPEED模式下完全相同的公共API。这种模式在包含大量.proto文件且不需要所有文件都能快速运行的场景很有用。
    • LITE_RUNTIME:protocol buffers编译器将生成仅依赖于"精简版"运行时库的类(libprotobuf-lite 而不是libprotobuf)。精简版运行时比完整库要小得多(大约小一个数量级),但省略了某些功能,例如描述符和反射。这对于在受限平台(例如,手机)上运行的应用程序特别有用。编译器仍将像在SPEED模式下一样快速生成所有方法的实现。各种语言生成的类将仅实现MessageLite接口,该接口仅提供完整Message接口方法的子集。

自定义选项

protocol buffers也允许你定义和使用自己的选项。这是一个大多数人不需要的高级特性。如果你确实需要创建自己的选项,可以参考proto2 语言指南来获取详细信息。请注意,创建自定义选项使用扩展,扩展仅适用proto3中的自定义选项。

生成你的类

要生成JavaPythonC ++GoRubyObjective-CC#代码,你需要使用.proto文件中定义的消息类型,需要在.proto上运行protocol buffers编译器。如果尚未安装编译器,请下载软件包并按照README中的说明进行操作。对于Go,你还需要为编译器安装一个特殊的代码生成器插件:你可以在GitHub上的golang / protobuf仓库中找到此代码和安装说明。

protocol buffers编译器的调用如下:

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文件的目录。如果省略,则使用当前目录。可以通过多次传递--proto_path选项来指定多个导入目录。将按顺序搜索它们。-I = _IMPORT_PATH_可以用作--proto_path的缩写形式。
  • 你可以提供一个或多个输出指令:
    • --cpp_outDST_DIR生成C++代码。参考C++代码生成指南
    • --java_outDST_DIR生成Java代码。参考Java代码生成指南
    • --python_outDST_DIR生成Python代码。参考Python代码生成指南
    • --go_outDST_DIR生成Go代码。参考Go代码生成指南
    • --ruby_outDST_DIR生成ruby代码。ruby代码生成指南还没有 orz。。。
    • --objc_outDST_DIR生成Objective-C代码。参考Objective-C代码生成指南
    • --csharp_outDST_DIR生成C#代码。参考C#代码生成指南
    • --php_outDST_DIR生成PHP代码。参考PHP代码生成指南。为了更加方便,如果DST_DIR.zip.jar结尾,则编译器会将输出写入具有给定名称的单个ZIP格式的文件中。根据Java Jar规范的要求,还将以.jar输出提供清单文件。请注意:如果输出归档文件已经存在,它将被覆盖;编译器不够智能,无法将文件添加到现有文档文件中。
  • 你必须提供一个或多个.proto文件作为输入。可以一次指定多个.proto文件。尽管这些文件是相对于当前目录命名的,但是每个文件都必须位于IMPORT_PATH中,这样便于编译器确定其规范名称。

protoc命令

当你编写好proto文件后,你需要使用protoc将其编译为指定语言的代码。这里只介绍常用命令(以go为例)

protoc --proto_path=. --go_out=. ./src/proto/*.proto
  • --proto_path 指定import搜索的目录。如果存在多个目录,可以指定多次,并且会按目录顺序搜索。如果不设置,则为当前工作目录
  • --go_out 表示使用protoc-gen-go插件工作并指定生成go源代码保存目录。类似指令如:
  --cpp_out=OUT_DIR           Generate C++ header and source.
  --csharp_out=OUT_DIR        Generate C# source file.
  --java_out=OUT_DIR          Generate Java source file.
  --js_out=OUT_DIR            Generate JavaScript source.
  --objc_out=OUT_DIR          Generate Objective C header and source.
  --php_out=OUT_DIR           Generate PHP source file.
  --python_out=OUT_DIR        Generate Python source file.
  --ruby_out=OUT_DIR          Generate Ruby source file.

--go_out还可以指定一些参数,比如

  1. plugins 指定生成指定语言代码所使用到的插件
  2. paths 指定如何创建目录层级,有两个选项importsource_relative,默认为import
  • import 表示按照生成代码的包全路径生成目录
  • source_relative 表示按照**proto源文件的目录结构**存储生成的go代码

参数之间是可以同时使用的,eg

protoc --proto_path= --go_out=plugins=grpc,paths=import:. ./order/*.proto

这么讲可能比较晦涩,举个例子,我们创建一个proto-demo工程。

go mod init github.com/leoshus/proto-demo

定义proto

// user/user.proto
syntax = "proto3";

package user;

import "order/order.proto";

option go_package = "github.com/leoshus/proto-demo/user";

enum Gender {
GENDER_DEFAULT = 0;
GENDER_MALE = 1;
GENDER_FEMALE = 2;
}
message User {
int64 user_id = 1;
string user_name = 2;
Gender gender = 3;
repeated order.Order order = 4;
}

// order/order.proto
syntax = "proto3";

package order;

option go_package = "github.com/leoshus/proto-demo/order";

message Order {
int64 order_id = 1;
int64 user_id = 2;
}
  1. import模式 生成的go代码文件按照其包全路径创建目录并进行存储
#!/usr/bin/env sh

protoc --proto_path= --go_out=paths=import:. ./order/*.proto
protoc --proto_path= --go_out=paths=import:. ./user/*.proto

07ffc535ef221bc9b91bb7591df89122.webp

imports
  1. source_relative模式 此模式将生产的go文件和对应的proto源文件放在相同目录下。
#!/usr/bin/env sh

protoc --proto_path= --go_out=paths=source_relative:. ./order/*.proto
protoc --proto_path= --go_out=paths=source_relative:. ./user/*.proto

6574bdc8d1ce7f2f36760da12b2d4de6.webp

source_relative

使用--go_out=paths=source_relative:.时注意声明option go_package

  • --go_out=paths=source_relative:. 只是为了让生成的目标文件和proto源文件存放在同一位置
  • option go_package 才能保证代码依赖的正确性

使用Any数据类型出错?

有时你可能会需要利用google.protobuf.Any抽象数据结构

import "google/protobuf/any.proto";
message Message {
int64 message_id = 1 ;
MessageType message_type = 2;
google.protobuf.Any data = 3;
}

但是编译的时候可能会报如下异常:

google/protobuf/any.proto: File not found.
xxx.proto:5:1: Import "google/protobuf/any.proto" was not found or had errors.
xxx.proto:10:5: "google.protobuf.Any" is not defined.

解决办法:

下载二进制包 https://github.com/protocolbuffers/protobuf/releases其中包含include文件夹
方法一:
cp -r include/google /usr/local/include/
方法二:
protoc --proto_path=解压出proto文件的路径

c3a3ce0a47b17cc1cc5a455f0a33458f.webp

关于Go gRPC使用pb问题

对于Go开发者而言,使用protocol buffers最多的场景,当属gRPC了。当你使用go编写gRPC服务并编译proto文件时,protoc命令需要指定plugins=grpc来生成gRPC代码

protoc --proto_path=. --go_out=plugins=grpc:. ./src/proto/*.proto

但是protoc-gen-go v1.4.0版本后,执行上面的命令,会得到下面异常

--go_out: protoc-gen-go: plugins are not supported; use 'protoc --go-grpc_out=...' to generate gRPC

因为protoc-gen-go v1.4.0版本后,将gRPC的支持移除了。单独更加职责单一的使用http://google.golang.org/grpc/cmd/protoc-gen-go-grpc 提供服务。所以protoc-gen-go v1.4.0版本以后使用gRPC需要再安装一下protoc-gen-go-grpc。即:

go get google.golang.org/protobuf/cmd/protoc-gen-go \
         google.golang.org/grpc/cmd/protoc-gen-go-grpc

关于gRPC插件分离的问题可以围观下issues1070(https://github.com/golang/protobuf/issues/1070)

总结

窃以为如果选择使用protocol buffers作为自己业务协议的序列化方式,吃透其基本使用及原理是很有必要的。比如使用上来讲,proto语法并不复杂,但是有很多细节。如果你对代码有洁癖、对性能追求极致的话,掌握好这些细节,对于协议兼容、协议优化都会有很大帮助。下一篇我们继续聊聊protocol buffers底层是怎么编译、序列化和反序列化的。


浏览 58
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报