Protocol Buffer基础:Java
本教程提供了一个基本的 Java 程序员使用Protocol Buffer的介绍。 通过创建一个简单的示例应用程序,它向您展示了如何
- 在
.proto
文件中定义消息格式。 - 使用Protocol Buffer编译器。
- 使用 Java 协议缓冲区 API 来写入和读取消息。
这不是在 Java 中使用Protocol Buffer的综合指南。 有关更详细的参考信息,请参阅Protocol Buffer语言指南 (proto2)、Protocol Buffer语言指南 (proto3)、Java API 参考、Java 生成代码指南和编码参考。
问题域
我们将要使用的示例是一个非常简单的“地址簿”应用程序,它可以在文件中读取和写入人们的联系方式。 地址簿中的每个人都有一个姓名、一个 ID、一个电子邮件地址和一个联系电话号码。
你如何序列化和检索这样的结构化数据? 有几种方法可以解决这个问题:
- 使用 Java 序列化。 这是默认方法,因为它内置在语言中,但它有许多众所周知的问题(参见 Effective Java,Josh Bloch pp. 213,如果您需要与 用 C++ 或 Python 编写的应用程序交互数据也不会很好的工作。
- 您可以发明一种特殊方式将数据项编码为单个字符串——例如将 4 个整数编码为“12:3:-23:67”。 这是一种简单而灵活的方法,尽管它确实需要编写一次性的编码和解析代码,并且解析会产生很小的运行时成本。 这最适合编码非常简单的数据。
- 将数据序列化为 XML。 这种方法可能非常有吸引力,因为 XML(某种程度)是人类可读的,并且有许多语言的绑定库。 如果您想与其他应用程序/项目共享数据,这可能是一个不错的选择。 然而,众所周知,XML 是空间密集型的,对它进行编码/解码会对应用程序造成巨大的性能损失。 此外,导航 XML DOM 树比导航类中的简单字段通常要复杂得多。
您可以使用protocol buffers来代替这些选项。protocol buffers是解决这个问题的灵活、高效、自动化的解决方案。 使用protocol buffers,您可以编写要存储的数据结构的 .proto 描述。 由此,protocol buffer 编译器创建了一个类,该类以高效的二进制格式实现 protocol buffer 数据的自动编码和解析。 生成的类为组成protocol buffers的字段提供 getter 和 setter,并将读取和写入协议缓冲区的细节作为一个单元处理。 重要的是,protocol buffers格式支持随着时间的推移扩展格式的想法,这样代码仍然可以读取用旧格式编码的数据。
哪里找到示例代码
示例代码包含在“示例”目录下的源代码包中。
定义您的协议格式
要创建地址簿应用程序,您需要从 .proto
文件开始。 .proto
文件中的定义很简单:为每个要序列化的数据结构添加一条消息,然后为消息中的每个字段指定名称和类型。 这是定义您的消息的 .proto
文件 addressbook.proto
。
syntax = "proto2";
package tutorial;
option java_multiple_files = true;
option java_package = "com.example.tutorial.protos";
option java_outer_classname = "AddressBookProtos";
message Person {
optional string name = 1;
optional int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
optional string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
如您所见,语法类似于 C++ 或 Java。 让我们浏览文件的每个部分,看看它做了什么。
.proto
文件以包声明开头,这有助于防止不同项目之间的命名冲突。 在 Java 中,包名称用作 Java 包,除非您明确指定了 java_package
,就像我们在这里一样。 即使您确实提供了 java_package
,您仍然应该定义一个普通包,以避免在 Protocol Buffers 名称空间以及非 Java 语言中发生名称冲突。
在包声明之后,您可以看到三个特定于 Java 的选项:java_multiple_files
、java_package
和 java_outer_classname
。 java_package
指定生成的类应该使用什么 Java 包名称。如果您没有明确指定,它只会匹配包声明给出的包名,但这些名称通常不是合适的 Java 包名(因为它们通常不以域名开头)。 java_outer_classname
选项定义了代表这个文件的包装类的类名。如果您没有明确给出 java_outer_classname
,它将通过将文件名转换为大写驼峰式来生成。例如,默认情况下,“my_proto.proto”将使用“MyProto”作为包装类名称。 java_multiple_files = true
选项可以为每个生成的类生成一个单独的 .java 文件(而不是为包装类生成单个 .java 文件的传统行为,使用包装类作为外部类,并将所有其他类嵌套在里面包装类)。
接下来,您有您的消息定义。 消息只是包含一组类型字段的聚合。 许多标准的简单数据类型可用作字段类型,包括 bool
、int32
、float
、double
和 string
。 您还可以通过使用其他消息类型作为字段类型来为您的消息添加进一步的结构——在上面的示例中,Person
消息包含 PhoneNumber
消息,而 AddressBook
消息包含 Person
消息。 您甚至可以定义嵌套在其他消息中的消息类型——如您所见,PhoneNumber
类型是在 Person
中定义的。 如果您希望其中一个字段具有预定义的值列表之一,您还可以定义枚举类型 - 在这里您要指定电话号码可以是以下电话类型之一:MOBILE
、HOME
或 WORK
。
每个元素上的“= 1”、“= 2”标记标识该字段在二进制编码中使用的唯一“标签”。 标签编号 1-15 比更高的编号需要少一个字节来编码,因此作为一种优化,您可以决定将这些标签用于常用或重复的元素,而将标签 16 和更高的标签用于不太常用的可选元素。 重复字段中的每个元素都需要重新编码标签号,因此重复字段特别适合这种优化。
每个字段都必须使用以下修饰符之一进行注释:
optional
(可选):该字段可以设置也可以不设置。 如果未设置可选字段值,则使用默认值。 对于简单类型,您可以指定自己的默认值,就像我们在示例中对电话号码类型所做的那样。 否则,使用系统默认值:数字类型为零,字符串为空字符串,布尔值为 false。 对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,没有设置任何字段。 调用访问器以获取未显式设置的可选(或必需)字段的值始终返回该字段的默认值。repeated
(重复):该字段可以重复任意次数(包括零次)。 重复值的顺序将保存在协议缓冲区中。 将重复字段视为动态大小的数组。required
:必须提供该字段的值,否则该消息将被视为“未初始化”。 尝试构建未初始化的消息将引发RuntimeException
。 解析未初始化的消息将引发IOException
。 除此之外,必填字段的行为与可选字段完全相同。
必填项是永久的 您应该非常小心地根据需要标记字段。 如果在某些时候您希望停止写入或发送必填字段,则将该字段更改为可选字段会出现问题——老读者会认为没有此字段的消息不完整,可能会无意中拒绝或丢弃它们。 您应该考虑为您的缓冲区编写特定于应用程序的自定义验证例程。 在 Google 内部,必填字段是非常不受欢迎的; proto2 语法中定义的大多数消息仅使用可选且重复。 (Proto3 根本不支持必填字段。)
您可以在 Protocol Buffer Language Guide 中找到编写 .proto
文件的完整指南——包括所有可能的字段类型。 不过,不要去寻找类似于类继承的工具——protocol buffers 不会那样做。
编译你的Protocol Buffers
现在您已经有了一个 .proto
,接下来您需要生成读取和写入 AddressBook
(以及由此产生的 Person
和 PhoneNumber
)消息所需的类。 为此,您需要在 .proto
上运行协议缓冲区编译器协议:
- 如果您尚未安装编译器,请下载软件包并按照 README 中的说明进行操作。
- 现在运行编译器,指定源目录(您的应用程序的源代码所在的位置——如果您不提供值,则使用当前目录)、目标目录(您希望生成的代码所在的位置;通常与
$SRC_DIR
相同),以及.proto
的路径。 在这种情况下,您…:
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
因为您需要 Java 类,所以您使用 `--java_out` 选项——为其他支持的语言提供了类似的选项。
这会在您指定的目标目录中生成一个 com/example/tutorial/protos/
子目录,其中包含一些生成的 .java
文件。
Protocol Buffer API
让我们看一些生成的代码,看看编译器为您创建了哪些类和方法。 如果您查看 com/example/tutorial/protos/
,您可以看到它包含为您在 addressbook.proto
中指定的每条消息定义一个类的 .java
文件。 每个类都有自己的 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 phones = 4;
public List<PhoneNumber> getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(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 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();
如您所见,每个字段都有简单的 JavaBeans 样式的 getter 和 setter。 每个奇异字段也有 getter,如果该字段已设置,则返回 true。 最后,每个字段都有一个 clear 方法,可以将字段恢复为空状态。
重复字段有一些额外的方法 - 一个 Count
方法(它只是列表大小的简写),通过索引获取或设置列表的特定元素的 getter 和 setter,将新元素附加到列表的 add
方法,以及 一个 addAll
方法,它将一个充满元素的整个容器添加到列表中。
请注意这些访问器方法如何使用驼峰式命名,即使 .proto
文件使用带下划线的小写字母。 此转换由协议缓冲区编译器自动完成,以便生成的类符合标准 Java 样式约定。 您应该始终在 .proto 文件中使用带下划线的小写字母; 这确保了在所有生成的语言中都有良好的命名习惯。 有关良好 .proto
样式的更多信息,请参阅样式指南。
有关协议编译器为任何特定字段定义生成的确切成员的详细信息,请参阅 Java 生成的代码参考。
枚举和嵌套Class
生成的代码包括一个 PhoneType
Java 5 枚举,嵌套在 Person
中:
public static enum PhoneType {
MOBILE(0, 0),
HOME(1, 1),
WORK(2, 2),
;
...
}
正如您所料,嵌套类型 Person.PhoneNumber
是作为 Person
中的嵌套类生成的。
构建器和消息
协议缓冲区编译器生成的消息类都是不可变的。 消息对象一旦被构造,就不能被修改,就像 Java 字符串一样。 要构造消息,您必须首先构造一个构建器,将要设置的任何字段设置为您选择的值,然后调用构建器的 build()
方法。
您可能已经注意到,修改消息的构建器的每个方法都会返回另一个构建器。 返回的对象实际上是您调用该方法的同一个构建器。 它是为了方便而返回的,以便您可以在一行代码中将多个 setter 串在一起。
下面是一个如何创建 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();
标准消息方法
每个消息和构建器类还包含许多其他方法,可让您检查或操作整个消息,包括:
isInitialized()
: 检查是否已设置所有必填字段。toString()
: 返回友好的可读消息,对于调试特别有用。mergeFrom(Message other)
: (仅限构建器)将 other 的内容合并到此消息中,覆盖单个标量字段,合并复合字段并连接重复字段。clear()
: (仅限构建器)将所有字段清除回空状态。
这些方法实现了所有 Java 消息和构建器共享的 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 读取并解析消息。
这些只是为解析和序列化提供的几个选项。 再次,请参阅消息 API 参考以获取完整列表。
协议缓冲区和面向对象设计协议缓冲区类基本上是哑数据持有者(如 C 中的结构); 他们不会在对象模型中成为优秀的一等公民。 如果您想为生成的类添加更丰富的行为,最好的方法是将生成的协议缓冲区类包装在特定于应用程序的类中。 如果您无法控制 .proto 文件的设计(例如,您正在重用来自另一个项目的文件),包装协议缓冲区也是一个好主意。 在这种情况下,您可以使用包装类来制作更适合应用程序独特环境的接口:隐藏一些数据和方法,公开便利功能等。您永远不应该通过从生成的类继承来添加行为。 这将破坏内部机制并且无论如何都不是好的面向对象的实践。
写消息
现在让我们尝试使用您的协议缓冲区类。 您希望您的地址簿应用程序能够做的第一件事就是将个人详细信息写入您的地址簿文件。 为此,您需要创建和填充协议缓冲区类的实例,然后将它们写入输出流。
这是一个程序,它从文件中读取地址簿,根据用户输入向其中添加一个新人员,然后再次将新地址簿写回到文件中。 突出显示直接调用或引用协议编译器生成的代码的部分。
import com.example.tutorial.protos.AddressBook;
import com.example.tutorial.protos.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.protos.AddressBook;
import com.example.tutorial.protos.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);
}
}
其中读消息和写消息需要protocol buffer依赖,具体的添加方式可在Github-protocol/java中查看,以下是Maven形式添加依赖:
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.20.0-rc-1</version>
</dependency>
扩展 Protocol Buffer
在你发布使用你的协议缓冲区的代码之后,迟早你肯定会想要“改进”协议缓冲区的定义。 如果您希望您的新缓冲区向后兼容,并且您的旧缓冲区向前兼容——您几乎肯定希望这样做——那么您需要遵循一些规则。 在新版本的协议缓冲区中:
- 您不得更改任何现有字段的标签号。
- 您不得添加或删除任何必填字段。
- 您可以删除可选或重复的字段。
- 您可以添加新的可选字段或重复字段,但您必须使用新的标签号(即,从未在此协议缓冲区中使用过的标签号,即使已删除的字段也不使用)。
(这些规则有一些例外,但很少使用。)
如果您遵循这些规则,旧代码将愉快地阅读新消息并忽略任何新字段。 对于旧代码,已删除的可选字段将仅具有其默认值,而删除的重复字段将为空。 新代码也将透明地读取旧消息。 但是,请记住,旧消息中不会出现新的可选字段,因此您需要明确检查它们是否使用 has_ 设置,或者在 .proto
文件中使用 [default = value]
提供合理的默认值 标签号之后。 如果未为可选元素指定默认值,则使用特定于类型的默认值:对于字符串,默认值为空字符串。 对于布尔值,默认值为 false。 对于数字类型,默认值为零。 另请注意,如果您添加了一个新的重复字段,您的新代码将无法判断它是留空(通过新代码)还是根本没有设置(通过旧代码),因为它没有 has_
标志。
高级用法
Protocol buffers的用途超越了简单的访问器和序列化。 一定要探索 Java API 参考,看看你还能用它们做什么。
协议消息类提供的一个关键特性是反射。 您可以遍历消息的字段并操作它们的值,而无需针对任何特定的消息类型编写代码。 使用反射的一种非常有用的方法是将协议消息与其他编码(例如 XML 或 JSON)相互转换。 反射的更高级用途可能是发现相同类型的两条消息之间的差异,或者开发一种“协议消息的正则表达式”,您可以在其中编写与某些消息内容匹配的表达式。 如果您发挥自己的想象力,则可以将 Protocol Buffers 应用于比您最初预期的范围更广的问题!
反射作为 Message
和 Message.Builder
接口的一部分提供。