本文翻译自Protocol Buffer Basics: Java,水平有限,有错误的地方敬请指正,谢谢。
本教程提供一个基本的Java程序员手册来使用protocol buffers。通过了解创建一个简单的示例应用,它能让你知道如何:
在一个
.proto
文件中定义消息格式
使用protocol buffer编译器
使用Java protocol buffer API来读写消息
这不是一个如何在Java中使用protocol buffer的综合性教程。如果需要更多的细致的参考信息,参考Protocol Buffer Language Guide, Java API Reference, Java Generated Code Guide和Encoding Reference。
为什么使用Protocol Buffers?
我们将要使用的例子是一个很简单的“地址簿”应用,我们可以在一个文件上读写人们的联系信息。地址簿上的每一个人有一个名字,一个id,一个email地址和一个联系地址。
你如何序列化并取回像这样结构化的数据呢?下面有几种解决办法:
使用java Serialization。这是默认的方法,因为它是编译在Java语言中的,但是它有一大堆显著的问题(参考 Effective Java, by Josh Bloch pp.213),并且不能在你需要和其他用C++或者Python编写的应用共享数据时使用。
你可以发明一个点对点(ad-hoc)方法来编码数据项到一个string中,例如将4个int编码为“12:3:-23:67”。这是一个简单并且有效的方法,然而它要求编写一次性的编码和解码的代码,并且解码有一个小的运行时间花销。这个方法适用于编码非常简单的数据。
- 将数据序列化为XML。自从XML变得可以直接阅读并且与多种语言进行类库绑定,这个方法变得非常有吸引力。如果你想与其他项目或应用分享数据,这会是一个好选择。然而,XML是出了名的( space intensive),并且对其进行编码和解码对一个应用来说消耗巨大。还有,导航一个XML DOM树是比导航一个普通类中的简单字段要复杂的多。
Protocol buffers是针对这种问题的灵活,高效,自动化的解决方案。有了Protocol buffers,你可以写一个你想保存的数据结构的.proto
描述。Protocol buffer可以根据这个文件编译创建一个类,该类实现了通过一个高效的二进制格式自动编码和解码 Protocol buffer 数据。生成的类为字段提供了getters和satters方法,它们组成一个 protocol buffer 并且解决了将 protocol buffer 作为一个单元进行读写的问题。更重要的事, protocol buffer 格式支持继承过时的格式,这样可以使代码阅读通过旧格式编码的数据。
示例代码
示例代码在源代码包中,examples路径下,此处下载。
定义你的Protocol 格式
要创建你的地址簿应用,你需要从一个.proto
文件开始。.proto
文件中的定义很简单,给每一个你想序列化的数据结构增加一个消息(message),然后指定message中每个字段的名字和类型。如下是一个定义你的消息的.proto
文件,addresbook.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
文件开头是一个package声明,他可以防止不同项目间的命名冲突。在Java中,除非你像我们这样指定一个java_package
,否则这个package名将作为Java的包名。即使你确实提供了一个java_package
,你也应该仍然定义一个普通的package
来避免与非Java语言中的protocol buffer命名空间冲突。
在package声明之后,你可以看到两个指定Java的option:java_package
和java_outer_classname
。java_package指定了你生成的类应当放在哪个Java的包下。如果你不明确的指定,它会简单的匹配package声明提供的包名,但是这些名字通常不是Java的包名(因为他们通常不是以域名开头)。java_outer_classname
选项定义了应当包含所有这个文件中的类的类名。如果你不提供一个java_outer_classname,他会以驼峰形式转化后的文件名作为生成的类名。例如,my_proto.proto默认使用MyProto作为外部类名。
接下来,你可以看到你的消息定义。一个消息只是一个包含一些类型的字段的聚集。许多标准简单数据类型是可以作为字段类型的,包括bool,int32,float,double和string。你也可以通过使用其他message类型作为字段类型来增加更高级的结构到你的消息中,上面的例子中Person的消息包括了PhoneNumber,而AddressBook包括了Person。你也可以在其他消息中嵌套定义消息类型–如你所见,PhoneNumber类型定义在Person中。如果你想让你的一个字段有预定义的值列表,你也可以定义枚举类型—在这里你可以指定一个电话号码为MOBILE,HOME或者WORK。
每个元素的“=1”,“=2”标记确定了唯一的“tag”,字段在二进制编码中使用该标签。由于标签数1-15只需要1个字节来编码,所以作为最优选择你可以对经常用到的或者重复的元素使用这些标签,将16乃至更大数的标签留给不那么经常使用的选项元素。在一个重复字段中每个元素需要对标签数再次编码,因此重复的字段是特别好的候选选择。(这句感觉翻译的不对,原文 :Each element in a repeated field requires re-encoding the tag number, so repeated fields are particularly good candidates for this optimization. )
每个字段必须用以下的一个修饰词注释 :
- required:该字段的值必须被提供,否则消息会被认为是“未初始化”。尝试编译一个未初始化的message会抛出一个RuntimeException。解析一个未初始化的消息会抛出一个IOException。除去这些,一个required字段表现的就像一个可选字段。
- optional:字段可能有或没有被设置。如果没有设置 optional 字段的值,该字段会使用默认值。对于简单类型,你可以指定你自己的默认值,例如我们在例中PhoneType中设置的那样。否则会使用系统默认值:数字类型为0,string为空字符串,bool类型为false。对于嵌套的消息,默认值总是该消息的“default instance”或者“prototype”,并且不包含它所有的字段。调用存取器来获取一个未被明确设置的optional(或required)字段总是返回该字段的默认值。
- repeated:这个字段可能重复多次(包括0)。重复值的顺序实在protocol buffer预设好的。可以将repeated 的字段当做动态长度的数组。
Required Is Forever 你应当慎重标记字段为required。如果在某些时候你希望停止读或写一个required的字段,改变这些字段为optional是会有问题的–旧的阅读器会认为没有这个字段的message是不完整的并且有可能拒绝或者无意中丢弃它们。你应该考虑为你的buffer编写应用程序特定的自定义验证程序。一些谷歌的工程师总结说使用required弊大于利;他们更愿意只使用optional和repeated。然而,这种观点不是普遍的。
你会在Protocol Buffer Language Guide找到一个完整的编写.proto
文件(包括所有可能的字段类型)的手册。不要去寻找类似类继承的机制,protocol buffer不支持这个。
编译你的Protocol Buffer
现在你有了一个.proto文件,接下来你要做的就是生成你需要读写AddressBook(以及Person和PhoneNumber)消息的类。因此,你需要在你的.proto文件上运行protocol buffer 编译器protoc。
如果你还没有安装编译器,下载安装包并按照README上的操作来安装。
运行编译器,指定源目录(你的应用的源代码存放的位置–如果不提供那么使用当前目录),目标目录(生成的代码存放的地方,也就是$SRC_DIR)以及你的.proto的路径。
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
使用java_out选项来生成Java类,其他语言和java_out类似。
The Protocol Buffer API
让我们看一下生成的代码,看看编译器创建了什么样的类和方法。如果你查看AddressBookProtos.java,你可以看到它定义了一个叫做AddressBookProtos的类,其中嵌套了你在addressbook.proto中指定的每一个消息。每一个类有它自己的Builder类,你可以用它来创建这些类的实体。你可以在下面的Builders vs. Messages一节找到更多的builders的相关信息。
消息和builder都有自动生成的对消息中每个字段的存取器;消息只有getter方法,builder有getter和setter方法。如下是一些Person类中的存取器(略微简洁的实现):
// 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> getPhoneList();
public int getPhoneCount();
public PhoneNumber getPhone(int index);
同时,Person.Builder有相同的getter和额外的setter:
// 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> getPhoneList();
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。还有每个字段的getter,如果该字段已经设置,则返回true。最后,每个字段都有一个clear
方法,将该字段重置为空。
重复字段有一些额外的方法 - 一个Count
方法(它只是list的size的缩写),getter和setter,它们通过索引获取或设置列表的特定元素,add
方法向列表中追加一个新元素,一个addAll
方法,它将一个包含完整元素的整个容器添加到列表中。
请注意这些访问器方法使用驼峰命名,尽管.proto文件使用带下划线的小写字母。此转换由protocol buffer编译器自动完成,以便生成的类匹配标准Java样式约定。您应该始终对.proto
文件中的字段名使用带下划线的小写字母;这确保在所有生成的语言中良好的命名实践。有关良好.proto
样式的更多信息,请参阅样式指南。
有关protocol编译器为任何特定字段定义生成成员的更多信息,请参阅Java生成代码指引。
枚举和嵌套类
生成的代码包括 Java 5的枚举类PhoneType,嵌套在Person中:
public static enum PhoneType {
MOBILE(0, 0),
HOME(1, 1),
WORK(2, 2),
;
...
}
嵌套类型Person.PhoneNumber是根据您的期望生成的,作为Person中的嵌套类。
Builders vs. Messages
由protocol buffer编译器生成的消息类都是不可变的。一旦message对象被构造,它不能被修改,就像一个String
。要构造消息,必须先构建一个Builder,将任何要设置的字段设置为所选的值,然后调用Builder的build()方法。
ps:关于builder模式可以见我的另一篇文章:Effective Java学习笔记一(静态工厂方法、JavaBeans模式、builder模式)
下面是一个如何创建Person实例的示例:
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();
标准消息方法
每个消息和构建器类还包含许多其他方法,您可以检查或操纵整个消息,包括:
isInitialized()
:检查是否所有必填字段都已设置。toString()
:返回一个可读的消息表示,特别适用于调试。mergeFrom(Message other)
:(仅构建器)将other的内容合并到此消息中,覆盖单个字段并连接重复的字段。clear()
:(仅限Builder)将所有字段清除回空状态。这些方法实现了所有Java message和builder共享的
Message
和Message.Builder
接口。有关详细信息,请参阅Message
的完整API文档。
解析和序列化
最后,每个protocol buffer类具有使用protocol buffer二进制格式写入和读取所选类型的消息的方法。这些包括:
byte [] toByteArray();
:序列化消息,并返回一个包含其原始字节的字节数组。static Person parseFrom(byte [] data);
:解析来自给定字节数组的消息。void writeTo(OutputStream output);
:序列化消息并将其写入OutputStream。static Person parseFrom(InputStream input);
:从InputStream读取并解析消息。
这些只是为解析和序列化提供的几个选项。再次,有关完整列表,请参阅Message API参考。
Protocol Buffers 和面向对象的设计Protocol Buffer类基本上是哑数据持有者(dumb data holders)(像C++中的结构体);他们不是一个对象模型中的一流公民。如果要向生成的类添加更丰富的行为,最好的方法是将生成的protocol buffer类包装在特定于应用程序的类中。包装protocol buffer也是一个好主意,如果你不能控制.proto文件的设计(比如,你从另一个项目重用)。在这种情况下,您可以使用包装器类来创建一个更适合您的应用程序的唯一环境的接口:隐藏一些数据和方法,暴露方便功能等。任何时候都不应该通过继承生成的类来增加行为。这将打破内部机制,并不是好的面向对象的做法。
写消息
现在让我们尝试使用你的protocol buffer类。您希望您的通讯簿应用程序能够做的第一件事是将个人详细信息写入您的通讯录文件。为此,您需要创建并填充protocol buffer类的实例,然后将它们写入输出流。
下面是一段程序,从文件中读取一个AddressBook,根据用户输入添加一个新的Person,并将新的AddressBook再次写回文件。
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();
}
}
读消息
当然,如果你不能得到任何信息,通讯簿不会有太多的用处!此示例读取由上述示例创建的文件,并打印其中的所有信息。
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
在您释放使用protocol buffer的代码后,您将毫无疑问地希望“改进”protocol buffer的定义。如果你想要新的buffer向后兼容,并且你的旧buffer是向前兼容的 - 你几乎肯定会想要这样 - 那么你需要遵循一些规则。在新版本的protocol buffer中:
- 您不能更改任何现有字段的标签号。
- 您不能添加或删除任何必填字段。
- 您可以删除可选字段或重复字段。
- 您可以添加新的可选字段或重复字段,但必须使用新的标记号(即,此protocol buffer中从未使用的标记号,甚至不包括已删除的字段)。
如果你遵循这些规则,旧的代码将很容易阅读新消息,只是忽略任何新的字段。对于旧代码,被删除的可选字段将只具有其默认值,并且删除的重复字段将为空。新代码还将透明地读取旧消息。但是,请记住,新的可选字段不会出现在旧消息中,因此您需要明确地检查它们是否使用has_
设置,或者使用[default = value]
在.proto
文件中提供合理的默认值后面的标签号。如果未为可选元素指定默认值,则使用类型特定的默认值:对于字符串,默认值为空字符串。对于布尔值,默认值为false
。对于数字类型,默认值为零。还要注意,如果你添加了一个新的重复字段,你的新代码将不能告诉它是空的(通过新代码)还是永远不设置(旧的代码),因为没有has_
标志。
高级用法
Protocol Buffer具有超越简单访问器和序列化的用途。一定要研究Java API参考,看看你能用它做什么。
Protocol Buffer类提供的一个关键特性是反射。您可以迭代消息的字段并操作其值,而无需针对任何特定消息类型编写代码。使用反射的一个非常有用的方法是将protocol消息转换为其他编码,如XML或JSON。反射的更高级使用可能是找到相同类型的两个消息之间的差异,或者开发一种“用于protocol消息的正则表达式”,其中可以编写与某些消息内容匹配的表达式。如果你使用你的想象力,你有可能将protocol buffer应用到比你当初期望的更加广泛的问题中!
反射是作为Message
和Message.Builder
接口的一部分提供的。