这是一个基于Java使用Protocol Buffer的基础的介绍。通过一个简单的例子,你可以了解如何:
- 在.proto文件中定义消息格式。
- 使用protocol buffer编译器。
- 使用Java protocol buffer API读写消息。
这不是一个关于如何在Java环境下使用protocol buffers的详细文档。对于更多的信息,可以通过Protocol Buffer Language Guide, Java API Reference, Java Generated Code Guide 和Encoding Reference来了解。
为什么使用Protocol Buffers?
这里我们用一个非常简单的“地址簿”应用来说明怎样使用Protocol Buffer来从文件读写信息。地址簿中的每个人有名字,ID,EMali地址,和电话号码。如何把这样的信息保存或传输,然后再恢复出来?一般有几种办法:
- 使用Java串行化(Serialization)。这是缺省的办法,是Java语言本身提供的功能。但是这种方法有一些固有的问题。 (参考Effective Java, by Josh Bloch pp. 213), 并且如果你需要和其他语言开发的应用共享数据则不是很方便。
- 你可以自己创造一种编码方法,例如把4个整数编码成"12:3:-23:67"。 这是一种简单并且灵活的方法,并且不需要特别写专门的处理代码,这是处理简单编码的最好方法。
- 使用XML是另一种方法。因为XML是直接可读的,所以这也是比较吸引人的。这也是和其他应用共享数据的一种好方法。但是,XML文件的数据量是比较大的,而且编解码操作性能较差。另外,XML的DOM树型结构也比正常的Class定义难于理解。
Protocol buffers是一种对上面问题的灵活的,高效的自动解决方案。使用protocol buffers,通过.proto文件来描述你需要存储的数据结构。Protocol buffer编译器可以自动为你生成可以处理编解码的Class文件。通过Class文件,可以存取相应的域的内容。更重要的是,protocol buffer支持内容的扩展,你可以增加新的域而不会影响处理老格式的代码。
获得例子程序代码
例子代码在"examples"目录下。Download it here.
定义你的协议格式
为了建立你的地址簿应用,你需要从定义.proto文件开始。这个文件的定义很容易理解:你为希望存储的每一个数据结构定义一个message,为message的每一个域定义类型和名字。下面是你的地址簿程序的.proto文件的例子:addressbook.proto.
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 phone = 4;
}
message AddressBook {
repeated Person person = 1;
}
这里的语法和C++或Java很类似。让我们仔细研究一下这个文件。
.proto文件从包定义开始,这样可以避免潜在的名字冲突问题。在Java中,你可以象普通Java包一样来定义,或者象例子里面那样,明确指出所在的java_package。尽管你使用了java_package,你还是需要定义一个正常的包名字,这样可以避免在和非Java应用通信时发生名字冲突。
包定义之后,你可以看到两个Java特有的定义项:java_package和java_outer_classname。 java_package定义了生成的类文件应该在哪一个Java的包中,如果你没有定义java_package,那系统会使用你前面定义的包名,不过它们通常不是合适的Java包名。java_outer_classname定义包括所有类文件的类名。如果你没有定义,系统自动通过.proto文件名来转换出类名。例如, "my_proto.proto"文件缺省生成的类名是"MyProto"。
下一步,你可以定义你的Message。一个Message是一个数据域的集合。这里有很多简单类型可以使用,包括bool, int32, float, double,和string。你可以扩展你的message定义,也可以在message中包含其他的message。在上面的例子中,Person就包括了PhoneNumber,而AddressBook包括了Person。你也可以在message中定义message, PhoneNumber就定义在Person内。你也可以定义enum类型,就像上面的MOBILE, HOME或WORK。
" = 1", " = 2"用来设置在二进制流中标识相应数据域的标志(Tag)。标志1-15比其他的标志少需要一个字节,所以这些标志通常被用于常用数据域或重复数据域,而将16以上的标志用于非常用或可选数据域。重复数据域的每一个元素需要一个标志,所以重复数据域是最需要短标志的。
每一个数据域必须有下面之一的说明:
- required: 这个域必须有一个值,否则消息被认为是不完整的。对不完整消息编码会得到RuntimeException。试图对不完整消息解码会得到IOException。
- optional: 这个域可能被设置。如果一个optional的域没有被设置,那么会自动被设置成缺省值。简单数据类型你可自己定义缺省值,就像例子中的phone number。否则系统有自己的缺省值:对于数字是0,对于String是空串,对于bool是false。对于复合数据类型,逐层按照上面的规则设置缺省值。Accessor函数可以取得optional (或required)数据域的缺省值。
- repeated: 这个域可能被重复0到多次。protocol buffer会自动处理重复的次数。类似与自动变长数组。
Required Is Forever 在定义一个数据域为Required的时候应该非常小心,因为一旦你定义了某个数据域为Required,你很难再将它改为Optional,除非你原因修改所有使用这个数据域的老代码。有些Google的工程师认为使用Required的缺点大于优点,他们只使用Optional和Repeat。当然,这个观点只供参考。
你可以找到如何定义.proto的指南,包含所有可能的数据域定义。Protocol Buffer Language Guide。不要试图去找类继承,protocol buffers不支持。
编译你的Protocol Buffers
现在你有了.proto文件,下一步你需要生成你需要的类文件。你需要使用protoc工具来编译你的.proto:
- 如果你还没有编译器,download the package,按照README的指示来安装。
- 运行编译器,需要的参数是你的源文件目录(缺省是当前目录)和你们目的目录(缺省和源目录一致),以及你的.proto文件对于源目录的相对位置和名字。例如:
protoc -I=$SRC_DIR --java_out=$DST_DIR addressbook.proto
因为你需要Java类文件,所以你使用--java_out选项。
这样将在你的目标目录生成com/example/tutorial/AddressBookProtos.java文件。
Protocol Buffer API
让我们看看编译器为你生成了什么。在AddressBookProtos.java里,你可以看到一个叫AddressBookProtos的类,包含了你的addressbook.proto中的每一个message的定义类。每一个类有自己的Builder类。(你可以在后面的Builders vs. Messages章节找到更多关于Builder的信息)
Message和Builder都有对每一个数据域自动生成的accessor方法,messages 只有getter,而builders同时有getter和setter。下面是Person类的一些accessor (实现部分省略):
// 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 phone = 4;
public List<PhoneNumber> getPhone();
public int getPhoneCount();
public PhoneNumber getPhone(int index);
Meanwhile, Person.Builder has the same getters plus setters:
// 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 phone = 4;
public List<PhoneNumber> getPhone();
public int getPhoneCount();
public PhoneNumber getPhone(int index);
public Builder setPhone(int index, PhoneNumber value);
public Builder addPhone(PhoneNumber value);
public Builder addAllPhone(Iterable<PhoneNumber> value);
public Builder clearPhone();
可见,每一个数据域都有JavaBeans风格的getter和setter。同时还有方法可以取得某个数据域是否被设置。最好,每个数据域还有一个clear方法来清除设置的数据。
Repeated数据域的方法更多一些,一个Count方法可以取得数据项的数目, getter和setter可以根据索引设置指定的数据项, add方法可以增加新的数据项, addAll方法可以一次增加一组数据项。
需要注意accessor方法的命名风格,尽管.proto使用小写加下划线来命名数据域,protocol buffer编译器自动生成Java风格的accessor方法名。你需要在.proto文件中使用小写加下划线风格的命名方式,这样可以保证在任何支持的语言中生成合适的数据域名。参考style guide。
参考Java generated code reference可以了解更多。
Enums和嵌套类(Nested Classes)
生成的代码支持enum定义,就像Persion中的PhoneType:
public static enum PhoneType {
MOBILE(0, 0),
HOME(1, 1),
WORK(2, 2),
;
...
}
系统自动生成嵌套类型Person.PhoneNumber。
Builders 对比. Messages
protocol buffer编译器生成的message类是不可变的。一旦生成了message对象,它不能被修改。为了生成message,你必须先生成一个builder,设置数据域,再调用build()方法。
你可能注意到了每一个修改数据域的方法都返回一个builder对象,实际上这个builder对象就是你拿来调用的那一个。这样定义可以让在一行中连续调用多个修改数据域的函数。 下面是一个例子:
Person john =
Person.newBuilder()
.setId(1234)
.setName("John Doe")
.setEmail("jdoe@example.com")
.addPhone(
Person.PhoneNumber.newBuilder()
.setNumber("555-4321")
.setType(Person.PhoneType.HOME))
.build();
标准Message方法
每个message和builder类还有很多其他的辅助方法:
- isInitialized(): 检查是否全部的required数据域都被设置。
- toString(): 返回一个字符串,通常用于Debug调试。
- mergeFrom(Message other): (只有builder)将参数消息合并进当前消息,覆盖简单数据域,叠加Repeated数据域。
- clear(): (只有builder) 清除所有的数据域。
这些方法在Message和Message.Builder接口定义。参考 complete API documentation for Message.
解析和串行化
最好,每个protocol buffer类有从二进制序列(binary format)中读写message的方法。包括:
- byte[] toByteArray();: 返回一个二进制比特序列。
- static Person parseFrom(byte[] data);: 从二进制序列中取得message。
- void writeTo(OutputStream output);: 将message写入一个输出流。
- static Person parseFrom(InputStream input);:从一个输入流中读取一个message。
这里只列出了常用的几个方法。参考Message API reference可以知道全部相关的方法。
Protocol Buffers和O-O Design Protocol buffer类是基本的数据存储对象。它并不支持真正的面向对象。如果你需要面向对象的特性,最好的办法是用你定义的类来包裹Protocol Buffer生成的类,在其中加上面向对象的处理。这也是在你不直接控制.proto文件的情况下的一个比较好的做法(例如,你用的是另一个项目的.proto定义)这种情况下,最好用自己的一层Wrapper来隔离生成类和你的应用,这样在.proto文件发生你不能控制的变化时,可以保证你的应用正常工作。但是,不要试图直接修改生成类,这样不是好的面向对象的做法。
写一个Message
下面我们来使用你的protocol buffer类。你的地址簿应用需要做的第一件事是将一个人的信息写进你的地址簿文件。你需要简历一个实例将其写入文件。
下面的程序从文件读取地址簿,增加一个人的信息,再将新的地址簿写回到文件
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.addPhone(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();
}
}
读Message
显然,如果不能把数据读回来,你的地址簿程序也没什么用处。下面的程序把上面程序写入的信息读回来再显示出来:
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.getPersonList()) {
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.getPhoneList()) {
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);
}
}
扩展Protocol Buffer
当你Release了一个版本以后,早晚你回遇到需要扩展你的message的需求。如果你希望你的接口可以向后兼容,下面有几个规则你需要注意:
- 你不能改变已经存在数据域的标志(tag)
- 你不能增加或删除Required数据域.
- 你可以删除optional或repeated数据域。
- 你可以增加新的optional或repeated数据域,但是你必须使用全新的标识号 (例如,即使你删除了一个标识为4的数据域,4也不能被你新加的数据域使用)
(这里有一些例外some exceptions ,但是不常用)
如果你遵守这些规则,旧代码可以兼容你的新消息,忽略你新增加的数据域。你删除的optional数据会被设置成缺省值,删除的repeated数据域将没有数据项。新代码也可以兼容老消息。需要注意的是,老消息中不会有新的Optional数据域,你永远得到的都是缺省值,你要在你的程序中判断做出处理,防止某些缺省值不能被你的程序接受。同样,老消息中新增加的repeated数据域永远没有数据项,你也需要进行处理。
更快的速度
缺省的, protocol buffer编译器生成尽可能小的文件。但是,你可以设置让编译器生成优化的代码,尽可能地提高速度,但是代码长度会加倍。如果你需要搞速度,你可以在你的.proto文件中增加下面:
option optimize_for = SPEED;
重新编译.proto文件,你会得到可以高速处理编码的代码。
高级用法
除了简单的accessor以外,Protocol buffers还有很多高级用法。参考Java API reference可以详细了解还能做什么。
一个很有用的功能是反射(reflection)。你可以编写不针对任何具体消息的消息处理代码,例如编写message到XML的转换工具,或者比较两条message的不同,或者对于message进行排序等等。你可以创造性地发挥,利用protocol buffer来实现更多的功能。
反射机制在Message和Message.Builder接口定义。