这篇文章主要对protocol buffer做基本介绍(针对java开发人员),我门通过一个简单的例子来学习,其中主要包含三块内容:
- 在.proto文件中定义一个消息格式
- 使用protobuf编译器
- 使用java protocol buffer API读写消息
为什么使用Protocol Buffers
在本文中我们要使用的例子是一个"address book" 应用,主要是从文件中读写联系人信息,每条联系人信息包含:名称、ID、邮箱、手机号。
如何序列化和检索这些结构化数据呢?我们一般有下面几种方式来处理:
- 使用Java Serialization。这是java语言中已经集成的默认方法。但是这种处理方式有很多问题( 参考Josh Bloch写的Effective Java pp. 213),并且如果是c++或者python等其他语言写的应用无法很好的共享这种方式序列化的数据,
- 自定义数据格式,例如:“12:3:-23:67”。这是一种简单灵活的方式,但是需要写编解码的代码,并且在运行时解析会有一定的消耗。这种方式适合非常简单数据格式。
- 使用XML,这种方式非常适合阅读,并且很多语言都有对xml支持库,这是一种很好的在不同语言之间分享数据的一种方式。但是,xml会占用大量的空间,并且在编解码时会带来很大的性能损耗。XML DOM 树查找方式也比普通类简单字段查找更加复杂。
Protocol buffers是一种灵活、高效、自动化的解决方案,可以解决上述方式中存在的问题。使用protocol buffers,需要将想要存储的数据结构写入到.proto文件中。protocol buffer编译器会根据.proto文件生成一个类,此类已经通过高效的二进制格式实现编码解析数据格式,还包含.proto中定义的字段的getter和setter方法,其中的读写细节会在类中自动处理。更重要的是,它还支持以某种方式扩展协议之后仍然可以读写旧格式的数据。
定义Protocol格式
下面开始我们的demo,首先我们先创建一个.proto格式的文件,文件中定义的内容也很简单:为每一个数据结构定义一个message,然后在message中指定属性的名称和类型。addressbook.proto的示例代码如下:
syntax = "proto2";
package tutorial;
option java_package = "com.example.tutorial";
option java_outer_classname = "AddressBookProtos";
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
required string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
让我们来分析下上面的代码,从上面代码中可以看到proto的语法格式跟java或c++很像。
首先,我们自定package声明,用来防止在不同的工程下名称冲突问题。默认情况下,此处package会用作java的package,当然也可以配置java_package来指定生成java类的包名,上述代码中是通过java_package来指定包名。其中要注意的是,即使我们指定了java_package,我们也要指定package,这样来避免在pb或者生成其他语言时导致的名称冲突问题。
接下来是两个java特别指定:java_package和java_outer_classname,其中java_package刚才已经说过,不过要注意的是,一般情况下java包名是按照域名来定义的,跟pacakge定义的不太匹配。java_outer_classname是用来指定生成的java类的名称,如果没有指定java_outer_classname,编译器会根据文件名称按照驼峰命名法生成java类名,例如,“my_proto.proto” 生成的类名为"MyProto"。
然后是message的定义,message是包含一组类型字段的汇总。一些常见的简单字段类型可以直接使用,包括:bool, int32, float, double, string
,当然也可以指定自定义类型的数据类型。在上面的demo中Person包含PhoneNumber的message类型,AddressBook包含Person的消息类型。也可以在message内嵌套定义message,例如PhoneNumber定义在Person内部。你也可以定义enum类型,例如上例中的PhoneType。
字段修饰符说明:
- required: 用此作为修饰符的字段必须指定值,否则会被认为未初始化,在编译未初始化的字段时会抛出RuntimeException异常,解析为初始化的字段会抛出IOException异常。其他的跟optional修饰的字段相同。
- optional: 这个修饰符指定的字段可以指定值也可以不用指定,如果没有指定的场景下,会使用一个默认值。对于普通简单字段来说,你可以自定指定一个默认值,例如上述demo中的PhoneType。如果没有指定,则会使用系统默认值,数值型变量默认为0,字符串类型为空字符串,布尔类型为false。对于嵌入的message类型,默认值为这个message的"default instance" 或者 “prototype” ,其没有设置任何字段。
- repeated: 这个字段可以被重复多次,重复的顺序会被记录下来。可以将其理解为动态分配的数组。
注意: 当使用required修饰时一定要小心,因为当你从required修改为optional时会产生一些问题,修改之后老版本的程序解析数据时认为此处是必须的,而新版本的没有指定,就会造成老版本的异常,出现兼容问题。对于这种必须指定的尽量在程序代码中做check。所以在定义proto时尽量少用required来修饰。
说明: proto格式不支持继承。
编译proto文件
现在已经写好来.proto文件,接下来我们可以使用编译器来生成Java类了。
1.下载编译器
下载地址:https://developers.google.com/protocol-buffers/docs/downloads
因为新版本的编译器是向后兼容的,所以下载最新版本的protoc编译器即可。
2.编译生成java代码
编译使用:
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
demo编译如下:
$ ls
addressbook.proto dist
$ protoc -I=./ --java_out=./dist/ ./addressbook.proto
查看dist目录
B000000141481OS:tutorial$ cd dist
B000000141481OS:dist$ ls
com
B000000141481OS:dist$ cd com/example/tutorial/
B000000141481OS:tutorial$ ls
AddressBookProtos.java
可以看到已经生成AddressBookProtos的Java类
Protocol Buffer API
接下来让我们分析一下编译器生成的部分Java代码,看下编译器为我们生成类哪些类和方法。
首先,打开生成的java文件,会看到定义的类AddressBookProtos,此类里面包含类proto文件中指定的消息类。每个类都包含一个Builder的类,用来创建类的实例。每个类及对应的builder中会生成字段访问接口,类仅生成getter接口,builder生成getter和setter接口。下面是Person类中的一些简化java代码:
// required string name = 1;
public boolean hasName();
public String getName();
// required int32 id = 2;
public boolean hasId();
public int getId();
// optional string email = 3;
public boolean hasEmail();
public String getEmail();
// repeated .tutorial.Person.PhoneNumber phones = 4;
public List<PhoneNumber> getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);
Person.Builder中的部分代码如下:
// required string name = 1;
public boolean hasName();
public java.lang.String getName();
public Builder setName(String value);
public Builder clearName();
// required int32 id = 2;
public boolean hasId();
public int getId();
public Builder setId(int value);
public Builder clearId();
// optional string email = 3;
public boolean hasEmail();
public String getEmail();
public Builder setEmail(String value);
public Builder clearEmail();
// repeated .tutorial.Person.PhoneNumber phones = 4;
public List<PhoneNumber> getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);
public Builder setPhones(int index, PhoneNumber value);
public Builder addPhones(PhoneNumber value);
public Builder addAllPhones(Iterable<PhoneNumber> value);
public Builder clearPhones();
从上述代码中可以看到,编译器会为每个字段生成简单的JavaBean风格代码,也会提供has方法来判断字段是否已经被配置,clear方法用来恢复字段空值状态。
repeated字段会提供一些额外的针对list的处理方法,如Cout用来获取list的大小,通过index来get或者set 值,通过add添加元素,addAll添加所有元素到list中。
注意: 编译器生成的java代码是遵从java约束规范按照驼峰命名法生成的,但是在proto文件中均统一使用小写+下划线方式命名,这样保证了生成其他语言的规范。
枚举和内嵌类
在Person类下包含PhoneType枚举:
public static enum PhoneType {
MOBILE(0, 0),
HOME(1, 1),
WORK(2, 2),
;
...
}
Person.PhoneNumber类也会内嵌在Person类中
Builders Vs Messages
所有的更具message生成的java类都是固定不变的,如果要构造一个类,首先需要创建一个builder,把想要设置的值通过builder设置,然后调用build()方法。
下面是一个创建Person实例的代码:
Person john =
Person.newBuilder()
.setId(1234)
.setName("John Doe")
.setEmail("jdoe@example.com")
.addPhones(
Person.PhoneNumber.newBuilder()
.setNumber("555-4321")
.setType(Person.PhoneType.HOME))
.build();
Message标准方法
每个builder和message类包含一些额外的方法用来检查或者操纵整个消息,包含:
- isInitialized() : 检查所有的required字段是否已经初始化
- toString(): 返回一个可读的消息内容,主要用来方便调试
- mergeFrom(Message other): 合并其他消息,覆盖但单个相同类型字段,合并复合类型字段,整合repeated字段。
- clear():设置所有的字段为空。
解析和序列化
最后,每个类中都包含读写protocol buf二进制类型的接口方法。
- byte[] toByteArray(): 序列化消息并返回一个包含其二进制数据的byte数组
- static Person parseFrom(byte[] data): 从给定的字节数组中解析消息内容
- void writeTo(OutputStream output);: 序列化消息并将其写入到OutputStream
- static Person parseFrom(InputStream input);: 从InputStream读取并解析消息
写消息
让我来用protoc生成的类来写消息到文件中,代码:
import com.example.tutorial.AddressBookProtos.AddressBook;
import com.example.tutorial.AddressBookProtos.Person;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.PrintStream;
class AddPerson {
// This function fills in a Person message based on user input.
static Person PromptForAddress(BufferedReader stdin,
PrintStream stdout) throws IOException {
Person.Builder person = Person.newBuilder();
stdout.print("Enter person ID: ");
person.setId(Integer.valueOf(stdin.readLine()));
stdout.print("Enter name: ");
person.setName(stdin.readLine());
stdout.print("Enter email address (blank for none): ");
String email = stdin.readLine();
if (email.length() > 0) {
person.setEmail(email);
}
while (true) {
stdout.print("Enter a phone number (or leave blank to finish): ");
String number = stdin.readLine();
if (number.length() == 0) {
break;
}
Person.PhoneNumber.Builder phoneNumber =
Person.PhoneNumber.newBuilder().setNumber(number);
stdout.print("Is this a mobile, home, or work phone? ");
String type = stdin.readLine();
if (type.equals("mobile")) {
phoneNumber.setType(Person.PhoneType.MOBILE);
} else if (type.equals("home")) {
phoneNumber.setType(Person.PhoneType.HOME);
} else if (type.equals("work")) {
phoneNumber.setType(Person.PhoneType.WORK);
} else {
stdout.println("Unknown phone type. Using default.");
}
person.addPhones(phoneNumber);
}
return person.build();
}
// Main function: Reads the entire address book from a file,
// adds one person based on user input, then writes it back out to the same
// file.
public static void main(String[] args) throws Exception {
if (args.length != 1) {
System.err.println("Usage: AddPerson ADDRESS_BOOK_FILE");
System.exit(-1);
}
AddressBook.Builder addressBook = AddressBook.newBuilder();
// Read the existing address book.
try {
addressBook.mergeFrom(new FileInputStream(args[0]));
} catch (FileNotFoundException e) {
System.out.println(args[0] + ": File not found. Creating a new file.");
}
// Add an address.
addressBook.addPerson(
PromptForAddress(new BufferedReader(new InputStreamReader(System.in)),
System.out));
// Write the new address book back to disk.
FileOutputStream output = new FileOutputStream(args[0]);
addressBook.build().writeTo(output);
output.close();
}
}
读消息
同样,我们将写的信息内容读取出来,代码如下:
import com.example.tutorial.AddressBookProtos.AddressBook;
import com.example.tutorial.AddressBookProtos.Person;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.PrintStream;
class ListPeople {
// Iterates though all people in the AddressBook and prints info about them.
static void Print(AddressBook addressBook) {
for (Person person: addressBook.getPeopleList()) {
System.out.println("Person ID: " + person.getId());
System.out.println(" Name: " + person.getName());
if (person.hasEmail()) {
System.out.println(" E-mail address: " + person.getEmail());
}
for (Person.PhoneNumber phoneNumber : person.getPhonesList()) {
switch (phoneNumber.getType()) {
case MOBILE:
System.out.print(" Mobile phone #: ");
break;
case HOME:
System.out.print(" Home phone #: ");
break;
case WORK:
System.out.print(" Work phone #: ");
break;
}
System.out.println(phoneNumber.getNumber());
}
}
}
// Main function: Reads the entire address book from a file and prints all
// the information inside.
public static void main(String[] args) throws Exception {
if (args.length != 1) {
System.err.println("Usage: ListPeople ADDRESS_BOOK_FILE");
System.exit(-1);
}
// Read the existing address book.
AddressBook addressBook =
AddressBook.parseFrom(new FileInputStream(args[0]));
Print(addressBook);
}
}
协议扩展
当我们定义好协议,我们后期肯定会因为各种各样的需求来改善协议,如果更改原来的协议那么我们要保证新协议向后兼容,旧协议向前兼容,为来保证兼容性,我们需要遵循下面几条原则:
- 不能更改任何存在的字段的标记数字
- 不能添加或者删除任何required字段
- 可以删除optional或者repeated字段
- 添加optional或者repeated字段必须使用一个新的标记数字,这个标记数字不能在之前定义的字段中使用过,包括之前已经删除的标记数字字段
遵从上面的原则,老版本的代码可以使用新协议的内容:
1)忽略新增加的字段
2)删除的optional字段将会使用其默认值
3)删除的repeated字段会使用一个空的list
对与新版本的代码完全可以直接读取解析旧版本的协议消息。
一定要注意的是旧版本的消息中不会包含新增加的optional字段,所以我们需要在代码中使用has_方法去做检查,或者在定义.proto文件是在标记数字后面添加[default = value]默认值,如果没有指定则会使用系统默认值。