本教程提供了一个Java程序员使用协议缓冲区的基本介绍。通过创建一个简单的示例应用程序,它会显示如何:
- 在
.proto
文件中定义消息格式。 - 使用协议缓冲区编译器。
- 使用Java协议缓冲区API来写入和读取消息。
这不是在Java中使用协议缓冲区的全面指导。有关更详细的参考信息,请参阅“协议缓冲区语言指南”,
“Java API参考”,
“Java生成的代码指南”和
“编码参考”。
为什么要使用协议缓冲区?
我们要使用的例子是一个非常简单的“通讯录”应用程序,它可以从文件中读取和写入联系人信息。通讯录中的每个人都有姓名,身份证号码,电子邮件地址和联系电话号码。
你如何序列化和检索这样的结构化数据?有几种方法来解决这个问题:
使用Java序列化。这是默认的方法,但它有一个众所周知的问题(见Effective Java, by Josh Bloch 213页),如果你需要和以C或Python语言编写的应用程序共享数据这不会很好。
您可以发明一种ad-hoc方式将数据项编码为单个字符串 - 例如编码4个ints为“12:3:-23:67”。这是一个简单而灵活的方法,尽管它需要编写一次性编码和解析(具有很小的运行时成本)代码。
但这很适合编码非常简单的数据。将数据序列化为XML。这种方法可能非常有吸引力,因为XML是(可能的)人类可读的,并且有很多语言的绑定库。如果您想与其他应用程序/项目共享数据,这可能是一个很好的选择。
然而,众所周知,XML是空间密集型的,编码/解码可能会对应用程序造成巨大的性能损失。另外,导航XML DOM树比在一般类中导航某个简单的字段要复杂得多。
协议缓冲区是解决这个问题的灵活,高效,自动化的解决方案。使用协议缓冲区,您可以编写一个.proto
描述您希望存储的数据结构。依据文件中,协议缓冲区编译器创建一个实现自动编码和解析协议缓冲区数据的类,并使用高效的二进制格式。
生成的类为组成协议缓冲区的字段提供getter和setter方法,并将协议缓冲区的读写细节作为一个单元。更重要的事,协议缓冲区格式支持随着时间的推移扩展格式的想法,使得代码仍然可以读取以旧格式编码的数据。
在哪里可以找到示例代码
示例代码包含在源代码包中的“examples”目录下。在这里下载
定义您的协议格式
要创建通讯录应用程序,您需要从.proto
文件开始。.proto
文件中的定义很简单:您为要序列化的每个数据结构添加消息,然后为消息中的每个字段指定名称和类型。这是定义您的消息的.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 phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
您可以看到,语法与C或Java类似。让我们来看看文件的每个部分,了解它在做什么?
`.proto`文件以包声明开头,这有助于防止不同项目之间的命名冲突。在Java中,package
名称用作Java包,除非您已经明确指定了java_package
,就像我们在这里一样。
即使您提供了java_package
,您仍然应该定义一个package
,以避免在协议缓冲区命名空间以及非Java语言中的命名冲突。
在包声明之后,您可以看到两个特定Java选项: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
。
您还可以通过使用其他消息类型作为字段类型,为消息添加进一步的结构 - 在上述示例中,Person消息包含PhoneNumber消息,而AddressBook消息包含Person消息。
您甚至可以定义嵌套在其他消息中的消息类型 - 如你所见,PhoneNumber类型在Person中定义。如果您希望其中一个字段具有预定义的值列表,也可以定义为枚举类型 -
例如本例中你指定一个电话号码可以是MOBILE, HOME, 或WORK之一。
每个元素上的“= 1”,“= 2”标记标识字段在二进制编码中使用的唯一“标签”。标签编号1-15比数字更高的编号少了一个字节编码,所以作为优化,您可以决定将这些标签用于常用或重复的元素,
为不常用的可选元素留下标签16或更高的编号。重复字段中的每个元素都需要重新编码标签号,因此,重复字段特别适合这种优化。
每个字段必须使用以下修饰符之一进行注释:
required
: 必须提供该字段的值,否则该消息将被视为“未初始化”。尝试构建未初始化的消息将抛出RuntimeException。解析未初始化的消息将抛出IOException。
除此之外,必填字段的行为与可选字段完全相同。optional
: 该字段可以被设置也可以不被设置。如果未设置可选字段值,则使用默认值。对于简单类型,您可以指定自己的默认值,就像我们在示例中为电话号码类型所做的那样。
否则,使用系统默认值:数字类型为零,字符串为空字符串,bools为false。对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,它没有设置任何字段。
调用访问器获取尚未显式设置的可选(或必需)字段的值始终返回该字段的默认值。repeated
: 该字段可以重复任意次数(包括0次)。重复值的顺序将保留在协议缓冲区中。将重复的字段视为动态大小的数组。
永远是必需的 将字段标记为
required
时你应该非常小心。如果您希望在将来某一时刻停止写入或发送必填字段,将字段更改为可选字段将是有问题的 - 老读入者会考虑没有这个字段的信息是不完整的,
可能会无意中拒绝或放弃它们。您应该考虑为您的缓冲区编写特定于应用程序的自定义验证例程。Google的一些工程师已经得出结论,使用required
弊大于利; 他们更喜欢只使用optional
和repeated
, 但是,这种看法并不普及。
您将找到一个完整的.proto
文件编写指南 - 包括所有可能的字段类型 - 在协议缓冲区语言指南。
不要去找类似于类继承的工具 - 协议缓冲区不这样做。
编译你的协议缓冲区
现在你有一个.proto
,接下来你需要做的是生成你需要读取和写入AddressBook(以及Person和PhoneNumber)消息的类。为此,您需要在.proto
上运行协议缓冲区编译器protoc
:
如果您尚未安装编译器,请下载软件包并按照README中的说明进行操作。
现在运行编译器,指定源目录(应用程序的源代码所在的位置 - 如果不提供值,则使用当前目录),目标目录(您希望生成的代码在哪里),通常与$SRC_DIR一样),以及您的.proto的路径。
在这种情况下,你…:protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
因为您需要Java类,所以使用–java_out选项 - 我们也为其他支持的语言提供了类似的选项。
协议缓冲区API
我们来看一下生成的代码,看看编译器为你创建的类和方法。 打开AddressBookProtos.java
,您可以看到它定义了一个名为AddressBookProtos
的类, 嵌套在其中的是在addressbook.proto
中指定的
每个消息的类。每个类都有自己的Builder类,用于创建该类的实例。您可以在下面的“Builders vs. Messages”部分找到有关构建器的更多信息。
消息和构建器都有自动生成的消息的每个字段的访问器方法;消息只有getter方法,而构建器有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方法。对于每个单个字段也有has
getter方法,如果该字段已设置,则返回true。
每个字段都有一个clear
的方法,将字段取消设值成空状态。
重复的字段有一些额外的方法 - Count
方法(它只是列表的大小的缩写),getters和setter方法可以通过索引获取或设置列表指定位置的元素,
一个add
方法将新元素附加到列表中的,以及一个addAll
方法,它将整个容器中的元素添加到列表中。
请注意这些访问器方法使用骆驼案例命名,即使.proto
文件使用小写和下划线形式。该转换由协议缓冲区编译器自动完成,以便生成的类符合标准Java风格约定。
您应该始终在.proto
文件中为字段名使用小写与下划线命名方式; 这确保了所有生成的语言中的良好命名实践。有关更好的.proto
风格,请参阅style指南。
有关协议编译器为任何特定字段定义生成的成员的详细信息,请参阅Java代码生成指南。
枚举和嵌套类
生成的代码包括一个嵌套在Person中的PhoneTypeJava 5
枚举:
public static enum PhoneType {
MOBILE(0, 0),
HOME(1, 1),
WORK(2, 2),
;
...
}
像您所期望的那样,嵌套类型Person.PhoneNumber将生成为Person中的内部类。
Builders vs. Messages
协议缓冲区编译器生成的消息类都是不可变的。一旦构建了消息对象,就不能修改, 像Java字符串那样。要构建消息,您必须首先构建构建器,将要设置的任何字段设置为所选值,然后调用构建器的build()
方法。
您可能已经注意到修改消息的构建器的每个方法都会返回另一个构建器。返回的对象实际上是您调用该方法的构建器。为了方便起见,它被返回,以便您可以在一行代码上将多个设置器串在一起。
以下是创建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): (仅构建器有)将其他消息内容合并到此消息中,覆盖单个标量字段,合并复合字段以及连接重复字段。
clear():(仅构建器有)将所有字段清除为空状态。
解析和序列化
每个协议缓冲器类都有使用协议缓冲区二进制格式写入和读取所选类型的消息的方法。这些包括:
byte[] toByteArray(); 序列化消息并返回一个包含其原始字节的字节数组。
static Person parseFrom(byte[] data); 从给定的字节数组解析消息。
void writeTo(OutputStream output); 将消息序列化并将其写入OutputStream。
static Person parseFrom(InputStream input); 从InputStream读取并解析消息。
这些只是解析和序列化提供的几个选项。你可以参阅Message API指南以获取完整列表。
协议缓冲区和面向对象设计 协议缓冲区基本上是哑数据持有者(如C++中的结构体);他们不会在对象模型中成为优秀的一流公民。如果你想为生成的类添加更丰富的行为,最好的方法是将生成的协议缓冲区类包装在特定于应用程序的类中。
如果您无法控制.proto
文件的设计,那么包装协议缓冲区也是一个好主意(如果说,您正在从另一个项目中重用.proto)。在这种情况下,您可以使用组装类来构造一个更适合您应用程序独特环境的接口:隐藏一些数据和方法,暴露便利功能,
等。您不应该通过继承它们来对编译器生成的类添加行为, 这将破坏内部机制,而且这绝对不是很好的面向对象的做法。
写一个消息
现在让我们尝试使用协议缓冲区类。您希望您的通讯录应用程序能够做的第一件事是将个人信息写入您的通讯录文件。为此,您需要创建和填充协议缓冲区类的实例,然后将其写入输出流。
这是一个从文件读取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);
}
}
扩展协议缓冲区
在您发布使用协议缓冲区的代码之后,毫无疑问,您迟早会希望“改进”协议缓冲区的定义。如果您希望新的缓冲区向后兼容,并且您的旧缓冲区向前兼容 - 你几乎肯定想要这个 - 那么你需要遵循一些规则。
在新版本的协议缓冲区中:
- 不能更改任何现有字段的标签号。
- 您不得添加或删除任何必填字段。
- 您可以删除可选或重复的字段。
- 您可以添加新的可选或重复的字段,但必须使用新的标签号(即在该协议缓冲区中从未使用的标签号,甚至不能重用已被删除字段的标签号)
(这些规则有一些例外,但很少使用它们。)
如果您遵循这些规则,旧代码将高兴地阅读新消息,并且忽略任何新字段。对于旧的代码,被删除的可选字段将只有其默认值,并且被删除的重复字段将为空。
新代码也可读取旧消息。但是,请注意,旧消息中不会显示新的可选字段,所以你需要使用has_
明确地检查它们是否设置,或者在.proto文件中提供一个合理的默认值,在标签号之后使用[default = value]指定。
如果未为可选元素指定默认值,则使用特定于类型的默认值:对于字符串,默认值是空字符串。对于布尔值,默认值为false。对于数值类型,默认值为0。还要注意,如果你添加了一个新的重复字段,
那么你的新代码将无法判断它是否为空(通过新的代码)或从不设置(通过旧的代码),因为它没有has_
方法。
高级用法
协议缓冲区具有简单访问器和序列化的用途。请务必浏览Java API指南,看看您还可以做些什么。
协议消息类提供的一个关键功能是反射。您可以迭代消息内的字段并操纵其值,而不会针对任何特定的消息类型编写代码。使用反射的一个非常有用的方法是将协议消息转换为其他类型的编码(如XML或JSON)。
反射的更高级的用法是可能找到相同类型的两个消息之间的差异,或者开发一种“协议消息的正则表达式”,您可以在其中编写与某些消息内容匹配的表达式。如果你用你的想象力,可以将协议缓冲区应用于比您最初期望的更广泛的问题!
反射是作为Message和Message.Builder接口的一部分提供的。