protobuf那些事一

97次阅读

共计 9451 个字符,预计需要花费 24 分钟才能阅读完成。

protobuf 是什么

protobuf 是 Google 创建的,是一种语言无关、平台无关、可扩展的序列化结构化数据的方法,可用于通信协议、数据存储等。
在序列化结构化数据的机制中,是灵活、高效、自动化的。相比于 XML,更小、更快、更简单。

为什么不直接用 XML

在序列化方面,protobuf 具有以下优势:

  1. 更简单
  2. 数据体积小 3 -10 倍
  3. 反序列化速度快 20-100 倍
  4. 生成更容易以编程方式使用的数据访问类

举个例子:咱们为一个具有 nameemaiperson 建模。
在 XML 中,我们是这样写的:

<person>
    <name>John Doe</name>
    <email>jdoe@example.com</email>
</person>

在 protocol buffers 中,我们是这样写的:

# Textual representation of a protocol buffer.
# This is *not* the binary format used on the wire.
person {
  name: "John Doe"
  email: "jdoe@example.com"
}

从性能上看,protocol buffers 经过编码后,用二进制的方式传输,只要可能有 28 个字节长,且需要大约 100-200 纳秒来解析。即便删除空白,那么 XML 至少是 69 字节长,解析大约需要 5000-10000 纳秒。
在编码方面,protocol buffers 也是更简洁的。
protocol buffers 读取是这样的:

cout << "Name:" << person.name() << endl;
cout << "E-mail:" << person.email() << endl;

XML 读取是这样的:

cout << "Name:"
       << person.getElementsByTagName("name")->item(0)->innerText()
       << endl;
cout << "E-mail:"
       << person.getElementsByTagName("email")->item(0)->innerText()
       << endl;

当然,相比于 protobuf,XML 依然也有自己的优势的,比如基于文本的使用标记(例如 HTML)建模。而且 XML 对于我们来说,是可读的,可编辑的,它具有自解释性。protobuf 是二进制的形式,所以只有.proto 定义,我们才可以进行解读。

准备工作

我 ide 是 idea 的,安装了两个插件,GenProtobuf 是用来生成 java 文件的,Protobuf Support 是用来高亮、语法检查等。

GEnProtobuf 设置,打开菜单:

设置 protoc.exe 的路径,以及生成的文件路径

在 idea 点击.proto 文件,右键,选择 quick gen protobuf rules,就可以在我们指定的地方生成 java 文件,如果选择 quick gen protobuf here 就会在当前目录生成 java 文件。

pom 文件如下:

<dependencies>
    <dependency>
        <groupId>com.google.protobuf</groupId>
        <artifactId>protobuf-java</artifactId>
        <version>3.9.1</version>
    </dependency>
    <dependency>
        <groupId>com.google.protobuf</groupId>
        <artifactId>protobuf-java-util</artifactId>
        <version>3.9.1</version>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.9</version>
        <scope>test</scope>
    </dependency>
</dependencies>

proto3

官方虽然将继续支持 proto2,但鼓励新代码使用 Proto3,因为它更容易使用,支持更多的语言。

一个简单的 message

Person.proto:

syntax = "proto3";// 指定了用的是 proto3 语法,不写默认 proto2 语法
package ch0;// 定义 proto 的包名,可以通过 package 防止命名冲突。/*
Person 包含 name 和 age 两个属性
*/
message Person {
    string name = 1;
    int32 age =2;
}

跟我们 java 一样,用 ///* ... */来注释单行和一段。
文件的第一个非空、非注释行,指定了使用了 proto3 语法,如果没指定,默认是 proto2 语法。
message Person{}指定了这个 Message 的名称是 Person,里面每一行都有三项,字段类型、字段名称、字段编号。
字段类型与各个语言的关系图如下:

第二个是字段名称,名称肯定要唯一,这个没什么好说的。
repeated 关键字,重复的意思,类似于数组或 List。
第三个是字段编号,每个字段都有一个惟一的编号。在上面例子中,假设 name 的值是张三,在转换二进制的时候,不会是 name:张三这种形式的,而是用字段编号 1:张三这种形式。在解析二进制的时候,也是获取 1:张三,然后 1 跟上面 Message 对应取到 name,再转为 name,这样二进制字节就会变短了。
这些字段编号,考虑到需求变化导致多个版本字段可能变动,所以尽量不要修改字段编号,不然有可能 1.0 版本 1 对应的是 name,1.1 版本却对应着 age,系统就混乱了。由于 1 到 15 的字段编号只需要一个字节进行编码,所以用的比较频繁的字段,建议都要这个范围。
字段编号的范围是 1 到 2^29-1(536,870,911),中间 19000 到 19999 是作为保留的数字,我们不能够使用的,在编译的时候就会报错。而且,也不能使用之前的保留数字,这个后面会讲字段删除修改时要怎么处理。
通过 GenProtobuf 生成的 java 文件,这边没有指定文件夹和类名,具体的文件生成规则后面讲。为了方便,后面我都指定文件夹和类名。

我们可以用生成的 java 类,调用 name 和 age 的 set 和 get 方法。

@Test
public void test1(){PersonOuterClass.Person.Builder person =  PersonOuterClass.Person.newBuilder();
    person.setAge(18);
    person.setName("张三");
    person.getAge();
    person.getName();}

多个 Message

一个.proto 文件中定义多个 Message。比如多个有关联的 Message,就可以添加到相同的.proto:

syntax = "proto3";

package ch2;
option java_package = "com.example.ch2";
option java_outer_classname = "Animo";
message Cat{
    string name = 1;
    int32 age =2;
}

message Dog{
    string name = 1;
    int32 age =2;
}

测试代码

@Test
public void test2(){Animo.Cat.Builder cat = Animo.Cat.newBuilder();
    cat.setAge(1);
    cat.setName("kitty");
    cat.getName();
    cat.getAge();

    Animo.Dog.Builder dog = Animo.Dog.newBuilder();
    dog.setAge(2);
    dog.setName("wangcai");
    dog.getName();
    dog.getAge();}

合并后的 java 类其实是同一个,只是通过不同的 Builder 来获取。

保留字段

上面有提过,当我们需求变更时,可能有不用的字段,那要怎么处理呢?如果直接删掉或者注释掉,有可能其他版本的应用或其他应用会用到这个字段,那就会导致严重的问题,比如数据损坏、隐私 bug 等。因此,我们就好保留这些不用的字段名称和字段编号。
编译器会提示我们错误,比如 2 通过 reserved 标记为保留的字段编号,在 age 使用的时候,就会提示了:

注意,不能在同一个保留语句中混合字段名和字段号。需要分开填写。

枚举

syntax = "proto3";
package ch4;
option java_package = "com.example.ch4";
option java_outer_classname = "MyEnum";

message Goods {
    string name = 1;
    enum Colors {
        red = 0;
        green = 1;
        blue = 2;
    }
    Goods.Colors color = 2;
    Sizes size = 3;
}

enum Sizes {
    option allow_alias = true;
    X = 0;
    XL = 1;
    XXL = 1;
}

用枚举的时候,为了与 proto2 兼容,必须有一个 0 值,且 0 值必须是第一个元素。
在上面的例子中,定义了两个枚举,一个是在 Message 中,一个是 Message 外,如果在其他 Message 中,需要用 Message 的名称加枚举获取,我这里演示的是自己用自己的,所以可以把 Goods. 去掉也是可以的。枚举的常数,必须在 32 位整数范围内,不推荐使用负值。
可以看到,Sizes 有两个值都是 1,这个时候,需要设置 allow_alias 为 true。我们看看测试代码和运行结果:

 @Test
public void test4() {MyEnum.Goods.Builder goods = MyEnum.Goods.newBuilder();
    goods.setColorValue(1);
    goods.setSizeValue(1);
    System.out.println(goods.getColor());
    System.out.println(goods.getColorValue());
    System.out.println(goods.getSize());
    System.out.println(goods.getSizeValue());
}

在反序列化期间,无法识别的 enum 值将保留在 Message 中,不同的语言有不同的处理。比如 C 和 Go,未识别的 enum 值只是作为其基础整数表示形式存储。在 Java 中,未识别的 enum 值会标识无法识别的值。在任何一种情况下,如果消息被序列化,未被识别的值仍将与消息一起序列化。
枚举的保留值写法跟保留字段一样。

默认值

proto 文件:

syntax = "proto3";
package ch5;
option java_package = "com.example.ch5";
option java_outer_classname = "MyDefault";

message DefaultValue {
    string name = 1;
    int32 age = 2;
    bytes bt = 3;
    bool bl = 4;
    Sizes size = 5;
}

enum Sizes {
    X = 0;
    XL = 1;
}

测试代码:

@Test
public void test5() {MyDefault.DefaultValue.Builder builder = MyDefault.DefaultValue.newBuilder();

    System.out.println(builder.getName());
    System.out.println(builder.getAge());
    System.out.println(builder.getBt());
    System.out.println(builder.getBl());
    System.out.println(builder.getSize());
}

运行结果:

strings:空的字符串
bytes:长度为 0 的 ByteString
bools:false
numeric:0
enums:默认第一个

使用其他 Message

用其他的 Message 类型作为字段类型,就好像 java 的 PO 中引入了其他的 PO。

syntax = "proto3";
package ch6;
option java_package = "com.example.ch6";
option java_outer_classname = "MyOtherMsg";

message MyMsg {MyOther myOther = 1;}

message MyOther {string name = 1;}

测试代码:

@Test
public void test6() {MyOtherMsg.MyMsg.Builder builder = MyOtherMsg.MyMsg.newBuilder();
    MyOtherMsg.MyOther.Builder myOther =MyOtherMsg.MyOther.newBuilder();
    myOther.setName("张三");
    builder.setMyOther(myOther);
    MyOtherMsg.MyOther myOther2 = builder.getMyOther();
    System.out.println(myOther2.getName());
}

运行结果:

导入其他文件

上面的例子中,如果 MyMsg 和 MyOther 不在一个文件中呢,那就需要用 import 引入。
MyMsg.proto

syntax = "proto3";
package ch7;
option java_package = "com.example.ch7";
option java_outer_classname = "MyMsg2";
import "MyOther.proto";

message MyMsg {MyOther myOther = 1;}

MyOther.proto

syntax = "proto3";
package ch7;
option java_package = "com.example.ch7";
option java_outer_classname = "MyOther2";

message MyOther {string name = 1;}

测试代码:

@Test
public void test7() {MyMsg2.MyMsg.Builder builder = MyMsg2.MyMsg.newBuilder();
    MyOther2.MyOther.Builder myOther =MyOther2.MyOther.newBuilder();
    myOther.setName("张三");
    builder.setMyOther(myOther);
    MyOther2.MyOther myOther2 = builder.getMyOther();
    System.out.println(myOther2.getName());
}

运行结果如下:

嵌套类型

也就是说在 Message 中定义了 Message 类型,比如下面在 Nest 中定义了 Inner:

syntax = "proto3";
package ch8;
option java_package = "com.example.ch8";
option java_outer_classname = "NestType";

message Nest {
    message Inner{string name = 1;}
    Inner inner = 1;
}

测试代码,有点像内部类

@Test
public void test8() {NestType.Nest.Builder builder = NestType.Nest.newBuilder();
    NestType.Nest.Inner.Builder inner = NestType.Nest.Inner.newBuilder();
    inner.setName("张三");
    builder.setInner(inner);
    NestType.Nest.Inner inner2 = builder.getInner();
    System.out.println(inner2.getName());
}

运行结果:

更新 Message

更新 message,主要是不能更改任何现有字段的字段编号,以及字段类型的兼容。
比如 int32、uint32、int64、uint64、bool 兼容,sint32 和 sint64 兼容,string 和 bytes(如果 bytes 是合法的 UTF-8)兼容,fixed32 与 sfixed32 兼容,fixed64 与 sfixed64 兼容,enum 和 int32、uint32、int64、uint64 兼容(值不适合会被截断)。

未知字段

未知字段是 protobuf 序列化数据,表示解析器无法识别的字段。例如,当旧的二进制代码解析带有新字段的新二进制代码发送的数据时,这些新字段将成为旧二进制代码中的未知字段。比如我们 Message 加了一个新的字段 name,那这个 name 就是未知字段。

Any

Any 字段直接用其他 Message 类型,类似于泛型,需要导入 google/protobuf/any.proto。

syntax = "proto3";
package ch9;
option java_package = "com.example.ch9";
option java_outer_classname = "MyAny";
import "google/protobuf/any.proto";

message Any {
    string name = 1;
    google.protobuf.Any any = 2;
}

测试代码,这个 Person 是第一个例子的。

@Test
public void test9() throws InvalidProtocolBufferException {PersonOuterClass.Person.Builder person =  PersonOuterClass.Person.newBuilder();
    person.setAge(18);
    person.setName("张三");

    MyAny.Any.Builder builder = MyAny.Any.newBuilder();
    builder.setAny(Any.pack(person.build()));
    builder.setName("李四");
    PersonOuterClass.Person person2 = builder.getAny().unpack(PersonOuterClass.Person.class);
    System.out.println(person2.getName()+"-"+person2.getAge());
    System.out.println(builder.getName());
}

运行结果如下:

Oneof

map

需要键值对可以用 map,其中 key_type 可以是 init 或 string 类型(排除 floate 和 byte)。key_type 不能是枚举。

syntax = "proto3";
package ch11;
option java_package = "com.example.ch11";
option java_outer_classname = "MyMap";

message Map {map<string, string> filedMap = 1;}

测试代码:

@Test
public void test11() throws InvalidProtocolBufferException {MyMap.Map.Builder builder =   MyMap.Map.newBuilder();
    builder.putFiledMap("name","张三");
    builder.putFiledMap("age","18");
    System.out.println(builder.getFiledMapMap().get("name"));
    System.out.println(builder.getFiledMapMap().get("age"));
}

运行结果

map 不能用 repeated 修饰。

JSON

Proto3 支持 JSON 格式的规范编码。
proto 文件就用第一个例子的。
测试代码:

@Test
public void test12() throws InvalidProtocolBufferException {JsonFormat.Printer printer = JsonFormat.printer();
    JsonFormat.Parser parser = JsonFormat.parser();

    PersonOuterClass.Person.Builder builder = PersonOuterClass.Person.newBuilder();
    builder.setAge(18);
    builder.setName("lilei");

    String jsonStr = printer.print(builder);
    System.out.println(jsonStr);

    PersonOuterClass.Person.Builder builder2 = PersonOuterClass.Person.newBuilder();
    parser.merge(jsonStr,builder2);
    PersonOuterClass.Person person = builder2.build();
    System.out.println(person);
}

运行结果:

对应关系:

Services 定义

如果要在 RPC(远程过程调用)系统中使用 Message,可以在.proto 文件中定义 RPC 服务接口,protocol buffer 编译器将根据所选语言生成服务接口代码和 stubs。如果我们定义一个 RPC 服务,入参是 SearchRequest 返回值是 SearchResponse,就可以这样在.proto 文件中定义它:

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

与 protocol buffer 一起使用的最直接的 RPC 系统是 gRPC: 在谷歌开发的与语言和平台无关的开放源码 RPC 系统。gRPC 在 protocol buffer 中工作得非常好,还可以允许我们使用特殊的 protocol buffer 编译插件,直接从.proto 文件中生成 RPC 相关的代码。

Options

option 用于对文件的声明,google/protobuf/descriptor.proto 定义了 option 的完整列表。
我们看看上面的 proto 文件,第一个 Person.proto,没有用 option 对文件进行声明,生成的目录结果如下:

可以看到的是,java 文件在根目录下,而且 java 类名是 PersonOuterClass。
我们看看 Map 那个例子的 proto 文件,跟 Person.proto 不同是下面两个 option 定义:

option java_package = "com.example.ch11";
option java_outer_classname = "MyMap";

生成的目录结果如下:

目录是由 java_package 指定的,类名是由 java_outer_classname 决定的。

正文完
 0