共计 9451 个字符,预计需要花费 24 分钟才能阅读完成。
protobuf 是什么
protobuf 是 Google 创建的,是一种语言无关、平台无关、可扩展的序列化结构化数据的方法,可用于通信协议、数据存储等。
在序列化结构化数据的机制中,是灵活、高效、自动化的。相比于 XML,更小、更快、更简单。
为什么不直接用 XML
在序列化方面,protobuf 具有以下优势:
- 更简单
- 数据体积小 3 -10 倍
- 反序列化速度快 20-100 倍
- 生成更容易以编程方式使用的数据访问类
举个例子:咱们为一个具有 name
和emai
的 person
建模。
在 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 决定的。