十三.Java使用Protobuf3
阅读原文时间:2023年07月12日阅读:2

为什么使用Protobuf?

本教程翻译自谷歌开发者官网,原文地址:https://developers.google.com/protocol-buffers/docs/javatutorial。开发学院对其进行了简单的翻译和排版,本页面的内容是根据知识共享属性3.0许可的,代码示例是根据Apache 2.0许可的。

Protocol Buffer 基础

本教程为java程序介绍了Protocol Buffer 的基本知识。通过创建一个简单的示例程序,它向您展示了如下内容:

在. proto文件中定义消息格式。

使用Protocol Buffer 编译器。

使用Java protocol buffer API 来写和读消息。

这不是在Java中使用Protocol Buffer的全面指南。有关更详细的参考信息,请参见Protocol Buffer语言指南、Java应用编程接口参考、Java生成代码指南和编码参考。

为什么使用Protocol Buffers?

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

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

使用Java序列化。这是默认的方法,因为它内置于语言中,但是它有许多众所周知的问题,如果您需要与用C与C++程序设计学习与实验系统或Python编写的应用程序共享数据,这种方法也不能很好地工作。

您可以发明一种特殊的方法将数据项编码成单个字符串,例如将4个int编码为“12:3:-23:67”。这是一种简单而灵活的方法,尽管它确实需要编写一次性编码和解析代码,而且解析会带来很小的运行时成本。这对于编码非常简单的数据最有效。

将数据序列化为XML。这种方法可能非常有吸引力,因为XML(某种程度上)是人类可读的,并且有许多语言的绑定库。如果您想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,众所周知,XML占用大量空间,编码/解码它会给应用程序带来巨大的性能损失。此外,导航一个XML DOM树比导航一个类中的简单字段要复杂得多。

Protocol buffers是解决这个问题的灵活、高效、自动化的解决方案。使用Protocol Buffer,您可以编写一个想要存储的数据结构的. proto描述。为此,Protocol Buffer编译器创建了一个类,该类以有效的二进制格式实现Protocol Buffer数据的自动编码和解析。生成的类为组成Protocol Buffer的字段提供了获取器和设置器,并作为一个单元处理读写Protocol Buffer的细节。重要的是,Protocol Buffer格式支持随着时间的推移扩展格式的想法,使得代码仍然可以读取用旧格式编码的数据。

第一步:编写.proto文件

要创建地址簿程序,您需要从. proto文件开始。. proto文件中的定义很简单:为要序列化的每个数据结构添加一条消息,然后为消息中的每个字段指定一个名称和类型。下面是定义好的消息文件,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包,除非您已经明确指定了一个Java包,就像我们在这里所做的那样。即使您提供了一个java包,您也应该定义一个普通的包,以避免在Protocol Buffers名字空间和非Java语言中的名字冲突。

在包声明之后,您可以看到两个特定于Java的选项:java_package和java_outer_classname。java_package指定生成的类应该以什么样的java包名存在。如果没有明确指定,它只是与包声明中给出的包名相匹配,但是这些名称通常不是合适的Java包名(因为它们通常不以域名开头)。java_outer_classname选项定义了应该包含该文件中所有类的类名。如果没有明确给出java_outer_classname,它将通过将文件名转换为camel大小写来生成。例如,默认情况下,“my_proto.proto”将使用“MyProto”作为外部类名。

接下来,您需要定义消息。消息只是包含一组类型化字段的集合。许多标准的简单数据类型作为字段类型可用,包括bool、int32、float、double和string。您还可以通过使用其他消息类型作为字段类型来为消息添加进一步的结构,在上面的示例中,Person消息包含PhoneNumber消息,而AddressBook消息包含Person消息。您甚至可以定义嵌套在其他消息中的消息类型,如您所见,PhoneNumber类型是在Person中定义的。如果您希望某个字段具有预定义的值列表之一,也可以定义枚举类型,在这里,您希望指定电话号码可以是MOBILE, HOME或WORK之一。

每个元件识别上的" = 1", " = 2"标记是字段在二进制编码中使用的唯一“标签”。标签号1-15需要比更高的数字少一个字节来编码,因此作为一种优化,您可以决定将那些标签用于常用或重复的元素,而将标签号16和更高的用于不常用的可选元素。重复字段中的每个元素都需要重新编码标签号,因此重复字段是这种优化的特别好的候选者。

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

  • required: 必须为字段提供一个值,否则消息将被视为“uninitialized”。试图构建未初始化的消息将引发RuntimeException。解析未初始化的消息将引发IOException。除此之外,必填字段的行为与可选字段完全一样。

  • optional: 该字段可以被设置,也可以不被设置。如果未设置可选字段值,则使用默认值。对于简单类型,您可以指定自己的默认值,就像我们在示例中对电话号码类型所做的那样。否则,将使用系统默认值:数字类型为零,字符串为空字符串,布尔值为假。对于嵌入式消息,默认值始终是消息的“default instance”或“prototype”,其中没有设置任何字段。调用访问器来获取未显式设置的optional (或required)字段的值总是返回该字段的默认值。

  • repeated: 该字段可以重复任何次数(包括零)。重复值的顺序将保留在Protocol Buffer中。将重复字段视为动态大小的数组。

Required是永久性的。在根据需要标记字段时,您应该非常小心。如果您希望在某个时候停止写入或发送Required字段,将该字段更改为optional 将会有问题,旧的接收者会认为没有该字段的邮件不完整,可能会无意中拒绝或丢弃它们。您应该考虑为您的Protocol Buffer编写特定于应用程序的自定义验证例程。谷歌的一些工程师得出结论,使用Required弊大于利;他们更喜欢只使用optional和repeated。然而,这种观点并不普遍。

你会找到完整的写作指南。Protocol Buffer 语言指南中的原型文件,包括所有可能的字段类型。但是,别尝试寻找类似于类继承的工具,Protocol Buffer 不会这样做。

第二部:生成Java类

现在我们定义好了.proto,我们需要做的下一件事就是生成需要读写AddressBook(以及Person和PhoneNumber)信息的类。为此,您需要在您的.proto上运行Protocol Buffer编译器:

如果您尚未安装编译器,请下载软件包,并按照自述文件中的说明操作。

现在运行编译器,指定源目录(您的应用程序的源代码所在的位置,如果不提供值则使用当前目录)、目标目录(您希望生成的代码所在的位置;通常与$SRC_DIR相同),以及到您的. proto的路径,下面是命令:

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部分找到更多关于生成器的信息。

消息和生成器都为消息的每个字段自动生成访问者方法;消息只有获取者,而构建者既有获取者又有设置者。下面是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);

与此同时,erson.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();

如您所见,每个字段都有简单的JavaBeans风格的getter和setter。每个特殊字段也有setter,如果该字段已设置,setter、

返回真。最后,每个字段都有一个清晰的方法,可以将字段重新设置为空状态。

重复字段有一些额外的方法:计数方法(这只是列表大小的简写)、通过索引获取或设置列表中特定元素的getter和setter、向列表中添加新元素的添加方法以及向列表中添加一个装满元素的完整容器的添加所有方法。

请注意这些访问器方法如何使用camel-case命名,即.proto文件使用带下划线的小写字母。这种转换由protocol buffer编译器自动完成,以便生成的类与标准的Java风格约定相匹配。您应该始终在.proto文件中使用带下划线的小写字母作为字段名;这确保了所有生成语言的良好命名实践。

有关protocol buffer编译器为任何特定字段定义生成的确切成员的更多信息,请参见Java生成的代码引用。

枚举和内部类

生成的代码包括一个嵌套在Person中的PhoneType枚举:

public static enum PhoneType {
  MOBILE(0, 0),
  HOME(1, 1),
  WORK(2, 2),
  ;
  …
}

正如您所料,嵌套类型Person.PhoneNumber 是作为Person中的嵌套类生成的。

生成器 vs 消息

protocol buffer编译器生成的消息类都是不可变的。一旦消息对象被构造,就不能像Java字符串一样被修改。要构造消息,您必须首先构造一个生成器,将您想要设置的任何字段设置为您选择的值,然后调用生成器的build()方法。

您可能已经注意到,每个修改消息的生成器方法都会返回另一个生成器。返回的对象实际上是您调用方法的同一生成器。返回它是为了方便,这样您可以在一行代码中将几个setters串在一起。

下面是一个如何创建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): (仅生成器) 将其他的内容合并到此消息中,覆盖单个标量字段,合并复合字段,并连接重复的字段。

clear(): (仅生成器) 将所有字段清除回空状态。

这些方法实现了消息和消息。所有Java消息和生成器共享的生成器接口。有关更多信息,请参见完整的消息应用编程接口文档。

解析和序列化

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

byte[] toByteArray();: 序列化消息并返回包含原始字节的字节数组。

static Person parseFrom(byte[] data);: 解析给定字节数组中的消息。

void writeTo(OutputStream output);: 序列化消息并将其写入输出流。

static Person parseFrom(InputStream input);: 读取并解析来自输入流的消息。

这些只是为解析和序列化提供的几个选项。同样,有关完整列表,请参见消息应用编程接口参考。

Protocol Buffers和O-O设计Protocol buffer类基本上是哑数据持有人(像C语言中的结构);在实物模型中,他们不是优秀的一等公民。如果您想向生成的类添加更丰富的行为,最好的方法是将生成的Protocol Buffer类包装在特定于应用程序的类中。如果您不能控制.proto文件(例如,如果您正在重用另一个项目中的一个文件)。在这种情况下,您可以使用包装类来创建一个更适合您的应用程序的独特环境的接口:隐藏一些数据和方法,公开方便的函数,等等。永远不要通过继承生成的类来给它们添加行为。这将打破内部机制,无论如何都不是好的面向对象实践。

第三步:使用生成的类创建对象

现在让我们尝试使用生成的protocol buffer类。我们希望地址簿程序能够做的第一件事是将个人详细信息写入地址簿文件。为此,需要创建并填充protocol buffer类的实例,然后将它们写入输出流。

这是一个从文件中读取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);
  }
}

示例代码

示例代码在https://developers.google.com/protocol-buffers/docs/downloads.html下载,在"examples" 目录中。