Protocol Buffer 基础:Java

一篇面向 Java 程序员的 Protocol Buffer 入门介绍。

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

  • .proto 文件中定义消息格式。
  • 使用 Protocol Buffer 编译器。
  • 使用 Java protocol buffer API 来写入和读取消息。

这不是一份在 Java 中使用 protocol buffer 的综合指南。如需更详细的参考信息,请参阅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 Buffer。Protocol Buffer 是解决这一问题的灵活、高效、自动化的解决方案。使用 Protocol Buffer,您需要编写一个 .proto 文件来描述您希望存储的数据结构。然后,Protocol Buffer 编译器会据此创建一个类,该类实现了对 Protocol Buffer 数据的自动编码和解析,并采用高效的二进制格式。生成的类为构成 Protocol Buffer 的字段提供了 getter 和 setter 方法,并负责处理将 Protocol Buffer 作为一个单元进行读写的细节。重要的是,Protocol Buffer 格式支持随着时间的推移扩展格式,这样代码仍然可以读取用旧格式编码的数据。

在哪里找到示例代码

示例代码包含在源代码包的“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,它将通过将文件名转换为大驼峰式命名法 (upper camel case) 来生成。例如,“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" 标记标识了该字段在二进制编码中使用的唯一“标签”(tag)。标签号 1-15 比较大的数字少用一个字节来编码,因此作为一种优化,您可以决定将这些标签用于常用或重复的元素,而将标签 16 及以上的数字留给不常用的可选元素。重复字段中的每个元素都需要重新编码标签号,因此重复字段特别适合进行这种优化。

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

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

您可以在Protocol Buffer 语言指南中找到编写 .proto 文件的完整指南——包括所有可能的字段类型。但不要去寻找类似于类继承的功能——protocol buffers 不支持这个。

编译你的 Protocol Buffers

现在您有了一个 .proto 文件,接下来需要做的就是生成读写 AddressBook(以及 PersonPhoneNumber)消息所需的类。为此,您需要在您的 .proto 文件上运行 Protocol Buffer 编译器 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/,您可以看到它包含了为 addressbook.proto 中指定的每个消息定义的 .java 文件。每个类都有自己的 Builder 类,您可以用它来创建该类的实例。您可以在下面的构建器 (Builder) 与消息 (Message)部分了解更多关于构建器的信息。

消息和构建器都为消息的每个字段自动生成了访问器方法;消息只有 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。每个单数(singular)字段还有一个 has getter,如果该字段已设置,则返回 true。最后,每个字段都有一个 clear 方法,可以将字段取消设置,恢复到其空状态。

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

请注意这些访问器方法如何使用驼峰式命名法,即使 .proto 文件使用的是小写加下划线。这种转换由 protocol buffer 编译器自动完成,以使生成的类符合标准的 Java 风格约定。您应始终在 .proto 文件中使用小写加下划线来命名您的字段;这能确保在所有生成的语言中都有良好的命名实践。有关良好 .proto 风格的更多信息,请参阅风格指南

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

枚举和嵌套类

生成的代码包含一个 Java 5 枚举 PhoneType,嵌套在 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 内部的一个嵌套类生成的。

构建器 (Builder) 与消息 (Message)

由 protocol buffer 编译器生成的消息类都是*不可变的*。一旦一个消息对象被构造出来,它就不能被修改,就像 Java 的 String 一样。要构造一个消息,您必须首先构造一个构建器,将您想要设置的任何字段设置为您选择的值,然后调用构建器的 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():检查所有 required 字段是否都已设置。
  • 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 中:

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

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

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

高级用法

Protocol buffer 的用途超出了简单的访问器和序列化。请务必探索Java API 参考,看看您还能用它们做什么。

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

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