Protocol Buffer 基础教程: Java

原文地址:
https://developers.google.com/protocol-buffers/docs/javatutorial

这个教程为Java开发者使用protocol buffers工作提供一个基本的介绍。通过创建一个简单的示例程序,向你展示如何:

  • 在一个.proto文件中定义message格式
  • 使用protocol buffer编译器
  • 使用Java protocol buffer的API来读写message

这不是一个深入介绍如何通过Java使用protocol buffers的教程。你可以通过Protocol Buffer Language Guide, the Java API Reference, the Java Generated Code Guide, and the Encoding Reference.来获取更多的参考信息。

为什么要使用Protocol Buffers?

我们将要使用的例子是一个非常简单的“地址簿”应用,这个应用可以从一个文件里读写人们的联系方式。每个在地址簿中的人都有一个名字, 一个ID, 一个email地址,和一个联系电话号码。

你是如何序列化并寻回像这样的结构化数据的呢?有这样一些方法可以解决这个问题:

  • 使用Java序列化。这是默认的方式,因为它是内建于语言的,但是它有诸多的众所周知的问题(参见Effective Java,by Josh Bloch pp. 213),并且如果你需要与C++或者Python写成的应用共享数据的时候,这种方式并不能很好的工作。
  • 你可以发明一种ad-hoc的方式来将数据项编码成一个单独的串--比如编码 4 ints为“12:3-23:67”。这是一种简单并且灵活的方式,虽然它需要写编码和转码的代码,并且转码的过程会消耗一些运行时间。这种方式对于编码一些简单的数据是最好的。
  • 将数据序列化为XML。这种方式是非常吸引人的,因为XML几乎是人类可读的,并且许多语言都有支持XML的库可以使用。如果你想同其它应用/项目共享数据,这将是一个很好的选择。然而,XML的臭名昭著的空格密集型,使得编码和解码的过程给应用带来巨大的性能问题。与此同时,导航一个XML DOM树比起导航一个类中简单的域属性来说会带来更大的复杂性。

Protocol buffers是灵活,高效,自动化的解决方案,来解决这个问题。通过protocol buffers,你写一个.proto的描述来描述你想要存储的数据结构。proto buffer编译器会通过这个.proto创建一个类,这个类实现了自动化的编码和将proto buffer数据的转化为高效的二进制格式。生成的类为域属性提供了getters和setters,并实现将proto buffer作为一个单元来进行读写的功能。更重要的是,proto buffer格式支持扩展格式,因此,使用这种方式的代码依旧能够读取通过旧格式编码的数据。

哪里可以找到示例代码?

示例代码是被包含在源码包中的,在名为“example”目录下面。点击下载

定义你的Protocol格式

为了创建你的地址簿应用,你将需要从一个.proto文件开始。在.proto文件中定义很简单:你为每一个想要序列化的数据结构添加一个message,然后在message中为每一个属性域指定一个name和一个type。下面是定义了你的message的.proto文件,addressbook.proto

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 phone = 4;
}

message AddressBook {
  repeated Person person = 1;
}

你可以看到语法与C++或者Java很相似。下面我们来仔细看看这个文件的每一部分以及它们的作用。

.proto文件起始于一个包声明,这能够帮助在不同的工程中避免命名冲突。在使用Java的情况下,package name被用来作为Java的package,除非你详细指定了一个java_package,正如我们在这里是这样做的。即使你却是提供了一个java_package,你也应该仍旧定义一个正常的package以便在Protocol Buffers命名空间和非Java语言中避免命名冲突。

在包声明以后,你会看到两个Java特有的配置项:java_packagejava_outer_classnamejava_package指定你生成的类要存在于什么包下。如果你没有具体的指定,它会简单的与package给出的package name相匹配,但是这些名字通常不适合做为Java包的名字(因为它们通常不以域名开头)。java_outer_classname配置项定义了那些在这个文件中包含所有类的名称。如果你没有具体指定java_outer_classname,它将会转换文件名为驼峰式来产生。例如,“my_proto.proto”将默认使用“MyProto”做为outer class name(类的文件名)。

接下来是你的message定义。一个message只是一系列具有类型的域的集合。许多标准的简单类型可以做为可用的域类型,包括bool, int32, float, double, 和string。你同样可以向你的message中添加更多的结构,通过把其他的message类型当做域类型使用--在上面的例子中,Person message包含PhoneNumber message,而AddressBook message包含Person message。你甚至可以通过内部嵌套其它message的方式定义message,你可以看到,PhoneNumber类型被定义在Person里面。你同样可以定义enum类型,如果你希望其中的一个你的域拥有预定义列表中的一个值--在这里,你希望指定一个phone number的值是MOBILE, HOME, 或者WORK中的一个。

这里在每个元素上面的“=1”,“=2”标记,辨识唯一的“tag”,这些“tag”是域在二进制编码的时候使用的。Tag编号从1到15,需要比更大的数字少一个字节,因此,出于优化考虑你可以决定使用常用的或者重复的元素。而从16到更高的编号留给不常用的可选元素。在重复域中的每一个元素都需要重新编码tag number,所以,使用重复域对优化来说是特别好的选择。

每一个域都必须被下面之一的modifer注解:

  • required:域的值必须被提供,否则message会被认为是“未初始化的”。试图创建一个未初始化的message将会抛出一个RuntimeException。转化一个未初始化的message将会抛出一个IOException。除了这些,required域的行为和optional域表现相同。
  • optional:该域可能被设置,也可能未被设置。如果一个optional域未被设置,默认的值将会被使用。对于简单类型来说,你可以指定你自己的默认值,就像我们在例子中对phone number类型所做的那样。否则,一个系统默认的值将被使用:数值类型是0,字符串类型是"",布尔类型是false。对于嵌入式的message来说,默认值总是这个message的没有任何域被设置过的“默认实例”或“原型”。调用访问器去获取一个optional或者required域的值,这些域如果没有被具体设置,那么总是返回域的默认值。
  • repeated:域将会被重复任意次数(包括0次)。重复的值的顺序将会被保存在protocol buffer中。可以把重复的域想成变长的数组。

Required是永久的。你在将域标记成required的时候需要很小心。如果在某些时候你希望停止写入或发送一个required域,它将会易出问题地将域转为一个optional域--以前的读取器将认为没有这个域的message是不完整的,并且可能无意中将它们拒绝或者丢弃。取而代之地,你应该考虑为你的buffer写应用特有的自定义校验规则。在Google的一些工程师得出了结论就是:与带来的益处相比,使用required带来的坏处会更多。他们倾向于只使用optionalrepeated。然而,这种情景并不是普遍的。

你将找到一个完整的教程关于写.proto文件--包括所有可能的域类型--在Protocol Buffer Language Guide。不要试图寻找类似于类继承机制的组件,因为protocol buffer不做这些。

编译你的Protocol Buffers

现在你有一个.proto,接下来你需要生成类,这些类是你需要读写AddressBook(当然也包括PersonPhoneNumber) message用的。为了做到这点,你需要运行proto 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/AddressBookProtos.java在你指定的目标目录中。

Protocol Buffer API

让我们来看一些生成的代码,并看看编译器都为你生成了什么类和方法。如果你看看AddressBookProtos.java文件,你能看到它定义了一个叫做AddressBookProtos的类,嵌套在其中的是你在addressbook.proto中指定的每一个类。每一个类都有自己的Builder类,通过这个类你可以创建那个类的实例。你可以在下面的Builders vs. Messages找到关于builder的更多信息。

message和builder都有自动生成的为message中每个域准备的访问器方法。message只有getter方法,而builder同时有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 phone = 4;
public List<PhoneNumber> getPhoneList();
public int getPhoneCount();
public PhoneNumber getPhone(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 phone = 4;
public List<PhoneNumber> getPhoneList();
public int getPhoneCount();
public PhoneNumber getPhone(int index);
public Builder setPhone(int index, PhoneNumber value);
public Builder addPhone(PhoneNumber value);
public Builder addAllPhone(Iterable<PhoneNumber> value);
public Builder clearPhone();

你可以看到,每一个域都有简单的JavaBeans风格的getters和setters方法。如果一个域被设置了值,同样会有getters为每一个单独的域。最后,每一个域都有一个clear方法,用来将域设置回原来的空状态。

Repeated域有一些额外的方法--一个Count方法(用来速记列表的大小),getters和setters用来通过下标,get或者set一个具体的元素。一个add方法用来向列表中追加一个新元素。一个addAll方法用来追加整个容器中的元素到列表中。

请注意这些访问器方法是如何使用驼峰式的命名,即使.proto文件使用了小写字母+下划线的方式。这种格式的转换是由protocol buffer的编译器自动完成的,以便于生成的类可以符合标准的Java风格规范。你应该总是为.proto文件中的域名称使用“小写字母+下划线”的方式;这确保了好的命名实践在所有的生成的语言里。参考style guide以了解更多好的.proto风格。
了解更多关于编译器对于任何具体的域定义会生成什么样的成员,请参见Java generated code reference

枚举和内嵌类

生成的代码中包含一个PhoneTypeJava 5 enum, 内嵌于Person

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

内嵌的类型Person.PhoneNumber被生成,正如你所期望的那样,作为一个内嵌类在Person中。

Builders vs. Messages

protocol buffer生成的所有message类都是不可变的(immutable)。一旦一个message对象被构建,它就不能被修改,就像Java的String类型一样。为了构建一个message,你必须首先构建一个builder,给任何域设置你想要设置的值,然后调用builder的build()方法。

你或许已经注意到builder的每一个修改message的方法都会返回一个新的builder。返回的对象和你调用方法时使用的其实是同一个builder。它被返回是为了方便,使你能够将若干setters组成一串,在代码中书写为一行(译者注:链式编程)。

这里是一个你如何创建一个Person实例的例子:

Person john =
  Person.newBuilder()
    .setId(1234)
    .setName("John Doe")
    .setEmail("jdoe@example.com")
    .addPhone(
      Person.PhoneNumber.newBuilder()
        .setNumber("555-4321")
        .setType(Person.PhoneType.HOME))
    .build();

标准的Message方法

每一个message和builder类也包含一些其它的方法,这些方法使你可以检查或操作整个message,其中包括:

  • isInitialized():检查是否所有的required域都已经被设置过。
  • toString():返回一个便于人眼阅读的message的展示,在debug的时候特别有用。
  • mergeFrom(Message other):(builder特有)合并other的内容到这个message中,如果是单数域则覆盖,如果是重复域则追加连接。
  • clear():(builder特有)清空所有的域,回到空值状态。

这些方法实现了MessageMessage.Builder接口,这些接口被所有的Java message和builder共享。更多的信息,请参见complete API documentation for Message

转化和序列化

最后,每一个protocol buffer类都有一些方法用来读写你使用protocol buffer二进制格式选择的message。这包括:

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

这些只是一些提供的选项来转化和序列化。再次参见Message API reference的完整列表。

Protocol Buffers和面向对象Protocol buffer类是基本的不发挥作用的数据持有者(像是C++中的结构体);它们不创建第一个类成员在一个对象模型中。如果你想要向一个生成的类中添加丰富的行为,最好的方式是用一个应用特有的类包含生成的protocol buffer类。这样做同样是一个好的方式,如果你对.proto文件的设计没有控制权的时候(这是说,如果你在从另一个项目中重用一个.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.addPhone(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();
  }
}

读取一个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.getPersonList()) {
      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.getPhoneList()) {
        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 number。
  • 你一定不能添加或删除任何required域。
  • 你可以删除optional域或repeated域。
  • 你可以添加新的optional域或repeated域,但是你必须使用新的tag number(比如:你在这个protocol buffer中从没使用过的tag number,甚至不能是已删除的域的tag number)。

(关于这些约定有一些例外的情况,但它们极少被用到。)

如果你遵守这些规则,旧的代码将会很好地读取新的message,并且轻易地忽略任何新的域。对于旧的代码来说,被删除的optional域将有它们的默认值,并且被删除的repeated域将会为空。新的代码将会透明地读取旧的message。然而,需要牢记的是新的optional域将不会在旧的message中出现,所以你将需要具体检查它们是否被has_设置,或者提供一个可靠的默认值在你的.proto文件中,即在tag number后面写[default = value]。如果一个optional元素未被具体指定,那么取而代之地,一个具体类型的默认值将被使用:对于string,默认值是空串。对于boolean,默认值是false。对于numeric类型,默认值是0。同样记得,如果你添加了一个新的repeated域,你的新代码将不能识别它是被置空(通过新的代码)还是从来没有被设置过(通过旧的代码)。因为,没有为它提供has_标记。

高级用法

Protocol buffers有一些超过访问器和序列化的用法。请确保查看过Java API reference来看看你还能用它做些什么。

Protocol message类提供的一个关键特性是反射。你可以迭代一个message中的域,并且不用写任何与message中类型抵触的代码,来操作它们的值它们的值。一个非常有用的方法是使用反射来将protocol message转为或转自其它的编码方式,比如:XML或JSON。一个更高级的反射用法是查找相同类型的两个message的不同之处,或者开发一套“protocol message的正则表达式”,使得你可以通过写表达式来匹配确定的message内容。如果发挥你的想象力,通过应用Protocol Buffer去解决一些的问题会远超出你的预期!

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,123评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,031评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,723评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,357评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,412评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,760评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,904评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,672评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,118评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,456评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,599评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,264评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,857评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,731评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,956评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,286评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,465评论 2 348

推荐阅读更多精彩内容