.proto
文件中定义消息格式我们将要使用的示例是一个非常简单的“地址簿”应用程序,它可以从文件中读取和写入人们的联系人详细信息。地址簿中的每个人都有一个姓名、一个ID、一个电子邮件地址和一个联系电话号码。
如何序列化和检索这样的结构化数据?有几种方法可以解决这个问题:
协议缓冲区是解决这一问题的灵活、高效、自动化的解决方案。使用协议缓冲区,您可以编写一个.proto要存储的数据结构的说明。由此,协议缓冲区编译器创建一个类,该类以高效的二进制格式实现协议缓冲区数据的自动编码和解析。生成的类为组成协议缓冲区的字段提供getter和setter,并负责作为一个单元读取和写入协议缓冲区的详细信息。重要的是,协议缓冲区格式支持随着时间的推移扩展格式的思想,以便代码仍然可以读取旧格式编码的数据。
示例代码包含在“示例”目录下的源代码包中。在这里下载。
https://developers.google.cn/protocol-buffers/docs/downloads
要创建通讯簿应用程序,您需要从一个.proto
文件开始。定义一个.proto
文件很简单:对于每个要序列化的数据结构你可以添加一个message,然后为message中的每个字段指定名称和类型。这是.proto
定义您的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;
}
如您所见,语法类似于C+或Java。让我们检查一下文件的每个部分,看看它能做什么。
这个.proto
文件以包声明开始,这有助于防止不同项目之间的命名冲突。在Java中,除非您已经显式地指定了java_package
就像我们在这里一样。即使你提供了java_package
,您仍然应该定义一个正常的package
。此外,为了避免协议缓冲区中的名称冲突,以及在非Java语言中,名称空间也是如此。
在包声明之后,您可以看到两个特定于Java的选项:java_package
和java_outer_classname. java_package
在Java包名称中指定生成的类应该使用的名称。如果没有显式地指定此名称,则它与package
声明,但这些名称通常不是适当的Java包名称(因为它们通常不以域名开头)。这个java_outer_classname
选项定义类名,该类名应包含该文件中的所有类。如果你显式地提供java_outer_classname
,它将通过将文件名转换为CAMEL(驼峰式)大小写来生成。例如,默认情况下,“my_pro.proto”将使用“MyProto”作为外部类名。
接下来,您将得到您的消息定义。消息只是包含一组类型化字段的聚合。许多标准的简单数据类型都可以作为字段类型使用,包括bool
、int32
、float
、double
和string
。还可以将其他消息类型作为字段类型添加到消息中-在上面的示例中,Person
消息包含PhoneNumber
消息,而AddressBook
消息包含Person
消息。您甚至可以定义嵌套在其他消息中的消息类型-正如您所看到的,PhoneNumber
类型是在Person
中定义的。如果您希望您的一个字段具有一个预定义的值列表,您也可以定义enum
类型-在这里,您希望指定电话号码可以是MOBILE
、HOME
或WORK
。
每个元素上的“=1”、“=2”标记标识字段在二进制编码中使用的唯一“tag”。标签号 1-15 比较高的数字需要少一个字节来编码,因此,作为优化,您可以决定将这些标记用于常用的或重复的元素,留下标记16或更高的标记用于较少使用的可选元素。重复字段中的每个元素都需要重新编码标记号,因此重复字段是这种优化的最佳选择。
必须用下列修饰符之一对每个字段进行注释:
required
:必须提供字段的值,否则该消息将被视为“未初始化”。试图构建未初始化的消息将引发RuntimeException
。解析未初始化的消息将引发IOException
。除此之外,required
字段的行为与optional
字段完全相同。
optional
:字段可以设置,也可以不设置。如果未设置可选字段值,则使用默认值。对于简单类型,可以指定自己的默认值,就像我们在示例中为电话号码type
所做的那样。否则,将使用系统默认值:数字类型为零,字符串为空字符串,bools为false。对于嵌入式消息,默认值始终是消息的“default instance”或“prototype”,没有设置任何字段。调用访问器以获取未显式设置的可选(或必需)字段的值,始终返回该字段的默认值。
repeated
:该字段可以重复任何次数(包括零)。重复值的顺序将保留在协议缓冲区中。将重复字段视为动态大小的数组。
警告:Required is Forever 你应该非常小心的按照required字段进行标记。如果你希望在某个时候停止编写或发送所需的字段,那么将该字段更改为optional字段将有问题-Old readers认为没有此字段的消息是不完整的,并且可能会拒绝或删除他们。您应该考虑为缓冲区编写特定于应用程序的自定义验证例程。Google的一些工程师得出了这样的结论:使用所需的东西弊大于利;他们宁愿只使用可选的和重复的。然而,这种观点并不普遍。
您将在Protocol Buffer Language Guide
中找到编写.proto
文件的完整指南-包括所有可能的字段类型。不要去寻找类似于类继承的工具,尽管协议缓冲区不会这样做。
现在您有了一个.proto
,接下来需要做的事情是生成需要读写AddressBook
(以及Person
和PhoneNumber
)消息的类。为此,需要在.proto
上运行协议缓冲区编译器protoc
:
如果尚未安装编译器,请到官网下载相关的包并按照自述文件中的说明进行操作。
https://developers.google.cn/protocol-buffers/docs/downloads
现在运行编译器,指定源目录(应用程序的源代码所在-如果不提供值,则使用当前目录)、目标目录(您希望生成代码;通常与$SRC_dIR
相同)和.proto
的路径。在这种情况下,you…
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
因为您想要Java类,所以使用--java_out
选项-为其他受支持的语言提供了类似的选项。
这将在你指定的目录中生成com/example/tutorial/AddressBookProtos.java
让我们看看一些生成的代码,看看编译器为您创建了哪些类和方法。如果您查看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 phones = 4;
public List 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 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 value);
public Builder clearPhones();
As you can see, there are simple JavaBeans-style getters and setters for each field. There are also has
getters for each singular field which return true if that field has been set. Finally, each field has a clear
method that un-sets the field back to its empty state.
Repeated 字段有一些额外的方法-Count
方法(它只是列表大小的缩写)、getter和setter,它们通过索引获取或设置列表中的特定元素,添加方法将一个新元素附加到列表中,addAll
方法向列表中添加了整个容器中的元素。
注意这些访问器方法是如何使用驼峰式大小写命名(camel-case naming)的,即是.proto
文件使用小写带下划线。此转换由协议缓冲区编译器自动完成,以便生成的类符合标准的Java样式约定。您应该始终在.proto
文件中对字段名使用小写带下划线;这确保了在所有生成的语言中都有良好的命名习惯。有关更多好的.proto
样式信息,请参见style guide。
附:style guide:https://developers.google.cn/protocol-buffers/docs/style
有关协议编译器为任何特定字段定义生成的成员的更多信息,请参见 Java generated code reference.
附:https://developers.google.cn/protocol-buffers/docs/reference/java-generated
生成的代码包括PhoneType
Java 5 enum,嵌套在 Person
中:
public static enum PhoneType {
MOBILE(0, 0),
HOME(1, 1),
WORK(2, 2),
;
...
}
如您所料,嵌套类型Person.PhoneNumber
是作为Person
中的嵌套类生成的。
协议缓冲区编译器生成的消息类都是不可变的。一旦构造了消息对象,它就不能被修改,就像JavaString
一样。要构造消息,必须首先构造一个生成器,将要设置为所选值的任何字段设置为,然后调用构建器的build()
方法。
您可能已经注意到,修改消息的构建器的每个方法都返回另一个构建器。返回的对象实际上是调用该方法的同一个生成器。返回它是为了方便,这样您就可以在一行代码上将几个setter串在一起。
下面是如何创建Person
实例的示例:
Person john =
Person.newBuilder()
.setId(1234)
.setName("John Doe")
.setEmail("[email protected]")
.addPhones(
Person.PhoneNumber.newBuilder()
.setNumber("555-4321")
.setType(Person.PhoneType.HOME))
.build();
每个消息和构建器类还包含许多其他方法,这些方法允许您检查或操作整个消息,包括:
isInitialized()
: 检查是否设置了所有必需的字段。toString()
: 以人类可读的表示形式返回消息,对调试特别有用。clear()
:(仅用于生成器)将所有字段清除回空状态。这些方法实现了所有Java消息和构建器共享的Message
和Message.Builder
接口。有关更多信息,请参见complete API documentation for Message
。
附:complete API documentation for Message:https://developers.google.cn/protocol-buffers/docs/reference/java/com/google/protobuf/Message
最后,每个协议缓冲区类都有使用协议缓冲区binary format.
写入和读取所选类型的消息的方法。这些措施包括:
byte[] toByteArray();
:序列化消息并返回包含原始字节的字节数组。void writeTo(OutputStream output);
: 解析来自给定字节数组的消息。static Person parseFrom(InputStream input);
:读取并解析来自InputStream的消息。这些只是为解析和序列化提供的几个选项。同样,有关完整列表,请参见Message API reference
。
Message API reference:https://developers.google.cn/protocol-buffers/docs/reference/java/com/google/protobuf/Message
警告:Protocol Buffers and O-O Design 协议缓冲区类基本上是 dumb 数据持有者(就像C语言中的structs);它们在对象模型中不能成为良好的头等公民。如果要向生成的类添加更丰富的行为,最好的方法是将生成的协议缓冲区类封装到特定于应用程序的类中。如果您无法控制.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.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.addPeople(
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);
}
}
在您发布使用协议缓冲区的代码之后,您迟早会想要“改进”协议缓冲区的定义。如果你希望你的新缓冲区是向后兼容的,而你的旧缓冲区是向前兼容的-你几乎肯定想要这样-那么你需要遵守一些规则。在新版本的协议缓冲区中:
(这些规则有some exceptions ,但很少使用。)
some exceptions :https://developers.google.cn/protocol-buffers/docs/proto#updating
如果您遵循这些规则,旧代码将很高兴地读取新消息,而忽略任何新字段。对于旧代码,被删除的可选字段将只具有默认值,而删除的重复字段将为空。新代码还将透明地读取旧消息。但是,请记住,新的可选字段不会出现在旧消息中,因此您需要显式地检查它们是否是使用has_
设置的,或者在标记号后面的.proto
文件中提供一个合理的默认值[default=value]
。如果未为可选元素指定默认值,则将使用特定于类型的默认值:对于字符串,默认值为空字符串。对于boolean类型的值,默认值为false。对于Number类型,默认值为零。注意,如果您添加了一个新的重复字段,那么您的新代码将无法判断它是空的(by new code),还是根本没有设置(by old code),因为它没有has_
标志。
协议缓冲区的使用超出了简单访问器和序列化的范围。一定要研究Java API reference
,看看您还能用它们做些什么。
Java API reference:https://developers.google.cn/protocol-buffers/docs/reference/java/
协议消息类提供的一个关键特性是反射。您可以迭代消息的字段并操作它们的值,而无需针对任何特定的消息类型编写代码。使用反射的一种非常有用的方法是将协议消息与其他编码(如XML或JSON)进行转换。反射的一种更高级的使用可能是查找同一类型的两条消息之间的差异,或者开发一种“协议消息的正则表达式”,您可以在其中编写与某些消息内容匹配的表达式。如果你运用你的想象力,就有可能将协议缓冲区应用到比你最初预期的范围更广的问题上!
反射作为Message
和Message.Builder
接口的一部分提供。