Protocol Buffer 基礎:C++

C++ 程式設計人員使用 Protocol Buffer 的基本入門介紹。

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

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

這不是 C++ 中使用 Protocol Buffer 的完整指南。如需更詳細的參考資訊,請參閱Protocol Buffer 語言指南 (proto2)Protocol Buffer 語言指南 (proto3)C++ API 參考C++ 產生程式碼指南編碼參考

問題領域

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

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

  • 原始的記憶體內資料結構可以二進位形式傳送/儲存。隨著時間的推移,這是一種脆弱的方法,因為接收/讀取程式碼必須使用完全相同的記憶體配置、位元組順序等進行編譯。此外,隨著檔案以原始格式累積資料,並且為該格式設計的軟體副本散佈開來,擴充格式變得非常困難。
  • 您可以發明一種特別的方法將資料項目編碼為單一字串,例如將 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;

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 檔案以套件宣告開頭,這有助於防止不同專案之間的命名衝突。在 C++ 中,您產生的類別將放置在與套件名稱相符的命名空間中。

接下來,您有訊息定義。訊息只是包含一組類型化欄位的聚合。許多標準簡單資料類型可用作欄位類型,包括 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:必須提供欄位的值,否則訊息將被視為「未初始化」。如果在偵錯模式下編譯 libprotobuf,則序列化未初始化的訊息將導致判斷提示失敗。在最佳化組建中,會跳過檢查,並且仍會寫入訊息。但是,剖析未初始化的訊息始終會失敗(透過從剖析方法傳回 false)。除此之外,必要欄位的行為與選用欄位完全相同。

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

編譯您的 Protocol Buffers

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

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

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

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

    因為您想要 C++ 類別,所以您使用 --cpp_out 選項 – 為其他支援的語言提供類似的選項。

這會在您指定的目的地目錄中產生以下檔案:

  • addressbook.pb.h,宣告您產生的類別的標頭。
  • addressbook.pb.cc,其中包含您的類別的實作。

Protocol Buffer API

讓我們看看一些產生的程式碼,看看編譯器為您建立了哪些類別和函式。如果您查看 addressbook.pb.h,您可以看到您為您在 addressbook.proto 中指定的每個訊息都有一個類別。更仔細地查看 Person 類別,您可以看到編譯器已為每個欄位產生了存取器。例如,對於 nameidemailphones 欄位,您有以下方法:

  // name
  inline bool has_name() const;
  inline void clear_name();
  inline const ::std::string& name() const;
  inline void set_name(const ::std::string& value);
  inline void set_name(const char* value);
  inline ::std::string* mutable_name();

  // id
  inline bool has_id() const;
  inline void clear_id();
  inline int32_t id() const;
  inline void set_id(int32_t value);

  // email
  inline bool has_email() const;
  inline void clear_email();
  inline const ::std::string& email() const;
  inline void set_email(const ::std::string& value);
  inline void set_email(const char* value);
  inline ::std::string* mutable_email();

  // phones
  inline int phones_size() const;
  inline void clear_phones();
  inline const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phones() const;
  inline ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phones();
  inline const ::tutorial::Person_PhoneNumber& phones(int index) const;
  inline ::tutorial::Person_PhoneNumber* mutable_phones(int index);
  inline ::tutorial::Person_PhoneNumber* add_phones();

如您所見,getter 的名稱與小寫的欄位完全相同,而 setter 方法以 set_ 開頭。每個單數(必要或選用)欄位也有 has_ 方法,如果已設定該欄位,則傳回 true。最後,每個欄位都有一個 clear_ 方法,將欄位取消設定回其空狀態。

雖然數值 id 欄位只有上面描述的基本存取器集,但 nameemail 欄位有幾個額外的方法,因為它們是字串 – 一個 mutable_ getter,可讓您取得字串的直接指標,以及一個額外的 setter。請注意,即使尚未設定 email,您也可以呼叫 mutable_email();它將自動初始化為空字串。如果您在此範例中有一個重複訊息欄位,它也將有一個 mutable_ 方法,但沒有 set_ 方法。

重複欄位也有一些特殊方法 – 如果您查看重複的 phones 欄位的方法,您會看到您可以:

  • 檢查重複欄位的 _size(換句話說,與此 Person 關聯的電話號碼有多少個)。
  • 使用其索引取得指定的電話號碼。
  • 更新指定索引處的現有電話號碼。
  • 將另一個電話號碼新增至訊息,然後您可以編輯該號碼(重複的純量類型有一個 add_,它只允許您傳入新值)。

如需有關 Protocol 編譯器為任何特定欄位定義產生哪些成員的更多資訊,請參閱C++ 產生程式碼參考

列舉與巢狀類別

產生的程式碼包含一個 PhoneType 列舉,對應於您的 .proto 列舉。您可以將此類型稱為 Person::PhoneType,並將其值稱為 Person::PHONE_TYPE_MOBILEPerson::PHONE_TYPE_HOMEPerson::PHONE_TYPE_WORK(實作細節有點複雜,但您不需要了解它們即可使用列舉)。

編譯器也為您產生了一個名為 Person::PhoneNumber 的巢狀類別。如果您查看程式碼,您可以看到「真實」類別實際上名為 Person_PhoneNumber,但 Person 內定義的 typedef 允許您將其視為巢狀類別。唯一讓這產生差異的情況是您想在另一個檔案中向前宣告類別 – 您無法在 C++ 中向前宣告巢狀類型,但您可以向前宣告 Person_PhoneNumber

標準訊息方法

每個訊息類別還包含許多其他方法,可讓您檢查或操作整個訊息,包括:

  • bool IsInitialized() const;:檢查是否已設定所有必要欄位。
  • string DebugString() const;:傳回訊息的人類可讀表示法,特別適用於偵錯。
  • void CopyFrom(const Person& from);:使用給定訊息的值覆寫訊息。
  • void Clear();:將所有元素清除回空狀態。

以下各節中描述的這些方法和 I/O 方法實作了所有 C++ Protocol Buffer 類別共用的 Message 介面。如需更多資訊,請參閱Message 的完整 API 文件

剖析與序列化

最後,每個 Protocol Buffer 類別都有方法,可以使用您選擇的類型的 Protocol Buffer 二進位格式寫入和讀取訊息。這些包括:

  • bool SerializeToString(string* output) const;:序列化訊息並將位元組儲存在給定的字串中。請注意,位元組是二進位的,而不是文字;我們僅使用 string 類別作為方便的容器。
  • bool ParseFromString(const string& data);:從給定的字串剖析訊息。
  • bool SerializeToOstream(ostream* output) const;:將訊息寫入給定的 C++ ostream
  • bool ParseFromIstream(istream* input);:從給定的 C++ istream 剖析訊息。

這些只是為剖析和序列化提供的幾個選項。再次參閱Message API 參考以取得完整清單。

寫入訊息

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

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

#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;

// This function fills in a Person message based on user input.
void PromptForAddress(tutorial::Person* person) {
  cout << "Enter person ID number: ";
  int id;
  cin >> id;
  person->set_id(id);
  cin.ignore(256, '\n');

  cout << "Enter name: ";
  getline(cin, *person->mutable_name());

  cout << "Enter email address (blank for none): ";
  string email;
  getline(cin, email);
  if (!email.empty()) {
    person->set_email(email);
  }

  while (true) {
    cout << "Enter a phone number (or leave blank to finish): ";
    string number;
    getline(cin, number);
    if (number.empty()) {
      break;
    }

    tutorial::Person::PhoneNumber* phone_number = person->add_phones();
    phone_number->set_number(number);

    cout << "Is this a mobile, home, or work phone? ";
    string type;
    getline(cin, type);
    if (type == "mobile") {
      phone_number->set_type(tutorial::Person::PHONE_TYPE_MOBILE);
    } else if (type == "home") {
      phone_number->set_type(tutorial::Person::PHONE_TYPE_HOME);
    } else if (type == "work") {
      phone_number->set_type(tutorial::Person::PHONE_TYPE_WORK);
    } else {
      cout << "Unknown phone type.  Using default." << endl;
    }
  }
}

// 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.
int main(int argc, char* argv[]) {
  // Verify that the version of the library that we linked against is
  // compatible with the version of the headers we compiled against.
  GOOGLE_PROTOBUF_VERIFY_VERSION;

  if (argc != 2) {
    cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
    return -1;
  }

  tutorial::AddressBook address_book;

  {
    // Read the existing address book.
    fstream input(argv[1], ios::in | ios::binary);
    if (!input) {
      cout << argv[1] << ": File not found.  Creating a new file." << endl;
    } else if (!address_book.ParseFromIstream(&input)) {
      cerr << "Failed to parse address book." << endl;
      return -1;
    }
  }

  // Add an address.
  PromptForAddress(address_book.add_people());

  {
    // Write the new address book back to disk.
    fstream output(argv[1], ios::out | ios::trunc | ios::binary);
    if (!address_book.SerializeToOstream(&output)) {
      cerr << "Failed to write address book." << endl;
      return -1;
    }
  }

  // Optional:  Delete all global objects allocated by libprotobuf.
  google::protobuf::ShutdownProtobufLibrary();

  return 0;
}

請注意 GOOGLE_PROTOBUF_VERIFY_VERSION 巨集。在使用 C++ Protocol Buffer 程式庫之前執行此巨集是一種良好的實務 – 雖然不是絕對必要的。它驗證您沒有意外連結到與您編譯時使用的標頭版本不相容的程式庫版本。如果偵測到版本不符,程式將中止。請注意,每個 .pb.cc 檔案都會在啟動時自動叫用此巨集。

另請注意程式結尾對 ShutdownProtobufLibrary() 的呼叫。它所做的只是刪除 Protocol Buffer 程式庫配置的任何全域物件。對於大多數程式來說,這是沒有必要的,因為程序無論如何都會退出,並且作業系統將負責回收其所有記憶體。但是,如果您使用需要釋放每個最後一個物件的記憶體洩漏檢查器,或者如果您正在編寫一個可能會被單一程序多次載入和卸載的程式庫,那麼您可能希望強制 Protocol Buffer 清理所有內容。

讀取訊息

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

#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;

// Iterates though all people in the AddressBook and prints info about them.
void ListPeople(const tutorial::AddressBook& address_book) {
  for (int i = 0; i < address_book.people_size(); i++) {
    const tutorial::Person& person = address_book.people(i);

    cout << "Person ID: " << person.id() << endl;
    cout << "  Name: " << person.name() << endl;
    if (person.has_email()) {
      cout << "  E-mail address: " << person.email() << endl;
    }

    for (int j = 0; j < person.phones_size(); j++) {
      const tutorial::Person::PhoneNumber& phone_number = person.phones(j);

      switch (phone_number.type()) {
        case tutorial::Person::PHONE_TYPE_MOBILE:
          cout << "  Mobile phone #: ";
          break;
        case tutorial::Person::PHONE_TYPE_HOME:
          cout << "  Home phone #: ";
          break;
        case tutorial::Person::PHONE_TYPE_WORK:
          cout << "  Work phone #: ";
          break;
      }
      cout << phone_number.number() << endl;
    }
  }
}

// Main function:  Reads the entire address book from a file and prints all
//   the information inside.
int main(int argc, char* argv[]) {
  // Verify that the version of the library that we linked against is
  // compatible with the version of the headers we compiled against.
  GOOGLE_PROTOBUF_VERIFY_VERSION;

  if (argc != 2) {
    cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
    return -1;
  }

  tutorial::AddressBook address_book;

  {
    // Read the existing address book.
    fstream input(argv[1], ios::in | ios::binary);
    if (!address_book.ParseFromIstream(&input)) {
      cerr << "Failed to parse address book." << endl;
      return -1;
    }
  }

  ListPeople(address_book);

  // Optional:  Delete all global objects allocated by libprotobuf.
  google::protobuf::ShutdownProtobufLibrary();

  return 0;
}

擴充 Protocol Buffer

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

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

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

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

最佳化提示

C++ Protocol Buffer 程式庫經過極其大量的最佳化。但是,正確的使用方法可以進一步提高效能。以下是一些提示,可讓您從程式庫中榨取每一滴速度:

  • 盡可能重複使用訊息物件。訊息會嘗試保留它們為重複使用而配置的任何記憶體,即使在清除後也是如此。因此,如果您連續處理許多具有相同類型和相似結構的訊息,則最好每次重複使用相同的訊息物件,以減輕記憶體配置器的負擔。但是,物件可能會隨著時間的推移而變得臃腫,尤其是當您的訊息在「形狀」上有所不同,或者您偶爾建構一個比平常大得多的訊息時。您應該透過呼叫 SpaceUsed 方法來監控訊息物件的大小,並在它們變得太大時刪除它們。
  • 您的系統的記憶體配置器可能未針對從多個執行緒配置大量小型物件進行最佳化。請嘗試改用 Google 的 TCMalloc

進階用法

Protocol Buffer 的用途不僅僅是簡單的存取器和序列化。請務必瀏覽C++ API 參考,以了解您還可以對它們執行哪些操作。

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

反射由Message::Reflection 介面提供。