Protocol Buffer 基礎知識:Dart

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

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

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

這並非 Dart 中使用 Protocol Buffer 的完整指南。如需更詳細的參考資訊,請參閱Protocol Buffer 語言指南Dart 語言導覽Dart API 參考文件Dart 產生程式碼指南,以及編碼參考文件

問題領域

我們將使用的範例是一個非常簡單的「通訊錄」應用程式,可以將人員的聯絡資訊讀取和寫入檔案。通訊錄中的每個人都有姓名、ID、電子郵件地址和聯絡電話號碼。

您要如何序列化和擷取這類結構化資料?有幾種方法可以解決這個問題:

  • 您可以發明一種臨時方法,將資料項目編碼成單一字串,例如將 4 個整數編碼為「12:3:-23:67」。這是一種簡單而彈性的方法,但確實需要編寫一次性的編碼和剖析程式碼,而且剖析會產生少許執行階段成本。這種方法最適合用於編碼非常簡單的資料。
  • 將資料序列化為 XML。這種方法可能非常吸引人,因為 XML (在某種程度上) 是人類可讀的,而且有許多語言的繫結程式庫。如果您想要與其他應用程式/專案共用資料,這可能是一個不錯的選擇。但是,XML 以佔用大量空間而聞名,而且編碼/解碼 XML 會對應用程式造成巨大的效能損失。此外,導覽 XML DOM 樹狀結構比導覽類別中的簡單欄位通常要複雜得多。

Protocol Buffer 是彈性、效率高且自動化的解決方案,可精確地解決這個問題。使用 Protocol Buffer,您可以編寫要儲存的資料結構的 .proto 描述。Protocol Buffer 編譯器會從該描述建立一個類別,該類別會以有效率的二進位格式實作 Protocol Buffer 資料的自動編碼和剖析。產生的類別會為構成 Protocol Buffer 的欄位提供 Getter 和 Setter,並處理讀取和寫入 Protocol Buffer 作為單位的詳細資訊。重要的是,Protocol Buffer 格式支援隨著時間推移擴充格式的概念,以便程式碼仍然可以讀取以舊格式編碼的資料。

範例程式碼的位置

我們的範例是一組命令列應用程式,用於管理使用 Protocol Buffer 編碼的通訊錄資料檔案。命令 dart add_person.dart 會將新條目新增至資料檔案。命令 dart list_people.dart 會剖析資料檔案,並將資料列印到主控台。

您可以在 GitHub 存放庫的 examples 目錄中找到完整的範例。

定義您的協定格式

若要建立您的通訊錄應用程式,您需要從 .proto 檔案開始。.proto 檔案中的定義很簡單:您為要序列化的每個資料結構新增一個訊息,然後為訊息中的每個欄位指定名稱和類型。在我們的範例中,定義訊息的 .proto 檔案是 addressbook.proto

.proto 檔案以套件宣告開頭,這有助於防止不同專案之間的命名衝突。

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

接下來,您會有訊息定義。訊息只是一個包含一組類型化欄位的彙總。許多標準的簡單資料類型可用作欄位類型,包括 boolint32floatdoublestring。您也可以使用其他訊息類型作為欄位類型,為您的訊息新增更多結構。

message Person {
  string name = 1;
  int32 id = 2;  // Unique ID number for this person.
  string email = 3;

  enum PhoneType {
    PHONE_TYPE_UNSPECIFIED = 0;
    PHONE_TYPE_MOBILE = 1;
    PHONE_TYPE_HOME = 2;
    PHONE_TYPE_WORK = 3;
  }

  message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
  }

  repeated PhoneNumber phones = 4;

  google.protobuf.Timestamp last_updated = 5;
}

// Our address book file is just one of these.
message AddressBook {
  repeated Person people = 1;
}

在上述範例中,Person 訊息包含 PhoneNumber 訊息,而 AddressBook 訊息包含 Person 訊息。您甚至可以在其他訊息內定義巢狀訊息類型 — 如您所見,PhoneNumber 類型是在 Person 內部定義的。如果您希望您的欄位之一具有預先定義的值清單之一,您也可以定義 enum 類型 — 在此處,您想要指定電話號碼可以是 PHONE_TYPE_MOBILEPHONE_TYPE_HOMEPHONE_TYPE_WORK 之一。

每個元素上的「 = 1」、「 = 2」標記識別了該欄位在二進位編碼中使用的唯一「標籤」。標籤號碼 1-15 比更高的號碼需要更少的位元組來編碼,因此作為一種最佳化,您可以決定將這些標籤用於常用的或重複的元素,而將標籤 16 及更高號碼留給較少使用的選用元素。重複欄位中的每個元素都需要重新編碼標籤號碼,因此重複欄位特別適合這種最佳化。

如果未設定欄位值,則會使用預設值:數值類型為零,字串為空字串,布林值為 false。對於嵌入式訊息,預設值始終是訊息的「預設實例」或「原型」,其中沒有設定任何欄位。呼叫存取器以取得尚未明確設定的欄位的值始終會傳回該欄位的預設值。

如果欄位是 repeated,則該欄位可能會重複任意次數 (包括零次)。重複值的順序將在 Protocol Buffer 中保留。將重複欄位視為動態調整大小的陣列。

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

編譯您的 Protocol Buffers

現在您有了 .proto,接下來您需要做的是產生讀取和寫入 AddressBook (以及 PersonPhoneNumber) 訊息所需的類別。若要執行此操作,您需要在您的 .proto 上執行 Protocol Buffer 編譯器 protoc

  1. 如果您尚未安裝編譯器,請下載套件並按照 README 中的指示進行操作。

  2. 依照 其 README 中的說明安裝 Dart Protocol Buffer 外掛程式。可執行檔 bin/protoc-gen-dart 必須在您的 PATH 中,Protocol Buffer protoc 才能找到它。

  3. 現在執行編譯器,指定來源目錄 (您的應用程式原始碼所在的目錄 — 如果您未提供值,則使用目前目錄)、目的地目錄 (您要將產生的程式碼放入的位置;通常與 $SRC_DIR 相同) 以及您的 .proto 的路徑。在這種情況下,您將調用

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

    因為您想要 Dart 程式碼,所以您使用 --dart_out 選項 — 為其他支援的語言提供了類似的選項。

這會在您指定的目的地目錄中產生 addressbook.pb.dart

Protocol Buffer API

產生 addressbook.pb.dart 會為您提供以下有用的類型:

  • 具有 List<Person> get people Getter 的 AddressBook 類別。
  • 具有 nameidemailphones 的存取器方法的 Person 類別。
  • 具有 numbertype 的存取器方法的 Person_PhoneNumber 類別。
  • 具有 Person.PhoneType 列舉中每個值的靜態欄位的 Person_PhoneType 類別。

您可以在Dart 產生程式碼指南中閱讀更多關於確切產生的詳細資訊。

寫入訊息

現在讓我們嘗試使用您的 Protocol Buffer 類別。您希望您的通訊錄應用程式能夠做的第一件事是將個人詳細資訊寫入您的通訊錄檔案。若要執行此操作,您需要建立和填入 Protocol Buffer 類別的實例,然後將它們寫入輸出串流。

以下程式會從檔案讀取 AddressBook、根據使用者輸入向其中新增一個新的 Person,然後將新的 AddressBook 寫回檔案。直接呼叫或參考 Protocol 編譯器產生的程式碼的部分會以醒目提示。

import 'dart:io';

import 'dart_tutorial/addressbook.pb.dart';

// This function fills in a Person message based on user input.
Person promptForAddress() {
  Person person = Person();

  print('Enter person ID: ');
  String input = stdin.readLineSync();
  person.id = int.parse(input);

  print('Enter name');
  person.name = stdin.readLineSync();

  print('Enter email address (blank for none) : ');
  String email = stdin.readLineSync();
  if (email.isNotEmpty) {
    person.email = email;
  }

  while (true) {
    print('Enter a phone number (or leave blank to finish): ');
    String number = stdin.readLineSync();
    if (number.isEmpty) break;

    Person_PhoneNumber phoneNumber = Person_PhoneNumber();

    phoneNumber.number = number;
    print('Is this a mobile, home, or work phone? ');

    String type = stdin.readLineSync();
    switch (type) {
      case 'mobile':
        phoneNumber.type = Person_PhoneType.PHONE_TYPE_MOBILE;
        break;
      case 'home':
        phoneNumber.type = Person_PhoneType.PHONE_TYPE_HOME;
        break;
      case 'work':
        phoneNumber.type = Person_PhoneType.PHONE_TYPE_WORK;
        break;
      default:
        print('Unknown phone type.  Using default.');
    }
    person.phones.add(phoneNumber);
  }

  return person;
}

// Reads the entire address book from a file, adds one person based
// on user input, then writes it back out to the same file.
main(List arguments) {
  if (arguments.length != 1) {
    print('Usage: add_person ADDRESS_BOOK_FILE');
    exit(-1);
  }

  File file = File(arguments.first);
  AddressBook addressBook;
  if (!file.existsSync()) {
    print('File not found. Creating new file.');
    addressBook = AddressBook();
  } else {
    addressBook = AddressBook.fromBuffer(file.readAsBytesSync());
  }
  addressBook.people.add(promptForAddress());
  file.writeAsBytes(addressBook.writeToBuffer());
}

讀取訊息

當然,如果無法從通訊錄中取得任何資訊,通訊錄就沒有多大用處!此範例會讀取上述範例建立的檔案,並列印其中的所有資訊。

import 'dart:io';

import 'dart_tutorial/addressbook.pb.dart';
import 'dart_tutorial/addressbook.pbenum.dart';

// Iterates though all people in the AddressBook and prints info about them.
void printAddressBook(AddressBook addressBook) {
  for (Person person in addressBook.people) {
    print('Person ID: ${ person.id}');
    print('  Name: ${ person.name}');
    if (person.hasEmail()) {
      print('  E-mail address:${ person.email}');
    }

    for (Person_PhoneNumber phoneNumber in person.phones) {
      switch (phoneNumber.type) {
        case Person_PhoneType.PHONE_TYPE_MOBILE:
          print('   Mobile phone #: ');
          break;
        case Person_PhoneType.PHONE_TYPE_HOME:
          print('   Home phone #: ');
          break;
        case Person_PhoneType.PHONE_TYPE_WORK:
          print('   Work phone #: ');
          break;
        default:
          print('   Unknown phone #: ');
          break;
      }
      print(phoneNumber.number);
    }
  }
}

// Reads the entire address book from a file and prints all
// the information inside.
main(List arguments) {
  if (arguments.length != 1) {
    print('Usage: list_person ADDRESS_BOOK_FILE');
    exit(-1);
  }

  // Read the existing address book.
  File file = new File(arguments.first);
 AddressBook addressBook = new AddressBook.fromBuffer(file.readAsBytesSync());
  printAddressBook(addressBook);
}

擴充 Protocol Buffer

在您發佈使用您的 Protocol Buffer 的程式碼之後,遲早您會想要「改進」Protocol Buffer 的定義。如果您希望您的新緩衝區向後相容,並且您的舊緩衝區向前相容 — 而且您幾乎肯定希望如此 — 那麼您需要遵循一些規則。在新版本的 Protocol Buffer 中:

  • 絕對不能變更任何現有欄位的標籤號碼。
  • 可以刪除欄位。
  • 可以新增欄位,但您必須使用新的標籤號碼 (即從未在此 Protocol Buffer 中使用過的標籤號碼,即使是已刪除的欄位也不行)。

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

如果您遵循這些規則,舊程式碼將會很高興地讀取新訊息,並且只會忽略任何新欄位。對於舊程式碼,已刪除的單數欄位只會具有其預設值,而已刪除的重複欄位將會是空的。新程式碼也會透明地讀取舊訊息。

但是,請記住,新欄位不會出現在舊訊息中,因此您需要對預設值執行一些合理的處理。會使用類型特定的預設值:對於字串,預設值為空字串。對於布林值,預設值為 false。對於數值類型,預設值為零。