Protocol Buffer 基礎知識:Java

Java 程式設計人員使用 Protocol Buffer 的基本簡介。

本教學課程為 Java 程式設計人員提供 Protocol Buffer 的基本簡介。透過逐步建立簡單的範例應用程式,向您展示如何:

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

這並非 Java 中使用 Protocol Buffer 的完整指南。如需更詳細的參考資訊,請參閱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 檔案中的定義很簡單:您為要序列化的每個資料結構新增一個訊息,然後為訊息中的每個欄位指定名稱和類型。以下是定義訊息的 .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 套件,除非您已明確指定 java_package,就像我們在這裡所做的那樣。即使您提供了 java_package,您仍然應該定義一個普通的 package,以避免 Protocol Buffer 名稱空間以及非 Java 語言中的名稱衝突。

在套件宣告之後,您可以看到三個 Java 特定的選項:java_multiple_filesjava_packagejava_outer_classnamejava_package 指定產生的類別應位於哪個 Java 套件名稱中。如果您未明確指定此選項,它只會比對 package 宣告給定的套件名稱,但這些名稱通常不適合 Java 套件名稱 (因為它們通常不是以網域名稱開頭)。java_outer_classname 選項定義將代表此檔案的包裝函式類別的類別名稱。如果您未明確給定 java_outer_classname,它將透過將檔案名稱轉換為 UpperCamelCase 來產生。例如,「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」標記識別二進位編碼中該欄位使用的唯一「標籤」。標籤號碼 1-15 比更高的號碼需要少一個位元組來編碼,因此作為最佳化,您可以決定將這些標籤用於常用或重複的元素,而將標籤 16 及更高的標籤留給較不常用的選用元素。重複欄位中的每個元素都需要重新編碼標籤號碼,因此重複欄位特別適合此最佳化。

每個欄位都必須使用以下修飾詞之一註解:

  • optional:欄位可以設定或不設定。如果未設定選用欄位值,則會使用預設值。對於簡單類型,您可以指定自己的預設值,就像我們在範例中為電話號碼 type 所做的那樣。否則,將使用系統預設值:數值類型為零,字串為空字串,布林值為 false。對於內嵌訊息,預設值始終是訊息的「預設執行個體」或「原型」,它沒有設定任何欄位。呼叫存取子以取得未明確設定的選用 (或必要) 欄位的值始終會傳回該欄位的預設值。
  • repeated:欄位可以重複任意次數 (包括零次)。重複值的順序將保留在 Protocol Buffer 中。將重複欄位視為動態大小的陣列。
  • required:必須提供欄位的值,否則訊息將被視為「未初始化」。嘗試建構未初始化的訊息將會擲回 RuntimeException。剖析未初始化的訊息將會擲回 IOException。除此之外,必要欄位的行為與選用欄位完全相同。

您可以在Protocol Buffer 語言指南中找到編寫 .proto 檔案的完整指南 - 包括所有可能的欄位類型。不過,不要尋找類似於類別繼承的功能 - Protocol Buffer 不會執行該操作。

編譯您的 Protocol Buffer

現在您有了 .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/,您可以看到它包含 .java 檔案,為您在 addressbook.proto 中指定的每個訊息定義一個類別。每個類別都有自己的 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 樣式的更多資訊。

如需 Protocol 編譯器為任何特定欄位定義產生的成員的確切資訊,請參閱Java 產生程式碼參考文件

列舉和巢狀類別

產生的程式碼包含一個 PhoneType Java 5 列舉,巢狀在 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 內的巢狀類別。

建構工具與訊息

Protocol Buffer 編譯器產生的訊息類別都是不可變的。一旦建構訊息物件,就無法修改它,就像 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 寫回檔案。直接呼叫或參考 Protocol 編譯器產生程式碼的部分會以醒目提示。

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 中:

  • 絕不能變更任何現有欄位的標籤號碼。
  • 絕不能新增或刪除任何必要欄位。
  • 可以刪除選用或重複欄位。
  • 可以新增新的選用或重複欄位,但您必須使用新的標籤號碼 (也就是說,從未在此 Protocol Buffer 中使用過的標籤號碼,即使是被刪除的欄位也是如此)。

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

如果您遵循這些規則,舊版程式碼將會很高興地讀取新訊息,並且只會忽略任何新欄位。對於舊版程式碼,已刪除的選用欄位只會具有其預設值,而已刪除的重複欄位將為空。新程式碼也會透明地讀取舊訊息。但是,請記住,新選用欄位不會出現在舊訊息中,因此您需要明確檢查它們是否已使用 has_ 設定,或者在您的 .proto 檔案中使用 [default = value] 在標籤號碼之後提供合理的預設值。如果未為選用元素指定預設值,則會改為使用類型特定的預設值:對於字串,預設值為空字串。對於布林值,預設值為 false。對於數值類型,預設值為零。另請注意,如果您新增了一個新的重複欄位,您的新程式碼將無法判斷它是被新程式碼留空 (由新程式碼) 還是從未設定過 (由舊程式碼),因為它沒有 has_ 旗標。

進階用法

Protocol Buffer 的用途不僅僅是簡單的存取子和序列化。請務必探索Java API 參考文件,以了解您還可以使用它們做什麼。

Protocol 訊息類別提供的一個主要功能是反射。您可以迭代訊息的欄位並操作它們的值,而無需針對任何特定訊息類型編寫程式碼。使用反射的一個非常有用的方法是將 Protocol 訊息與其他編碼 (例如 XML 或 JSON) 之間進行轉換。反射的更進階用途可能是尋找相同類型的兩個訊息之間的差異,或開發一種「Protocol 訊息的正規表示式」,您可以在其中編寫與特定訊息內容比對的運算式。如果您發揮您的想像力,Protocol Buffer 可以應用於比您最初預期的更廣泛的問題!

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