Android技术周报190317期 —— Protocbuf全面解析

Protobuf即Protocol Buffers,是Google公司开发的一种跨语言和平台的序列化数据结构的方式,是一个灵活的、高效的用于序列化数据的协议。与XML和JSON格式相比,protobuf更小、更快、更便捷。而且Protobuf是跨语言的,并且自带一个编译器(protoc),只需要用protoc进行编译,就可以编译成Java、Python、C++、C#、Go等多种语言代码,然后可以直接使用,不需要再写其它代码,自带有解析的代码。

一. Protocbuf编译器的安装

从https://github.com/protocolbuffers/protobuf/releases上下载最新的release包,并解压到你的安装目录下,然后将其下面的bin目录添加为系统环境变量,如mac操作系统下:
export PATH=$PATH:/usr/local/protobuf/bin
这样就可以在任意地方的命令行窗口下使用protoc指令,如检查protoc编译器版本:
protoc --version

二. Protocbuf3语法

1. 版本声明

syntax = "proto3";

.proto文件中非注释非空的第一行必须使用Proto版本声明,如果不使用proto3版本声明,Protobuf编译器默认使用proto2版本。

2. Package

package com.aervon.proto;

.proto文件中可以新增一个可选的package声明符,用来防止不同的消息类型有命名冲突。包的声明符会根据使用语言的不同影响生成的代码:
A、对于C++语言,产生的类会被包装在C++的命名空间中。
B、对于Java语言,包声明符会变为java的一个包,除非在.proto文件中提供了一个明确有java_package。
C、对于Go语言,包可以被用做Go包名称,除非显式的提供一个option go_package在.proto文件中。

3. Import

import "google/protobuf/timestamp.proto";

通过import声明符可以引用其他.proto里的结构数据体,如以上声明后就可以使用google.protobuf.Timestamp了。

4. 消息定义

message Person {
     string name = 1;
     int32 id = 2 [default = 0];  
     string email = 3;
}

Protobuf中,消息即结构化数据。其中变量的声明结构为:

字段规则 + 字段类型 + 字段名称 + [=] + 标识符 + [默认值]

字段规则有:
required: 结构体必须包含该字段一次
optional: 结构体可以包含该字段零次或一次(不超过一次)
repeated: 该字段可以在格式良好的消息中重复任意多次(包括0),其中重复值的顺序会被保留,相当于数组

PS: 在 proto3 中已经为兼容性彻底抛弃 required

字段类型可以具有以下几种类型,在编译器作用下会自动生成类中的相应类型:

.proto Type Notes C++ Type Java Type Python Type Go Type
double double double float *float64
float float float float *float32
int32 使用可变长度编码。编码负数的效率低 - 如果你的字段可能有负值,请改用 sint32 int32 int int *int32
int64 使用可变长度编码。编码负数的效率低 - 如果你的字段可能有负值,请改用 sint64 int64 long int/long *int64
uint32 使用可变长度编码 uint32 int int/long *uint32
uint64 使用可变长度编码 uint64 long int/long *uint64
sint32 使用可变长度编码。有符号的 int 值。这些比常规 int32 对负数能更有效地编码 int32 int int *int32
sint64 使用可变长度编码。有符号的 int 值。这些比常规 int64 对负数能更有效地编码 int64 long int/long *int64
fixed32 总是四个字节。如果值通常大于 228,则比 uint32 更有效。 uint32 int int/long *uint32
fixed64 总是八个字节。如果值通常大于 256,则比 uint64 更有效。 uint64 long int/long *uint64
sfixed32 总是四个字节 int32 int int *int32
sfixed64 总是八个字节 int64 long int/long *int64
bool bool boolean bool *bool
string 字符串必须始终包含 UTF-8 编码或 7 位 ASCII 文本 string String str/unicode *string
bytes 可以包含任意字节序列 string ByteString str []byte

标识符:
在消息定义中,每个字段都有唯一的一个数字标识符。标识符用来在消息的二进制格式中识别各个字段,一旦使用就不能够再改变。最小的标识符可以从1开始,最大到2^29 - 1(536,870,911),不可以使用其中[19000-19999]( Protobuf协议实现中进行了预留,从FieldDescriptor::kFirstReservedNumber 到 FieldDescriptor::kLastReservedNumber)的标识号。如果非要在.proto文件中使用预留标识符,编译时就会报警。

PS: [1,15]内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为频繁出现的消息元素保留[1,15]内的标识号。

默认值:
当一个消息被解析的时候,如果编码消息里不包含一个特定的singular元素,被解析的对象所对应的字段被设置为一个默认值,不同类型默认值如下:

变量类型 默认值
string 空string
bytes 空bytes
bool false
数值类型 0
枚举 第一个定义的枚举值
消息类型(message) 字段没有被设置,确切的消息是根据语言确定的,通常情况下是对应语言中空列表
标量消息字段 一旦消息被解析,就无法判断字段是被设置为默认值还是根本没有被设置,应该在定义消息类型时注意

5. 添加注释

为你的 .proto 文件添加注释,可以使用 C/C++ 语法风格的注释 // 和 /* ... */ 。

/* SearchRequest represents a search query, with pagination options to
 * indicate which results to include in the response. */

message SearchRequest {
  required string query = 1;
  optional int32 page_number = 2;  // Which page number do we want?
  optional int32 result_per_page = 3;  // Number of results to return per page.
}

6. Reserved 保留字段

如果你通过完全删除字段或将其注释掉来更新 message 类型,则未来一些用户在做他们的修改或更新时就可能会再次使用这些字段编号。如果以后加载相同 .proto 的旧版本,这可能会导致一些严重问题,包括数据损坏,隐私错误等。确保不会发生这种情况的一种方法是指定已删除字段的字段编号(有时也需要指定名称为保留状态,英文名称可能会导致 JSON 序列化问题)为 “保留” 状态。如果将来的任何用户尝试使用这些字段标识符,protocol buffer 编译器将会抱怨。

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

PS: 你不能在同一 "reserved" 语句中将字段名称和字段编号混合在一起指定。

7. 枚举 Enumerations

在下面的例子中,我们添加了一个名为 Corpus 的枚举,其中包含所有可能的值,之后定义了一个类型为 Corpus 枚举的字段:

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

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

enum EnumAllowingAlias {
  option allow_alias = true;
  UNKNOWN = 0;
  STARTED = 1;
  RUNNING = 1;
}
enum EnumNotAllowingAlias {
  UNKNOWN = 0;
  STARTED = 1;
  // RUNNING = 1;  // 取消此行注释将导致 Google 内部的编译错误和外部的警告消息
}

枚举器常量必须在 32 位整数范围内。由于 enum 值在线上使用 varint encoding ,负值效率低,因此不推荐使用。你可以在 message 中定义 enums,如上例所示的那样。或者将其定义在 message 外部 - 这样这些 enum 就可以在 .proto 文件中的任何 message 定义中重用。你还可以使用一个 message 中声明的 enum 类型作为不同 message 中字段的类型,使用语法 MessageType.EnumType 来实现。
当你在使用 enum.proto 上运行 protocol buffer 编译器时,生成的代码将具有相应的用于 Java 或 C++ 的 enum,或者用于创建集合的 Python 的特殊 EnumDescriptor 类。运行时生成的类中具有整数值的符号常量。

8. 扩展 Extensions

通过扩展,你可以声明 message 中的一系列字段编号用于第三方扩展。扩展名是那些未由原始 .proto 文件定义的字段的占位符。这允许通过使用这些字段编号来定义部分或全部字段从而将其它 .proto 文件定义的字段添加到当前 message 定义中。我们来看一个例子:

message Foo {
  // ...
  extensions 100 to 199;
}

这表示 Foo 中的字段数 [100,199] 的范围是为扩展保留的。其他用户现在可以使用指定范围内的字段编号在他们自己的 .proto 文件中为 Foo 添加新字段,例如:

extend Foo {
  optional int32 bar = 126;
}

这会将名为 bar 且编号为 126 的字段添加到 Foo 的原始定义中。
当用户的 Foo 消息被编码时,其格式与用户在 Foo 中常规定义新字段的格式完全相同。但是,在应用程序代码中访问扩展字段的方式与访问常规字段略有不同 - 生成的数据访问代码具有用于处理扩展的特殊访问器。那么,举个例子,下面就是如何在 C++ 中设置 bar 的值:

Foo foo;
foo.SetExtension(bar, 15);

类似地,Foo 类定义模板化访问器 HasExtension(),ClearExtension(),GetExtension(),MutableExtension() 和 AddExtension()。它们都具有与正常字段生成的访问器相匹配的语义。有关使用扩展的更多信息,请参阅所选语言的代码生成参考。

PS:扩展可以是任何字段类型,包括 message 类型,但不能是 oneofs 或 maps。

另外,你也可以在另一种 message 类型内部声明扩展:

message Baz {
  extend Foo {
    optional int32 bar = 126;
  }
}

在这种情况下,访问此扩展的 C++ 代码为:

Foo foo;
foo.SetExtension(Baz::bar, 15);

换句话说,唯一的影响是 bar 是在 Baz 的范围内定义。
如果你的编号约定可能涉及那些具有非常大字段编号的扩展,则可以使用 max 关键字指定扩展范围至编号最大值:

message Foo {
  extensions 1000 to max;
}

最大值为 229 - 1,或者 536,870,911。与一般选择字段编号时一样,你的编号约定还需要避免 19000 到 19999 的字段编号(FieldDescriptor::kFirstReservedNumberFieldDescriptor::kLastReservedNumber),因为它们是为 Protocol Buffers 实现保留的。你可以定义包含此范围的扩展名范围,但 protocol 编译器不允许你使用这些编号定义实际扩展名。

9. Any类型

Any类型消息允许在没有指定.proto定义的情况下使用消息作为一个嵌套类型。一个Any类型包括一个可以被序列化bytes类型的任意消息以及一个URL作为一个全局标识符和解析消息类型。
为了使用Any类型,需要导入import 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
不同语言的实现会支持动态库以线程安全的方式去帮助封装或者解封装Any值。例如在java中,Any类型会有特殊的pack()和unpack()访问器,在C++中会有PackFrom()和UnpackTo()方法。

10. Oneof

Oneof定义用来代表在实现的时候,该组属性中有且只能有一个被定义,不能出现多个。

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

上述定义中只能出现name或者sub_message的出现,不能同时出现,同时Oneof不能出现repeated域。重复传递值到Oneof多个域仅仅最后的会生效,其它的将被忽略掉。

11. Map

如果要创建一个关联映射,Protobuf提供了一种快捷的语法:

map map_field = N;

其中key_type可以是任意Integer或者string类型(除了floating和bytes的任意标量类型都可以),value_type可以是任意类型,但不能是map类型。例如,创建一个Project的映射,每个Projecct使用一个string作为key:

map projects = 3;

Map的字段可以是repeated。序列化后的顺序和map迭代器的顺序是不确定的,所以不要期望以固定顺序处理Map。当为.proto文件产生生成文本格式的时候,map会按照key 的顺序排序,数值化的key会按照数值排序。
从序列化中解析或者融合时,如果有重复的key则后一个key不会被使用。

PS: map语法序列化后等同于如下内容,因此即使是不支持map语法的Protobuf实现也可以处理数据:

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

12. 定义服务

如果想要将消息类型用在RPC(远程方法调用)系统中,可以在.proto文件中定义一个RPC服务接口,Protobuf编译器将会根据所选择的不同语言生成服务接口代码及stub。如要定义一个RPC服务并具有一个方法Search,Search方法能够接收SearchRequest并返回一个SearchResponse,可以在.proto文件中进行如下定义:

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

最直观的使用Protobuf的RPC系统是gRPC,由谷歌开发的语言和平台中的开源的PRC系统,gRPC在使用Protobuf时非常有效,如果使用特殊的Protobuf插件可以直接从.proto文件中产生相关的RPC代码。
如果不想使用gRPC,可以使用Protobuf用于自己的RPC实现。

三. 编译Protoc文件

Protobuf提供了protoc编译器,用于通过定义好的.proto文件来生成Java,Python,C++,Ruby,Objective-C,C#,Go等语言代码。
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 --javanano_out=DST_DIR --objc_out=DST_DIR --csharp_out=DST_DIR path/to/file.proto

1. 导入目录设置

IMPORT_PATH声明了一个.proto文件所在的解析import具体目录。如果忽略该值,则使用当前目录。如果有多个目录则可以多次调用--proto_path,会顺序的被访问并执行导入。-I=IMPORT_PATH是--proto_path的简化形式。

2. 生成代码指定

--cpp_out :在目标目录DST_DIR中产生C++代码
--java_out :在目标目录DST_DIR中产生Java代码
--python_out :在目标目录 DST_DIR 中产生Python代码
--go_out :在目标目录 DST_DIR 中产生Go代码
--ruby_out:在目标目录 DST_DIR 中产生Ruby代码
--javanano_out:在目标目录DST_DIR中生成JavaNano
--objc_out:在目标目录DST_DIR中产生Object代码
--csharp_out:在目标目录DST_DIR中产生Object代码
--php_out:在目标目录DST_DIR中产生Object代码

3. 导入proto消息文件指定

必须指定一个或多个.proto文件作为输入,多个.proto文件可以只指定一次。虽然文件路径是相对于当前目录的,每个文件必须位于其IMPORT_PATH下,以便每个文件可以确定其规范的名称。

4. 生成编程语言相关代码

当用Protobuf编译器来运行.proto文件时,编译器将生成所选择语言的代码,相应语言的代码可以操作在.proto文件中定义的消息类型,包括获取、设置字段值,将消息序列化到一个输出流中以及从一个输入流中解析消息。
对C++语言,编译器会为每个.proto文件生成一个.h文件和一个.cc文件,.proto文件中的每一个消息有一个对应的类。
对Java语言,编译器为每一个消息类型生成了一个.java文件以及一个特殊的Builder类(用来创建消息类接口的)。
对Go语言,编译器会为每个消息类型生成了一个.pb.go文件。
对Ruby语言,编译器会为每个消息类型生成了一个.rb文件。

【参考文章】

  1. gRPC快速入门(一)——Protobuf简介
  2. [翻译] ProtoBuf 官方文档(二)- 语法指引(proto2)
  3. Protobuf学习

你可能感兴趣的:(Android技术周报190317期 —— Protocbuf全面解析)