Protocol Buffer 基礎:Dart

面向 Dart 程式設計師的 Protocol Buffer 入門指南。

本教程為 Dart 程式設計師提供了使用 Protocol Buffers 的基本介紹,使用的是 Protocol Buffers 語言的 proto3 版本。透過建立一個簡單的示例應用程式,它將向您展示如何:

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

這不是在 Dart 中使用 Protocol Buffers 的綜合指南。有關更詳細的參考資訊,請參閱 Protocol Buffer 語言指南Dart 語言導覽Dart API 參考Dart 生成程式碼指南編碼參考

問題領域

我們將要使用的示例是一個非常簡單的“地址簿”應用程式,它可以從檔案中讀取和寫入人們的聯絡方式。地址簿中的每個人都有姓名、ID、電子郵件地址和聯絡電話號碼。

你如何序列化和檢索這樣的結構化資料?有幾種方法可以解決這個問題:

  • 您可以發明一種特殊的方式將資料項編碼為單個字串——例如將 4 個整數編碼為“12:3:-23:67”。這是一種簡單而靈活的方法,但它需要編寫一次性的編碼和解析程式碼,並且解析會帶來一些執行時成本。這種方法最適合編碼非常簡單的資料。
  • 將資料序列化為 XML。這種方法可能非常有吸引力,因為 XML(某種程度上)是人類可讀的,而且有許多語言的繫結庫。如果您想與其他應用/專案共享資料,這可能是一個不錯的選擇。然而,XML 是出了名的佔用空間,並且編碼/解碼它可能會給應用帶來巨大的效能損失。此外,遍歷 XML DOM 樹比通常情況下遍歷類中的簡單欄位要複雜得多。

Protocol Buffer 正是為解決這一問題而設計的靈活、高效、自動化的解決方案。使用 Protocol Buffer,您需要編寫一個 .proto 檔案來描述您希望儲存的資料結構。Protocol Buffer 編譯器會根據該檔案建立一個類,該類使用高效的二進位制格式實現了對 Protocol Buffer 資料的自動編碼和解析。生成的類為構成 Protocol Buffer 的欄位提供了 getter 和 setter,並負責處理將 Protocol Buffer 作為一個單元進行讀寫的細節。重要的是,Protocol Buffer 格式支援隨著時間的推移擴充套件格式,使得程式碼仍然可以讀取用舊格式編碼的資料。

在哪裡找到示例程式碼

我們的示例是一組用於管理通訊錄資料檔案的命令列應用程式,該檔案使用 protocol buffers 進行編碼。命令 dart add_person.dart 會向資料檔案新增一個新條目。命令 dart list_people.dart 解析資料檔案並將資料列印到控制檯。

您可以在 GitHub 倉庫的 examples 目錄中找到完整的示例。

定義你的協議格式

要建立您的地址簿應用,您需要從一個 .proto 檔案開始。.proto 檔案中的定義很簡單:為您想要序列化的每個資料結構新增一個訊息(message),然後為訊息中的每個欄位指定名稱和型別。在我們的示例中,定義訊息的 .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" 標記標識了該欄位在二進位制編碼中使用的唯一“標籤”(tag)。標籤號 1-15 比較大的數字少用一個位元組來編碼,因此作為一種最佳化,您可以決定將這些標籤用於常用或重複的元素,而將標籤 16 及以上的數字留給不常用的可選元素。重複欄位中的每個元素都需要重新編碼標籤號,因此重複欄位特別適合進行這種最佳化。

如果某個欄位未設定值,則會使用預設值:數字型別為零,字串為空字串,布林值為 false。對於嵌入的訊息,預設值始終是該訊息的“預設例項”或“原型”,其所有欄位都未設定。呼叫訪問器獲取未顯式設定的欄位值時,總是返回該欄位的預設值。

如果一個欄位是 repeated,該欄位可以重複任意次數(包括零次)。重複值的順序將在協議緩衝區中保留。可以把重複欄位看作是動態大小的陣列。

您可以在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 會為您提供以下有用的型別:

  • 一個 AddressBook 類,其中包含一個 List<Person> get people getter。
  • 一個 Person 類,其中包含 nameidemailphones 的訪問器方法。
  • 一個 Person_PhoneNumber 類,其中包含 numbertype 的訪問器方法。
  • 一個 Person_PhoneType 類,其中包含 Person.PhoneType 列舉中每個值的靜態欄位。

您可以在 Dart 生成程式碼指南 中閱讀有關具體生成內容的更多詳細資訊。

寫入訊息

現在讓我們嘗試使用您的 Protocol Buffer 類。您的地址簿應用程式首先需要能夠將個人詳細資訊寫入您的地址簿檔案。為此,您需要建立並填充您的 Protocol Buffer 類的例項,然後將它們寫入輸出流。

這是一個程式,它從檔案中讀取一個 AddressBook,根據使用者輸入向其中新增一個新 Person,然後將新的 AddressBook 再次寫回檔案。直接呼叫或引用由協議編譯器生成的程式碼的部分已高亮顯示。

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

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

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

如果您遵守這些規則,舊程式碼將能夠愉快地讀取新訊息,並簡單地忽略任何新欄位。對於舊程式碼來說,被刪除的單數(singular)欄位將只有其預設值,而被刪除的重複(repeated)欄位將為空。新程式碼也將透明地讀取舊訊息。

但是,請記住,新欄位在舊訊息中是不存在的,因此您需要對預設值進行合理的處理。系統會使用特定型別的預設值:對於字串,預設值是空字串。對於布林值,預設值是 false。對於數字型別,預設值是零。