Protocol Buffer 基礎:Java

Java 程式設計師使用 protocol buffers 的基本介紹。

本教程為 Java 程式設計師提供了使用 protocol buffers 的基本介紹。透過建立一個簡單的示例應用程式,它將向您展示如何:

  • .proto 檔案中定義訊息格式。
  • 使用 Protocol Buffer 編譯器。
  • 使用 Java protocol buffer API 寫入和讀取訊息。

這不是一份關於在 Java 中使用 protocol buffers 的全面指南。有關更詳細的參考資訊,請參閱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,它將透過將檔名轉換為大駝峰命名法來生成。例如,“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:欄位可能設定也可能不設定。如果未設定可選欄位值,則使用預設值。對於簡單型別,您可以指定自己的預設值,就像我們在示例中為電話號碼 type 所做的那樣。否則,使用系統預設值:數字型別為零,字串為空字串,布林值為 false。對於嵌入式訊息,預設值始終是訊息的“預設例項”或“原型”,它沒有設定任何欄位。呼叫訪問器以獲取未顯式設定的可選(或必需)欄位的值始終返回該欄位的預設值。
  • repeated:欄位可以重複任意次數(包括零次)。重複值的順序將保留在協議緩衝區中。可以將重複欄位視為動態大小的陣列。
  • required:必須為欄位提供一個值,否則訊息將被視為“未初始化”。嘗試構建未初始化的訊息將丟擲 RuntimeException。解析未初始化的訊息將丟擲 IOException。除此之外,必需欄位的行為與可選欄位完全相同。

您將在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 類,您可以使用它來建立該類的例項。您可以在下面的構建器與訊息部分中瞭解更多關於構建器的資訊。

訊息和構建器都為訊息的每個欄位自動生成訪問器方法;訊息只有 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。每個單數字段也有 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 中的一個巢狀類。

構建器 vs. 訊息

由協議緩衝區編譯器生成的訊息類都是 不可變 的。一旦訊息物件被構造,就不能修改它,就像 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():檢查所有必需欄位是否已設定。
  • 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 中:

  • 您*絕不能*更改任何現有欄位的標籤號。
  • 你*不得*新增或刪除任何必需欄位。
  • 你*可以*刪除可選或重複欄位。
  • 您可以新增新的可選或重複欄位,但必須使用新的標籤號(即,在此協議緩衝區中從未使用的標籤號,即使是被刪除的欄位也一樣)。

(這些規則有一些例外,但很少使用。)

如果您遵循這些規則,舊程式碼將愉快地讀取新訊息並簡單地忽略任何新欄位。對於舊程式碼,已刪除的可選欄位將僅具有其預設值,已刪除的重複欄位將為空。新程式碼也將透明地讀取舊訊息。但是,請記住,新可選欄位不會出現在舊訊息中,因此您需要顯式檢查它們是否已設定(使用 has_),或者在您的 .proto 檔案中在標籤號後面提供一個合理的預設值(使用 [default = value])。如果未為可選元素指定預設值,則使用型別特定的預設值:對於字串,預設值為空字串。對於布林值,預設值為 false。對於數字型別,預設值為零。另請注意,如果您添加了一個新的重複欄位,您的新程式碼將無法區分它是被留空(由新程式碼)還是根本未設定(由舊程式碼),因為它沒有 has_ 標誌。

高階用法

Protocol buffers 的用途超出了簡單的訪問器和序列化。請務必瀏覽Java API 參考以瞭解它們還能做什麼。

協議訊息類提供的一個關鍵特性是 反射。您可以遍歷訊息的欄位並操作它們的值,而無需針對任何特定訊息型別編寫程式碼。使用反射的一個非常有用的方法是將協議訊息轉換為其他編碼(例如 XML 或 JSON),反之亦然。反射的更高階用法可能是查詢相同型別的兩個訊息之間的差異,或者開發一種“協議訊息的正則表示式”,您可以在其中編寫匹配某些訊息內容的表示式。如果您發揮想象力,可以將 Protocol Buffers 應用於比您最初預期更廣泛的問題!

反射作為 MessageMessage.Builder 介面的一部分提供。