Protobuf -java基础教程(译文)

郭凯
2023-12-01

protobuf 基础教程

最近突然对RPC序列化感兴趣,但是发现Protobuf的资料并不多,于是在官网找到了Java使用Protocol Buffer的入门指南,用蹩脚的英文翻译了下,以飨同道。原文地址

示例开始:定义协议格式 Protocol Format

示例:一个简单的通讯簿, .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;
}
复制代码

.proto 文件开始声明包,以免出现命名冲突。 在 JAVA 中,该包名可以作为java中的package,除非您又专门指定了 java_package,在addressbook.proto 中我们就指定了package。

即使您指定了一个java_package,也应该需要定义一个常规的package,是为了在Protocol Buffers命名空间中产生冲突。

在包定义声明之后,还看到了两个java规范中的可选项: java_packagejava_outer_classnamejava_package 指定了生成的java类需要放在哪个包下面,如果没有指定这个值,则会使用package指定的值。 java_outer_classname 选项定义了类名,包含这个.proto文件里面的所有类,如果没有明确指定这个值的话,将会把文件名通过驼峰大小写命名方式作为类名, 例如 my_proto.proto 则默认会生成 MyProto 类名。

接下来,就有了消息(message)定义。

一个消息(message)是包含一系列类型字段的聚合。

很多标准的简单数据类型供字段可用,包含:

  • bool
  • int32
  • float
  • double
  • string

您还可以添加其他的结构类型为您的消息字段类型所用。前面的示例中,Person 消息包含了PhoneNumber消息,而AddressBook消息包含Person消息。 您还可以定义枚举类型enum,如果你想你的字段可能的值为一个事先预定好的列表中的值,就可以使用enum类型。 这个电话簿示例中,phone number有三种类型: MOBILEHOMEWORK

=1=2 表示每个元素上的标识字段在二进制编码中使用的唯一“标记”。 Tag编号 1-15 编号所需的字节比编号高的要少,所以您可以对常用的或经常重复使用的标记使用这些编号,剩下的16到更高的标记在可选元素中比较少用。 重复字段中的每个元素都需要重新编码标记号tag,所以重复的字段是这种优化的最佳选择。

每个字段必须标注为以下几种修饰符:

  • required :该字段必须提供,否则认为消息是"未初始化"的。尝试去构建一个未初始化的消息会抛出一个RuntimeException。解析一个未初始化的消息则会抛出一个IOException。除此之外,required字段的行为和optional字段完全一样。

  • optional :表示可选的字段,该字段可以设值亦可以不设值。如果一个可选字段没有set值,则会使用其默认值进行初始化。对于简单类型,您可以明确指定自己默认的值,就像我们在示例中处理的一样(phoneNumber的type字段)。否则,系统默认值:数值类型默认为0,字符类型默认为空串,boo类型默认为false。对于嵌入的消息,默认值总是"默认实例"或者消息的"原型",没有设置任何字段。

  • repeated :该字段可以重复任意次数使用。重复值的顺序将会保留在protocol buffer中。可以将重复字段看作动态数组。

Required Is Forever 您需要非常谨慎地将字段标记为required修饰。如果在某些时候,您希望停止写或发送一个required字段,在将该字段变更为optional时可能会发生问题——旧的reader认为消息没有这个值则会拒绝或者丢弃这个消息。您应该为考虑为缓冲区编写特定于应用程序地校验例程。有一些Google工程师推测使用required弊大于利。他们更倾向于使用opyionalrepeated。不管怎样,这种观点并不普遍。

您还可以在Protocol Buffer 语言指南中了解到完整地教程。 不要尝试去寻找类似继承的工具,protocol buffer不支持这样做。

编译你的Protocol Buffers

现在您有了一个 .proto 文件了,下一步要做的事,就是生成一个您将要读、写的AddressBook类。因此,您需要运行potocol buffer编译器 protoc 处理 .proto:

协议编译安装

Protocol 编译器是C++编写的。如果您使用C++,请根据C++安装指导安装protoc。 对于非C++用户,最简单的安装protocol编译器的方式是从release页下载预构建的二进制:github.com/protocolbuf…

下载好的 protoc-$VERSION-$PLATFORM.zip。包含了二进制protoc,还有一系列与protobuf发布的标准的.proto文件。 如果您还想找旧版本,可以在https://repo1.maven.org/maven2/com/google/protobuf/protoc/找到。

这些预构建的二进制文件只会在发行版本中提供。

Protobuf 运行时安装

Protobuf 支持几种不同的编程语言。针对于每一种语言,你可以参考源码中的各种语言的说明指导。

  • 现在运行编译器,需要指明源目录(应用源代码所在-如果您没有提供一个值的话默认使用当前目录)、目标目录(您想要代码生成的目的目录,通常类似于 $SRC_DIR)、还有 .proto 的路径。在这种情况下,您可以如下操作:

protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto

因为您想生成Java类,您看到 --java_out 选项,类似的选项也提供了其他编程语言。

这将会在您指定的目标目录生成com/example/tutorial/AddressBookProtos.java

Protocol Buffer API

让我们来看看一些生成的代码,并查看一些由编译器为您创建的类与方法。如果您看一下AddressBookProtos.java 类,可以看到定义了一个叫做AddressBookProtos的类,内嵌了您在addressbooproto文件中为每个消息指定的类。每个类都拥有自身的Builder类,您可以使用它来创建一个对应的类实例。您可以在下面的 Builders vs. Messages可以看到更多细节。

messages 和 builders 拥有针对消息每个字段的自动生成的访问方法。message仅仅拥有getters方法、builders拥有getters、setters方法。这里有一些关于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 内部类则拥有getters、setters:

// 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();
复制代码

正如您所看到的一样,每个字段都有简单的 Java Bean 风格的getter、setter 方法。 每个字段也有其has方法,如果设置了字段值,则会返回true。 最后,每个字段还有clear方法,可以将该字段回归到其空白状态。

repeated 字段由一些额外的方法:

  • getXXXCount 方法用于获取泪飙大小;
  • 增加了根据元素索引下标获取元素的get、set方法(public PhoneNumber getPhones(int index); 和 public Builder setPhones(int index, PhoneNumber value);)。
  • *add、addAll 方法追加新元素(列表)到列表中。

注意到所有这些访问方法都使用了驼峰命名方式,即使 .proto 文件使用了小写和下划线。这个转换是由protocol编译器自动完成的,所以生成的类符合Java风格标准规范。在 .proto 中您应该总是将字段名用小写和下划线来命名。 可以参考风格指南获取更多良好的 .proto 命名风格。 更多关于编译器为字段生成的特定的详细信息可以参考 Java生成代码参考指南

枚举和内部类

生成的代码包含了一个枚举 PhoneType ,嵌套在 Person 类中:

public static enum PhoneType {
  MOBILE(0, 0),
  HOME(1, 1),
  WORK(2, 2),
  ;
  ...
}
复制代码

内部类Person.PhoneNumber也生成了,正如您期望的一样,它是作为Person的一个内部类的。

Builders vs. Messages

protocol buffer编译器生成的类都是不可变的。一旦一个message对象被创建后,就不能再被更改,类似于Java的String类。要构建一个message,您必须构建一个builder,并设置任何您想设置的字段的值,再调用builders's 的builder 方法。(使用过lombok的朋友,这很类似其@Builder注解的用法)

您可能还注意到每个builder的方法返回的是另一个builder。返回的对象其实和您调用方法的builder是同一个。这种处理方式很方便,您可以将多个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.HOME)
        .build())
    .build();
复制代码

标准的message方法

每个message和builder类也包含一系列其他的方法,可以让您检查与操作message:

  • isInitialized() : 检查是否所有的required 字段都已经set过值了/
  • toString() : 返回一个可读性良好的message表示,通常对调试特别有用。
  • mergeFrom(Message other) : (只在builder有)将other合并到该message中
  • clear() : (只在builder有)清除所有的字段,时其恢复到最初的空状态

解析和序列化

最后,每个 proto buffer 类都有一些用于读和写二进制的方法。

  • byte[] toByteArray() : 序列化message,返回一个byte数组。
  • static Person parseFrom(byte[] data) : 将给定的byte数组解析成一个message。
  • void writeTo(OutputStream output) : 序列化message,并将其写入一个输出流。
  • static Person parseFrom(InputStream input) : 读取一个输入流并从其解析处一个message。

这些只是为序列化和解析(反序列化)而提供的两组操作方法。更多完整列表可以参考 Message API帮助文档

Protocol Buffers 和 面向对象 Protocol buffer 类基本上是哑数据持有者(类似C中的struct);在对象模型中,它们不是一等公民。如果您想向生成类中添加更丰富的行为,最好的方式就是在一个特定于应用程序中的类中包装protocol buffer生成的类。如果您不能控制.proto文件的设计(例如,如果您正在重用来自另一个项目的一个文件),那么包装协议缓冲区也是一个好主意。在这种情况下,您可以使用包装器类来创建更适合您的应用程序的独特环境的接口:隐藏一些数据和方法,公开方便的函数,等等。这将破坏内部机制,而且无论如何都不是良好的面向对象实践。

写一个Message

现在尝试使用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();
  }
}
复制代码

读一个Message

当然,如果您不能从通讯簿中就获取任何信息,那也就没什么用了。这个示例展示了读取前面一个示例创建的文件并输出全部信息:

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);
  }
}
复制代码

扩展 Protocol Buffer

当您使用protocol buffer发布代码后,毋庸置疑,您会希望改善protocol buffer的定义。如果您想您的新buffer可以向后兼容,并且旧的buffer可以向前兼容——您肯定想要这个——这需要遵循一些规则。在新版本的protocol buffer中需要遵循:

  • 您不能改变已经存在的字段的tag的编号
  • 您不能添加或者删除任何required字段
  • 您可以删除optional或者repeated字段
  • 您可以添加新的optional或者repeated字段,但那时您必须使用新的tag编号(即该tag编号没有在这个protocol buffer文件中使用,或者已经被删除了)

如果您遵循这些规则,旧代码可以友好地读取新的message,并且忽略新的字段。对于旧代码,那些删除地optional字段会使用它们默认的值,而repeated字段则为空。 新代码显然会读取旧的message。不论如何,切记新的optional字段在旧的message中是不会出现的,所以您需要检查它们是否设置了值,可以使用 has_,或者在您的 .proto文件中在该字段的tab编号后面使用 [default = value] 为其提供一个default值。 如果optional字段没有指定default值,则会根据该类型自动为其赋值:string类型则赋值空串,对于布尔类型则赋值为false,对于数值类型则赋值为0. 请注意,假如您添加了一个repeated字段,您的新代码将无从知晓该字段是空的(新代码),还是从来没有设置过值(旧代码),因为它没有 has_ 方法。

高级用法

Protocol buffers 不仅仅只是提供了简单的访问和序列化功能。可以访问 Java API帮助文档.

protocol message类体哦概念股了一个关键的特性——反射。您可以遍历message的所有字段和操作它们的值而不需要编写指定的message类型的代码。 反射的一个有用的使用方式是在各种编码之间转换协议消息,例如XML或者JSON。 一个关于反射的更高级的用法是从两个相同类型的消息message中找出不同之处,或者开发出一种协议消息的“正则表达式”,是的您可以编写这种表达式去匹配某些message内容。 如果您充分发挥您的想象,使用protocol Buffers可以应用在更广泛的问题上,正如您所期望的那样。

转载于:https://juejin.im/post/5c4b16a26fb9a04a09566a4a

 类似资料: