Protocol Buffer 基礎知識:C++

為C++程式設計師提供的Protocol Buffers基礎介紹。

本教程為C++程式設計師提供了Protocol Buffers的基礎介紹。透過一個簡單的示例應用程式,它將向您展示如何:

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

這不是C++中使用Protocol Buffers的全面指南。有關更詳細的參考資訊,請參閱Protocol Buffer 語言指南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 檔案中的定義很簡單:為您想要序列化的每個資料結構新增一個 message,然後為訊息中的每個欄位指定名稱和型別。這是定義您的訊息的 .proto 檔案,addressbook.proto

edition = "2023";

package tutorial;

message Person {
  string name = 1;
  int32 id = 2;
  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;
}

message AddressBook {
  repeated Person people = 1;
}

正如你所見,語法類似於 C++ 或 Java。讓我們逐一檢視檔案的每個部分,看看它們的作用。

.proto檔案以edition宣告開頭。Editions取代了舊的syntax = "proto2"syntax = "proto3"宣告,並提供了一種更靈活的方式來隨時間演進語言。

接下來是包宣告,它有助於防止不同專案之間的命名衝突。在C++中,您生成的類將被放置在與包名匹配的名稱空間中。

包宣告之後是您的訊息定義。訊息只是一個包含一組型別化欄位的聚合。許多標準的簡單資料型別可用作欄位型別,包括boolint32floatdoublestring。您還可以透過使用其他訊息型別作為欄位型別來為訊息新增進一步的結構——在上面的示例中,Person訊息包含PhoneNumber訊息,而AddressBook訊息包含Person訊息。您甚至可以定義巢狀在其他訊息中的訊息型別——如您所見,PhoneNumber型別定義在Person內部。如果您希望某個欄位具有預定義值列表中的一個,您還可以定義列舉型別——在這裡,您希望指定電話號碼可以是多種型別之一。

每個元素上的“= 1”、“= 2”標記標識該欄位在二進位制編碼中使用的唯一欄位編號。欄位編號1-15比更高的編號編碼時少佔用一個位元組,因此作為最佳化,您可以決定將這些編號用於常用或重複的元素,將欄位編號16及更高用於不常用的元素。

欄位可以是以下之一:

  • 單一:預設情況下,欄位是可選的,這意味著該欄位可能設定,也可能未設定。如果單一欄位未設定,則使用型別特定的預設值:數字型別為零,字串為空字串,布林值為false,列舉為第一個定義的列舉值(必須為0)。請注意,您不能將欄位明確設定為singular。這是對非重複欄位的描述。

  • repeated:該欄位可以重複任意次數(包括零次)。重複值的順序將保持不變。將重複欄位視為動態大小的陣列。

在舊版本的protobuf中,存在一個required關鍵字,但它已被證明是脆弱的,並且在現代protobuf中不受支援(儘管版本確實有一個功能可以啟用它,用於向後相容)。

您可以在Protocol Buffer 語言指南中找到編寫.proto檔案的完整指南,包括所有可能的欄位型別。不過,不要尋找類似於類繼承的設施——Protocol Buffers不支援這種功能。

編譯你的 Protocol Buffers

現在您有了一個 .proto 檔案,接下來需要做的就是生成讀寫 AddressBook(以及 PersonPhoneNumber)訊息所需的類。為此,您需要在您的 .proto 檔案上執行 Protocol Buffer 編譯器 protoc

  1. 如果您尚未安裝編譯器,請按照Protocol Buffer 編譯器安裝中的說明進行操作。

  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
  bool has_name() const; // Only for explicit presence
  void clear_name();
  const ::std::string& name() const;
  void set_name(const ::std::string& value);
  ::std::string* mutable_name();

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

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

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

如您所見,getter 的名稱與欄位的小寫名稱完全相同,setter 方法以 `set_` 開頭。對於具有顯式存在跟蹤的單一欄位,還有 `has_` 方法,如果該欄位已設定,則返回 true。最後,每個欄位都有一個 `clear_` 方法,將該欄位取消設定回其預設狀態。

雖然數字型 `id` 欄位只有上面描述的基本訪問器集,但 `name` 和 `email` 欄位有幾個額外的(因為它們是字串):一個 `mutable_` getter,讓你可以直接獲取字串的指標,以及一個額外的 setter。請注意,即使 `email` 尚未設定,你也可以呼叫 `mutable_email()`;它會自動初始化為空字串。如果本例中有一個重複的訊息欄位,它也會有一個 `mutable_` 方法,但沒有 `set_` 方法。

重複欄位也有一些特殊方法——如果你檢視重複欄位phones的方法,你會發現你可以:

  • 檢查重複欄位的_size(換句話說,與此Person關聯的電話號碼數量)。
  • 使用索引獲取指定的電話號碼。
  • 更新指定索引處的現有電話號碼。
  • 向訊息中新增另一個電話號碼,然後你可以編輯它(重複的標量型別有一個add_方法,可以直接傳入新值)。

有關協議編譯器為任何特定欄位定義生成的成員的詳細資訊,請參閱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++ 協議緩衝區類共享的 `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再次寫回檔案中。直接呼叫或引用協議編譯器生成的程式碼的部分已高亮顯示。

#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 Buffers清理所有內容。

讀取訊息

當然,如果不能從中獲取任何資訊,地址簿就沒有多大用處!這個例子讀取了上面例子建立的檔案,並打印出其中的所有資訊。

#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 (const tutorial::Person& person : address_book.people()) {
    cout << "Person ID: " << person.id() << endl;
    cout << "  Name: " << person.name() << endl;
    if (!person.has_email()) {
      cout << "  E-mail address: " << person.email() << endl;
    }

    for (const tutorial::Person::PhoneNumber& phone_number : person.phones()) {
      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;
        case tutorial::Person::PHONE_TYPE_UNSPECIFIED:
        default:
          cout << "  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 中:

  • 不得更改任何現有欄位的欄位編號。
  • 可以刪除單一或重複欄位。
  • 可以新增新的單一或重複欄位,但必須使用新的欄位編號(也就是說,在這個協議緩衝區中從未被使用過的欄位編號,即使是被刪除的欄位也一樣)。

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

如果你遵循這些規則,舊程式碼會愉快地讀取新訊息並簡單地忽略任何新欄位。對於舊程式碼,已刪除的欄位將僅具有其預設值,已刪除的重複欄位將為空。新程式碼也會透明地讀取舊訊息。但是,請記住新欄位不會出現在舊訊息中,因此你需要在使用前透過檢查它們是否具有預設值(例如,空字串)來檢查它們的存在。

最佳化技巧

C++ Protocol Buffers庫經過極其嚴格的最佳化。然而,正確的使用可以進一步提高效能。以下是一些從庫中榨取每一滴速度的技巧:

  • 使用 Arena 進行記憶體分配。當您在短期操作中(例如解析單個請求)建立許多 Protocol Buffer 訊息時,系統的記憶體分配器可能會成為瓶頸。Arenas 的設計就是為了緩解這個問題。透過使用 Arena,您可以以低開銷執行多次分配,並一次性釋放所有這些分配。這可以顯著提高訊息密集型應用程式的效能。

    要使用 arena,您需要在 `google::protobuf::Arena` 物件上分配訊息:

    google::protobuf::Arena arena;
    tutorial::Person* person = google::protobuf::Arena::Create<tutorial::Person>(&arena);
    // ... populate person ...
    

    當 arena 物件被銷燬時,所有在其上分配的訊息都會被釋放。更多詳情,請參閱Arenas 指南

  • 儘可能重用非 Arena 訊息物件。訊息會嘗試保留它們為重用而分配的任何記憶體,即使它們被清除後也是如此。因此,如果你連續處理許多具有相同型別和相似結構的訊息,每次重用相同的訊息物件是一個好主意,以減輕記憶體分配器的負擔。然而,物件會隨著時間的推移而膨脹,特別是當你的訊息“形狀”不同或你偶爾構造的訊息比平常大得多時。你應該透過呼叫SpaceUsed方法來監控訊息物件的大小,並在它們變得太大時刪除它們。

    重用 arena 訊息可能導致無限制的記憶體增長。重用堆訊息更安全。即使使用堆訊息,您仍然可能遇到欄位高水位線的問題。例如,如果您看到訊息

    a: [1, 2, 3, 4]
    b: [1]
    

    a: [1]
    b: [1, 2, 3, 4]
    

    並重用這些訊息,那麼兩個欄位都將有足夠的記憶體來容納它們所見過的最大值。因此,如果每個輸入只有5個元素,重用後的訊息將擁有8個元素的記憶體。

  • 您的系統記憶體分配器可能沒有針對從多個執行緒分配大量小物件進行最佳化。嘗試改用Google 的 TCMalloc

高階用法

Protocol Buffers 的用途遠不止簡單的訪問器和序列化。請務必探索C++ API 參考,看看您還可以用它們做些什麼。

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

反射透過Message::Reflection介面提供。