Protocol Buffer 基础:Java

Java 程序员入门指南,介绍如何使用 Protocol Buffers。

本教程为 Java 程序员提供了使用 Protocol Buffers 的入门指南。通过逐步创建一个简单的示例应用程序,它将向您展示如何:

  • .proto 文件中定义消息格式。
  • 使用 Protocol Buffer 编译器。
  • 使用 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 notoriouly 占用空间很大,并且对其进行编码/解码可能会对应用程序造成巨大的性能损失。此外,导航 XML DOM 树比导航类中的简单字段要复杂得多。

除了这些选项之外,您还可以使用 Protocol Buffers。Protocol Buffers 是解决此类问题的灵活、高效且自动化的解决方案。使用 Protocol Buffers,您可以编写 .proto 文件来描述您希望存储的数据结构。由此,Protocol Buffer 编译器会创建一个类,该类使用高效的二进制格式实现 Protocol Buffer 数据的自动编码和解析。生成的类为构成 Protocol Buffer 的字段提供 getter 和 setter,并处理将 Protocol Buffer 作为单元读取和写入的细节。重要的是,Protocol Buffer 格式支持随着时间的推移扩展格式,以便代码仍然可以读取使用旧格式编码的数据。

在哪里找到示例代码

示例代码包含在源代码包中,“examples”目录下。 在此处下载。

定义您的协议格式

要创建您的地址簿应用程序,您需要从 .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 {
    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 所做的那样。否则,将使用系统默认值:数字类型为零,字符串为空字符串,布尔值为假。对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,其所有字段均未设置。调用访问器以获取尚未显式设置的可选(或必需)字段的值始终返回该字段的默认值。
  • repeated:字段可以重复任意次数(包括零次)。重复值的顺序将保存在 Protocol Buffer 中。将重复字段视为动态大小的数组。
  • required:必须提供字段的值,否则消息将被视为“未初始化”。尝试构建未初始化的消息将抛出 RuntimeException。解析未初始化的消息将抛出 IOException。除此之外,必需字段的行为与可选字段完全相同。

您将在Protocol Buffer 语言指南中找到有关编写.proto文件(包括所有可能的字段类型)的完整指南。不过,不要寻找类似于类继承的功能——协议缓冲区不提供此功能。

编译您的 Protocol Buffers

现在您有了.proto文件,接下来需要做的是生成读取和写入AddressBook(以及PersonPhoneNumber)消息所需的类。为此,您需要在您的.proto上运行协议缓冲区编译器protoc

  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/,您会发现它包含.java文件,这些文件为addressbook.proto中指定的每个消息定义了一个类。每个类都有自己的Builder类,您可以使用它来创建该类的实例。您可以在下面的构建器与消息部分中了解更多关于构建器的信息。

消息和构建器都具有为消息的每个字段自动生成的访问器方法;消息仅具有 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 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 方法。对于每个单一字段,还有一些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中的嵌套类生成。

构建器与消息

协议缓冲区编译器生成的 message 类都是不可变的。一旦构造了 message 对象,就无法修改它,就像 Java String一样。要构造一个 message,您必须首先构造一个构建器,将您想要设置的任何字段设置为所选的值,然后调用构建器的build()方法。

您可能已经注意到,构建器的每个修改 message 的方法都会返回另一个构建器。返回的对象实际上是您在其中调用该方法的同一个构建器。出于方便起见,它被返回,以便您可以在一行代码中串联多个 setter 方法。

以下是如何创建Person实例的示例

Person john =
  Person.newBuilder()
    .setId(1234)
    .setName("John Doe")
    .setEmail("[email protected]")
    .addPhones(
      Person.PhoneNumber.newBuilder()
        .setNumber("555-4321")
        .setType(Person.PhoneType.PHONE_TYPE_HOME)
        .build());
    .build();

标准消息方法

每个 message 和构建器类还包含许多其他方法,允许您检查或操作整个 message,包括

  • isInitialized():检查是否已设置所有必选字段。
  • toString():返回 message 的人类可读表示形式,这对于调试特别有用。
  • mergeFrom(Message other): (仅限构建器)将other的内容合并到此 message 中,覆盖单一标量字段,合并复合字段并连接重复字段。
  • clear(): (仅限构建器)将所有字段清除回空状态。

这些方法实现了MessageMessage.Builder接口,所有 Java message 和构建器都共享这些接口。有关更多信息,请参阅Message的完整 API 文档

解析和序列化

最后,每个协议缓冲区类都具有使用协议缓冲区二进制格式写入和读取所选类型消息的方法。这些包括

  • byte[] toByteArray();:序列化 message 并返回一个包含其原始字节的字节数组。
  • static Person parseFrom(byte[] data);:从给定的字节数组解析 message。
  • void writeTo(OutputStream output);:序列化 message 并将其写入OutputStream
  • static Person parseFrom(InputStream input);:从InputStream读取并解析 message。

这些只是解析和序列化提供的几个选项。同样,有关完整列表,请参阅Message API 参考

写入消息

现在让我们尝试使用您的协议缓冲区类。您希望您的通讯录应用程序能够执行的第一件事是将个人详细信息写入您的通讯录文件。为此,您需要创建和填充协议缓冲区类的实例,然后将其写入输出流。

这是一个程序,它从文件中读取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

在发布使用协议缓冲区的代码后,您迟早肯定会想要“改进”协议缓冲区的定义。如果您希望您的新缓冲区向后兼容,并且您的旧缓冲区向前兼容——而您几乎肯定希望这样——那么您需要遵循一些规则。在新版本的协议缓冲区中

  • 不得更改任何现有字段的标签号。
  • 不得添加或删除任何必选字段。
  • 可以删除可选字段或重复字段。
  • 可以添加新的可选字段或重复字段,但必须使用新的标签号(即,在此协议缓冲区中从未使用过的标签号,即使是已删除字段的标签号)。

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

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

高级用法

Protocol Buffers 的用途远不止简单的访问器和序列化。请务必浏览 Java API 参考,了解您可以用它们做些什么。

Protocol buffer 消息类提供的一个关键特性是反射。您可以遍历消息的字段并操作其值,而无需针对任何特定消息类型编写代码。反射的一个非常有用的用途是将 protocol buffer 消息转换为其他编码(例如 XML 或 JSON),反之亦然。反射的更高级用法可能是查找相同类型两个消息之间的差异,或者开发一种“protocol buffer 消息正则表达式”,您可以在其中编写与某些消息内容匹配的表达式。如果发挥您的想象力,Protocol Buffers 可以应用于比您最初预期的更广泛的问题范围!

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