在Java中使用Protocol Buffers

這份教程爲Java開發者提供了使用 Protocol Buffer 的基本介紹。通過創建一個簡單的示例應用,它展示了

  • .proto 文件中定義消息格式。
  • 使用 Protocol Buffer 編譯器。
  • 使用Java Protocol Buffer API讀寫消息。

這不是一個在Java中使用 Protocol Buffer 的全面指南。更多詳細的信息,請參考Protocol Buffer語言指南Java API參考Java Generated Code Guide,和 編碼參考

爲什麼使用Protocol Buffers?

我們將使用的例子是一個非常簡單的 "address book" 應用,它可以從文件讀取和向文件寫入人們的聯繫人詳情。地址簿中的每個人具有一個名字 (name),ID,電子郵件地址 (email address),和聯繫人電話號碼 (contact phone)。

你要如何序列化和提取這樣的結構化數據呢?有一些方法可以解決這個問題:

  • 使用Java序列化接口。這是默認的方法,因爲它是編程語言內建的,但它有一個廣爲人知的問題 (參見Josh Bloch的Effective Java,pp. 213),而且如果你需要與用C++或Python編寫的應用共享數據時不能很好的工作。
  • 你可以發明一種特別的方式來將數據項編碼爲一個字符串 —— 比如將4個int值編碼爲"12:3:-23:67"。這是一個簡單而靈活的方法,儘管它需要編寫一次性的編碼和解析代碼,而且解析消耗一小段運行時代價。這對於編碼非常簡單的數據是最好的方式。
  • 將數據序列化爲XML。這種方法可能非常具有吸引力,因爲XML是 (有點) 人類可讀的,而且它有大量編程語言的bindings庫。如果你想要與其它的應用/項目共享數據的話,這可能是一個很好的選擇。然而,XML是臭名昭著的空間密集,而且編碼/解碼它需要消耗應用大量的性能開銷。而且,瀏覽一個XML DOM樹也被認爲比通常瀏覽類中的簡單字段更復雜。

Protocol buffers 是解決這個問題靈活,高效,自動化的方案。通過 Protocol buffers ,你可以編寫一個 .proto 描述你想要存儲的數據結構。通過它, Protocol buffers 編譯器創建一個類,以一種高效的二進制格式實現自動地編碼和解析 Protocol buffers 數據。生成的類爲構成一個 Protocol buffers 的字段提供了getters和setters方法,並處理讀取和寫入 Protocol buffers 的細節。重要地是, Protocol buffers 格式通過使代碼依然能夠讀取用老的格式編碼的數據來支持隨着時間對格式的擴展。

在哪裏可以找到示例代碼

源碼包中包含的示例代碼,在"examples" 目錄下。在這裏下載。

定義你的協議格式

爲了創建你的地址簿應用,你需要先創建一個 .proto 文件。 .proto 文件中的定義很簡單:爲每個你想要序列化的數據結構添加一個 消息(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中,包名被用作Java包,除非你已經顯式地指定了 java_package,如我們這裏看到的。即使你不提供 java_package,你依然應該定義一個普通的 package 以避免Protocol Buffers命名空間中的衝突,以及在非Java語言中。

聲明瞭包之後,你可以看到兩個Java特有的選項: java_packagejava_outer_classnamejava_package 指定生成的類應該放在什麼Java包名下。如果你沒有顯式地指定這個值,則它簡單地匹配由package 聲明給出的Java包名,但這些名字通常都不是合適的Java包名 (由於它們通常不以一個域名打頭)。 java_outer_classname 選項定義應該包含這個文件中所有類的類名。如果你沒有顯式地給定java_outer_classname ,則將通過把文件名轉換爲首字母大寫來生成。比如"my_proto.proto",默認情況下,將使用 "MyProto" 做爲它的外層類的類名。

接下來,定義你的消息。消息只是包含了具有類型的字段的聚合。許多標準的簡單數據類型可用作字段類型,包括bool,int32,float,double,和string。你也可以通過使用消息類型作爲字段類型來給你的消息添加更多結構 —— 在上面的例子中,Person消息包含了多個PhoneNumber消息,同時AddressBook消息包含Person消息。你甚至可以在其它消息中嵌套的定義消息類型 —— 如你所見,PhoneNumber類型是在Person中定義的。如果你想要你的字段值爲某個預定義的值列表中的某個值的話,你也可以定義enum類型 —— 這裏你想要指定電話號碼是MOBILE,HOME,或WORK中的一個。

每個元素上的 " = 1"," = 2"標記標識在二進制編碼中使用的該字段唯一的 "tag" 。Tag數字 1-15 比更大的數字在編碼上少一個字節,因而作爲一種優化,你可以決定將那些數字用作常用的或重複的元素的tag,而將16及更大的數字tag留給更加不常用的可選元素。重複字段中的每個元素需要重編碼tag數字,因而這種優化特別適用於重複字段。

每個字段必須用下面的修飾符中的一個來註解:

  • required:字段必須提供,否則消息將被認爲是 "未初始化的 (uninitialized)"。嘗試構建一個未初始化的消息將拋出一個 RuntimeException。解析一個未初始化的消息將拋出一個 IOException。此外,required字段的行爲與optional字段完全相同。

  • optional:字段可以設置也可以不設置。如果可選的字段值沒有設置,則將使用默認值。對於簡單的類型,你可以指定你自己的默認值,如我們在例子中爲電話號碼 類型 做的那樣。否則,將使用系統默認值:數字類型爲0,字符串類型爲空字符串,bools值爲false。對於內嵌的消息,默認值總是消息的 "默認實例 (default instance)" 或 "原型(prototype)",它們沒有自己的字段集。調用accessor獲取還沒有顯式地設置的 optional (或required) 字段的值總是返回字段的默認值。

  • repeated:字段可以重複任意多次 (包括0)。在 protocol buffer 中,重複值的順序將被保留。將重複字段想象爲動態大小的數組。

你將找到一個編寫 .proto 文件的完整指南 —— 包括所有可能的字段類型 —— 在Protocol Buffer Language Guide 一文中。不要尋找與類繼承類似的設施 —— protocol buffer 不那樣做。

編譯你的Protocol Buffers

現在你有了一個.proto,接下來你需要做的事情是生成讀寫 AddressBook (及Person 和 PhoneNumber) 消息所需的類。要做到這一點,你需要在你的 .proto 上運行 Protocol Buffers 編譯器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/AddressBookProtos.java

Protocol Buffer API

讓我們看一下生成的代碼,並看一下編譯器都爲你創建了什麼類和函數。如果查看 AddressBookProtos.java,你可以看到它定義了一個稱爲 AddressBookProtos 的類,其中嵌套了爲你在 addressbook.proto 中描述的每個消息的類。每個類都有它自己的 Builder 類,你可以用來創建那個類的實例。你可以在下面的 Builders vs. Messages 小節中找到更多關於builders的信息。

消息和builders具有爲消息的每個字段自動生成的accessor方法;消息只有getters,而builders則同時具有getters和setters。這裏是 Person 類的一些accessors (省略實現以便於簡潔):

// 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.Person 類具有相同的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 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。每個單數的 (required 或 optional) 字段還有 has 方法,如果那個字段已經被設置了則它們放回true。最後,每個字段具有一個 clear 方法,用於將字段設置回它的空狀態。

重複的字段還有一些額外的方法 —— 一個 Count 方法(是列表大小的速記),通過索引獲取和設置列表的特定元素的getters和setters,一個 add 方法,將新元素添加到列表的末尾,及一個 addAll 方法,它將一個裝滿元素的整個容器添加到列表中。

注意這些accessor方法是如何以駝峯形式命名的,即使 .proto 文件使用了小寫字母加下劃線。這種轉換是由protocol buffer編譯器自動地完成的,以產生與標準Java風格規範匹配的類。你應該總是在你的 .proto 文件中爲字段使用小寫字母加下劃線;這確保了在所有生成的語言中良好的命名實踐。參考 風格指南 來了解更多好的 .proto 風格。

關於protocol編譯器爲任何特定的字段定義產生什麼成員的更多信息,請參考 Java 生成代碼參考

枚舉和嵌套類

生成的代碼包含一個PhoneType Java 5枚舉,嵌套在 Person 中:

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

生成的嵌套類型 Person.PhoneNumber,如你期待的那樣,是 Person 的嵌套類。

Builders和Messages

由protocol buffer編譯器生成的所有消息類都是不可變的。一旦某個消息對象構造完成 ,則它不能被修改,如同Java的 String 一樣。要構造一個消息,你必須首先構造一個builder,設置你想要設置的字段爲你選擇的值,然後調用builder的 build() 方法。

你可能已經注意到了builder的每個方法都修改消息並返回另一個builder。返回的對象實際上與調用方法的那個builder是同一個。它被返回以使你可以將多個setters串在一起放在單獨的一行代碼上。

這裏是如何創建你想要的 "Person" 實例一個例子:

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

標準的消息方法

每個消息和builder類還包含大量的其它方法,來讓你檢查或管理整個消息,包括:

  • isInitialized() : 檢查是否所有的required字段都已經被設置了。
  • toString() : 返回一個人類可讀的消息表示,對調試特別有用。
  • mergeFrom(Message other): (只有builder可用) 將 other 的內容合併到這個消息中,覆寫單數的字段,附接重複的。
  • clear(): (只有builder可用) 清空所有的元素爲空狀態。

這些方法實現由所有的Java消息和builders所共享的 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寫回文件。直接調用或引用由protocol編譯器生成的代碼的部分都被高亮了。

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();
  }
}

讀消息

當然,如果你不能從地址簿中獲取信息的話,那它就沒什麼用了。這個例子讀取上面例子創建的文件並打印它的所有信息。

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的定義。如果你想要你的新buffers向後兼容,你的老buffers向前兼容 —— 你當然幾乎總是想要這樣 —— 然後你有一些規則要遵守。在新版本的protocol buffer中:

  • 一定不能 修改任何已有字段的tag數字。
  • 一定不能 添加或刪除required字段。
  • 可以 刪除可選的或重複的字段。
  • 可以 添加可選或重複的字段,但你必須使用新的tag數字 (比如,從未在這個protocol buffer中使用過的tag數字,甚至是在刪除的字段中也是)。

(這些規則有 一些例外 ,但它們幾乎從未用到)

如果你按照這些規則,老代碼將開心地讀取新消息並簡單地忽略新字段。對於老代碼來說,刪除的可選字段將簡單的具有它們的默認值,刪除的重複字段將是空的。新代碼將透明地讀取老消息。然而,請記住新的可選字段將不會出現在老的消息中,因此你將需要通過has_顯式地檢查它們是否設置了,或通過 [default = value] 在你的 .proto 文件中的tag數字後面提供一個合理的默認值。如果沒有爲可選元素指定默認值,則會使用特定於類型的默認值代替:對於字符串,默認值是空字符串。對於booleans,默認值是false。對於數字類型,默認值是0。還要注意如果你添加了一個新的重複字段,你的新代碼將不能區別他是空的 (通過新代碼) 還是從來沒有設置 (通過老代碼) ,因爲它沒有 has_ 標記。

高級用法

Protocol buffers的使用場景不僅僅是簡單的存取器和序列化。務必瀏覽 Java API 參考 來了解你還可以用它做什麼。

由protocol消息類提供的一個重要功能是 反射 。你可以迭代一個消息的字段,並在不針對特定的消息類型編寫你的代碼的情況下,管理它們的值。使用反射的一個非常有用的方式是將protocol消息轉換爲其它編碼方式,或從其它編碼方式轉換,比如XML或JSON。反射的一個更高級的使用可能是查找相同類型的兩個消息之間的差異,或者開發某種"protocol消息正則表達式",你可以編寫表達式用它匹配某一消息內容。如果使用你想象力,則將Protocol Buffers用到比你最初期望的更加廣泛的問題的解決中是有可能的!

反射是作爲Message
Message.Builder
接口的一部分提供的。




轉載自:https://www.jianshu.com/p/1bf426a9f8f4
 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章