Protocol Buffer 基础知识:Java

Protocol Buffers 入门:为 Java 程序员提供的基础介绍。

本教程为 Java 程序员提供了 Protocol Buffers 入门的基础介绍。通过创建一个简单的示例应用程序,本教程将向你展示如何

  • .proto 文件中定义消息格式。
  • 使用协议缓冲区编译器。
  • 使用 Java protocol buffer API 写入和读取消息。

这不是在 Java 中使用 Protocol Buffers 的完整指南。有关更详细的参考信息,请参阅Protocol Buffer 语言指南 (proto2)Protocol Buffer 语言指南 (proto3)Java API 参考Java 生成代码指南以及编码参考

问题领域

我们将要使用的示例是一个非常简单的“地址簿”应用程序,它可以从文件中读取和写入联系人的详细信息。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话号码。

如何序列化和检索这种结构化数据?有几种方法可以解决这个问题

  • 使用 Java 序列化。这是默认方法,因为它内置于语言中,但它有许多众所周知的问题(参见 Josh Bloch 的《Effective Java》第 213 页),并且如果你需要与用 C++ 或 Python 编写的应用程序共享数据,它也无法很好地工作。
  • 你可以发明一种临时方法将数据项编码成一个字符串——例如将 4 个整数编码为“12:3:-23:67”。这是一种简单灵活的方法,尽管它需要编写一次性的编码和解析代码,并且解析会带来少量运行时成本。这最适合编码非常简单的数据。
  • 将数据序列化为 XML。这种方法非常有吸引力,因为 XML (某种程度上) 是人类可读的,并且有许多语言的绑定库。如果你想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,XML 以占用大量空间而闻名,并且编码/解码它会给应用程序带来巨大的性能开销。此外,遍历 XML DOM 树比通常在类中遍历简单字段要复杂得多。

除了这些选项,你可以使用 Protocol Buffers。Protocol Buffers 是一种灵活、高效、自动化的解决方案,专门用于解决这个问题。使用 Protocol Buffers,你编写一个 .proto 文件来描述你想要存储的数据结构。然后,Protocol Buffer 编译器会创建一个类,该类以高效的二进制格式实现协议缓冲数据的自动编码和解析。生成的类为构成协议缓冲区的字段提供了 getter 和 setter 方法,并负责将协议缓冲区作为一个单元进行读写的细节。重要的是,协议缓冲格式支持随着时间的推移扩展格式的想法,这样代码仍然可以读取使用旧格式编码的数据。

在哪里找到示例代码

示例代码包含在源代码包中,位于“examples”目录下。在这里下载。

定义你的协议格式

要创建你的地址簿应用程序,你需要从一个 .proto 文件开始。.proto 文件中的定义很简单:为你想要序列化的每个数据结构添加一个 message,然后为消息中的每个字段指定一个名称和类型。这是定义你的消息的 .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 {
    PHONE_TYPE_UNSPECIFIED = 0;
    PHONE_TYPE_MOBILE = 1;
    PHONE_TYPE_HOME = 2;
    PHONE_TYPE_WORK = 3;
  }

  message PhoneNumber {
    optional string number = 1;
    optional PhoneType type = 2 [default = PHONE_TYPE_HOME];
  }

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

如你所见,语法类似于 C++ 或 Java。让我们逐一查看文件的每个部分,了解它的作用。

.proto 文件以一个包声明开始,这有助于防止不同项目之间的命名冲突。在 Java 中,除非你像我们在这里做的那样明确指定了 java_package,否则包名称将用作 Java 包。即使你提供了 java_package,你也应该同时定义一个正常的 package,以避免在 Protocol Buffers 命名空间以及非 Java 语言中发生名称冲突。

在包声明之后,你可以看到三个 Java 特有的选项:java_multiple_filesjava_packagejava_outer_classnamejava_package 指定了生成的类应该位于哪个 Java 包名称下。如果你没有明确指定,它将简单地匹配 package 声明提供的包名称,但这些名称通常不适合作为 Java 包名称(因为它们通常不以域名开头)。java_outer_classname 选项定义了表示此文件的包装器类的类名称。如果你没有明确给出 java_outer_classname,它将通过将文件名转换为大驼峰命名法来生成。例如,“my_proto.proto”默认会将“MyProto”用作包装器类名称。java_multiple_files = true 选项允许为每个生成的类生成单独的 .java 文件(而不是旧版本的行为,即为包装器类生成一个单独的 .java 文件,使用包装器类作为外部类,并将所有其他类嵌套在包装器类内部)。

接下来,是你的消息定义。消息只是一个聚合,包含一组有类型的字段。许多标准的简单数据类型可以作为字段类型,包括 boolint32floatdoublestring。你还可以通过使用其他消息类型作为字段类型来为你的消息添加进一步的结构——在上面的示例中,Person 消息包含 PhoneNumber 消息,而 AddressBook 消息包含 Person 消息。你甚至可以定义嵌套在其他消息内部的消息类型——如你所见,PhoneNumber 类型定义在 Person 内部。如果你希望某个字段具有预定义值列表中的一个值,你还可以定义 enum 类型——在这里你希望指定电话号码可以是以下电话类型之一:PHONE_TYPE_MOBILEPHONE_TYPE_HOMEPHONE_TYPE_WORK

每个元素上的“= 1”、“= 2”标记标识了该字段在二进制编码中使用的唯一“标签号”。标签号 1-15 编码所需的字节比更高数字少一个,因此作为优化,你可以决定将这些标签用于常用或重复的元素,将标签 16 及以上留给不常用或可选的元素。重复字段中的每个元素都需要重新编码标签号,因此重复字段特别适合这种优化。

每个字段都必须用以下修饰符之一进行标注

  • optional: 字段可能已设置,也可能未设置。如果可选字段值未设置,则使用默认值。对于简单类型,你可以指定自己的默认值,就像我们在示例中为电话号码 type 所做的那样。否则,使用系统默认值:数字类型为零,字符串为空字符串,布尔型为 false。对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,其字段都没有设置。调用 accessor 获取未明确设置的可选(或必需)字段的值始终返回该字段的默认值。
  • repeated: 字段可以重复任意次数(包括零次)。重复值的顺序将在 protocol buffer 中保留。可以将重复字段视为动态大小的数组。
  • required: 必须为字段提供值,否则消息将被视为“未初始化”。尝试构建未初始化的消息将抛出 RuntimeException。解析未初始化的消息将抛出 IOException。除此之外,必需字段的行为与可选字段完全相同。

你可以在Protocol Buffer 语言指南中找到关于编写 .proto 文件的完整指南,包括所有可能的字段类型。不过,不要寻找类似于类继承的特性——protocol buffers 不提供这些。

编译你的 Protocol Buffers

既然你有了 .proto 文件,接下来你需要做的是生成读写 AddressBook(以及 PersonPhoneNumber)消息所需的类。为此,你需要运行 Protocol Buffer 编译器 protoc 来处理你的 .proto 文件

  1. 如果你还没有安装编译器,下载软件包并按照 README 中的说明进行操作。

  2. 现在运行编译器,指定源目录(应用程序源代码所在的目录——如果未提供值,则使用当前目录)、目标目录(希望生成代码所在的目录;通常与 $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 类,你可以用它来创建该类的实例。有关 Builder 的更多信息,请参阅下面的构建器 vs. 消息部分。

消息和构建器都为消息的每个字段自动生成了访问器方法;消息只有 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<PhoneNumber> getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);

同时,Person.Builder 具有相同的 getter 和 setter

// required string name = 1;
public boolean hasName();
public 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。每个单数字段还有一个 has getter,如果该字段已设置,则返回 true。最后,每个字段都有一个 clear 方法,将该字段重置回空状态。

重复字段有一些额外的方法——一个 Count 方法(它是列表大小的简写)、按索引获取或设置列表中特定元素的 getter 和 setter、一个将新元素追加到列表的 add 方法,以及一个将整个容器的元素添加到列表的 addAll 方法。

注意这些访问器方法如何使用驼峰命名法,尽管 .proto 文件使用带下划线的小写字母。这种转换由协议缓冲区编译器自动完成,以便生成的类符合标准的 Java 风格约定。在 .proto 文件中,应始终对字段名称使用带下划线的小写字母;这可以确保在所有生成的语言中都有良好的命名实践。有关良好的 .proto 风格的更多信息,请参阅风格指南

有关协议编译器为任何特定字段定义生成哪些成员的详细信息,请参阅Java 生成代码参考

枚举和嵌套类

生成的代码包含一个 PhoneType Java 5 枚举,嵌套在 Person 内部

public static enum PhoneType {
  PHONE_TYPE_UNSPECIFIED(0, 0),
  PHONE_TYPE_MOBILE(1, 1),
  PHONE_TYPE_HOME(2, 2),
  PHONE_TYPE_WORK(3, 3),
  ;
  ...
}

正如你所料,嵌套类型 Person.PhoneNumber 作为 Person 内部的一个嵌套类生成。

构建器 vs. 消息

协议缓冲区编译器生成的消息类都是 不可变的。一旦消息对象构建完成,就不能再修改它,就像 Java String 一样。要构建消息,你必须首先构建一个构建器(builder),将你想设置的任何字段设置为你选择的值,然后调用构建器的 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.PHONE_TYPE_HOME)
        .build());
    .build();

标准消息方法

每个消息和构建器类还包含许多其他方法,让你检查或操作整个消息,包括

  • isInitialized(): 检查所有必需字段是否已设置。
  • toString(): 返回消息的人类可读表示,特别适合调试。
  • mergeFrom(Message other): (仅限构建器) 将 other 的内容合并到此消息中,覆盖单个标量字段,合并复合字段,并连接重复字段。
  • clear(): (仅限构建器) 将所有字段重置回空状态。

这些方法实现了所有 Java 消息和构建器共享的 MessageMessage.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 buffer 类。你的地址簿应用程序首先需要具备的功能是将个人详细信息写入地址簿文件。为此,你需要创建并填充你的 protocol buffer 类实例,然后将它们写入输出流。

这是一个从文件中读取 AddressBook,根据用户输入向其中添加一个新的 Person,然后将新的 AddressBook 再次写回文件的程序。直接调用或引用协议编译器生成的代码的部分已高亮显示。

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.PHONE_TYPE_MOBILE);
      } else if (type.equals("home")) {
        phoneNumber.setType(Person.PhoneType.PHONE_TYPE_HOME);
      } else if (type.equals("work")) {
        phoneNumber.setType(Person.PhoneType.PHONE_TYPE_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 PHONE_TYPE_MOBILE:
            System.out.print("  Mobile phone #: ");
            break;
          case PHONE_TYPE_HOME:
            System.out.print("  Home phone #: ");
            break;
          case PHONE_TYPE_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 的定义。如果你希望你的新缓冲区向后兼容,旧缓冲区向前兼容——而且你几乎肯定会希望如此——那么你需要遵循一些规则。在 protocol buffer 的新版本中

  • 不得 更改任何现有字段的标签号。
  • 不得 添加或删除任何必需字段。
  • 可以 删除可选或重复字段。
  • 可以 添加新的可选或重复字段,但必须使用全新的标签号(也就是说,从未在此 protocol buffer 中使用过的标签号,即使是已被删除的字段使用过的也不行)。

(这些规则有一些例外,但很少使用。)

如果你遵循这些规则,旧代码将愉快地读取新消息并简单地忽略任何新字段。对于旧代码来说,被删除的可选字段将简单地使用其默认值,被删除的重复字段将为空。新代码也将透明地读取旧消息。但是,请记住新可选字段将不会出现在旧消息中,因此你需要明确检查它们是否已设置,使用 has_,或者在你的 .proto 文件中标签号后使用 [default = value] 提供一个合理的默认值。如果可选元素未指定默认值,则使用类型特定的默认值:对于字符串,默认值是空字符串。对于布尔值,默认值是 false。对于数字类型,默认值是零。还要注意,如果你添加了一个新的重复字段,你的新代码将无法判断它是否被留空(由新代码)或根本未设置(由旧代码),因为它没有相应的 has_ 标志。

高级用法

Protocol Buffers 的用途远不止简单的访问器和序列化。务必查阅Java API 参考,了解更多你可以用它们做什么。

Protocol 消息类提供的一个关键特性是 反射。你可以遍历消息的字段并操作它们的值,而无需针对任何特定的消息类型编写代码。使用反射的一个非常有用的方法是将协议消息转换为其他编码(如 XML 或 JSON),或从其他编码转换回来。反射的一个更高级的用途是查找同类型两个消息之间的差异,或开发一种“协议消息的正则表达式”,你可以编写匹配特定消息内容的表达式。如果你发挥想象力,Protocol Buffers 可以应用于比你最初想象的更广泛的问题!

反射作为 MessageMessage.Builder 接口的一部分提供。