Linux中國

C++ 程序員 Protocol Buffers 基礎指南

在哪可以找到示例代碼

示例代碼被包含於源代碼包,位於「examples」文件夾。可在這裡下載代碼。

定義你的協議格式

為了創建自己的地址簿應用程序,你需要從 .proto 開始。.proto 文件中的定義很簡單:為你所需要序列化的每個數據結構添加一個 消息 message ,然後為消息中的每一個欄位指定一個名字和類型。這裡是定義你消息的 .proto 文件 addressbook.proto

package tutorial;

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    required string number = 1;
    optional PhoneType type = 2 [default = HOME];
  }

  repeated PhoneNumber phone = 4;
}

message AddressBook {
  repeated Person person = 1;
}

如你所見,其語法類似於 C++ 或 Java。我們開始看看文件的每一部分內容做了什麼。

.proto 文件以一個 package 聲明開始,這可以避免不同項目的命名衝突。在 C++,你生成的類會被置於與 package 名字一樣的命名空間。

下一步,你需要定義 消息 message 。消息只是一個包含一系列類型欄位的集合。大多標準的簡單數據類型是可以作為欄位類型的,包括 boolint32floatdoublestring。你也可以通過使用其他消息類型作為欄位類型,將更多的數據結構添加到你的消息中——在以上的示例,Person 消息包含了 PhoneNumber 消息,同時 AddressBook 消息包含 Person 消息。你甚至可以定義嵌套在其他消息內的消息類型——如你所見,PhoneNumber 類型定義於 Person 內部。如果你想要其中某一個欄位的值是預定義值列表中的某個值,你也可以定義 enum 類型——這兒你可以指定一個電話號碼是 MOBILEHOMEWORK 中的某一個。

每一個元素上的 = 1= 2 標記確定了用於二進位編碼的唯一 「標籤」 tag 。標籤數字 1-15 的編碼比更大的數字少需要一個位元組,因此作為一種優化,你可以將這些標籤用於經常使用的元素或 repeated 元素,剩下 16 以及更高的標籤用於非經常使用的元素或 optional 元素。每一個 repeated 欄位的元素需要重新編碼標籤數字,因此 repeated 欄位適合於使用這種優化手段。

每一個欄位必須使用下面的修飾符加以標註:

  • required:必須提供該欄位的值,否則消息會被認為是 「未初始化的」 uninitialized 。如果 libprotobuf 以調試模式編譯,序列化未初始化的消息將引起一個斷言失敗。以優化形式構建,將會跳過檢查,並且無論如何都會寫入該消息。然而,解析未初始化的消息總是會失敗(通過 parse 方法返回 false)。除此之外,一個 required 欄位的表現與 optional 欄位完全一樣。
  • optional:欄位可能會被設置,也可能不會。如果一個 optional 欄位沒被設置,它將使用默認值。對於簡單類型,你可以指定你自己的默認值,正如例子中我們對電話號碼的 type 一樣,否則使用系統默認值:數字類型為 0、字元串為空字元串、布爾值為 false。對於嵌套消息,默認值總為消息的「默認實例」或「原型」,它的所有欄位都沒被設置。調用 accessor 來獲取一個沒有顯式設置的 optional(或 required) 欄位的值總是返回欄位的默認值。
  • repeated:欄位可以重複任意次數(包括 0 次)。repeated 值的順序會被保存於 protocol buffer。可以將 repeated 欄位想像為動態大小的數組。

你可以查找關於編寫 .proto 文件的完整指導——包括所有可能的欄位類型——在 Protocol Buffer Language Guide 裡面。不要在這裡面查找與類繼承相似的特性,因為 protocol buffers 不會做這些。

required 是永久性的

在把一個欄位標識為 required 的時候,你應該特別小心。如果在某些情況下你不想寫入或者發送一個 required 的欄位,那麼將該欄位更改為 optional 可能會遇到問題——舊版本的讀者(LCTT 譯註:即讀取、解析舊版本 Protocol Buffer 消息的一方)會認為不含該欄位的消息是不完整的,從而有可能會拒絕解析。在這種情況下,你應該考慮編寫特別針對於應用程序的、自定義的消息校驗函數。Google 的一些工程師得出了一個結論:使用 required 弊多於利;他們更願意使用 optionalrepeated 而不是 required。當然,這個觀點並不具有普遍性。

編譯你的 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 類,可以看到編譯器為每個欄位生成了 讀寫函數 accessors 。例如,對於 nameidemailphone 欄位,有下面這些方法:(LCTT 譯註:此處原文所指文件名有誤,徑該之。)

// 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();

// phone
inline int phone_size() const;
inline void clear_phone();
inline const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phone() const;
inline ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phone();
inline const ::tutorial::Person_PhoneNumber& phone(int index) const;
inline ::tutorial::Person_PhoneNumber* mutable_phone(int index);
inline ::tutorial::Person_PhoneNumber* add_phone();

正如你所見到,getters 的名字與欄位的小寫名字完全一樣,並且 setter 方法以 set_ 開頭。同時每個 單一 singular requiredoptional)欄位都有 has_ 方法,該方法在欄位被設置了值的情況下返回 true。最後,所有欄位都有一個 clear_ 方法,用以清除欄位到 empty 狀態。

數字型的 id 欄位僅有上述的基本 讀寫函數 accessors 集合,而 nameemail 欄位有兩個額外的方法,因為它們是字元串——一個是可以獲得字元串直接指針的mutable_ 的 getter ,另一個為額外的 setter。注意,儘管 email 還沒被 設置 set ,你也可以調用 mutable_email;因為 email 會被自動地初始化為空字元串。在本例中,如果你有一個單一的(requiredoptional)消息欄位,它會有一個 mutable_ 方法,而沒有 set_ 方法。

repeated 欄位也有一些特殊的方法——如果你看看 repeatedphone 欄位的方法,你可以看到:

  • 檢查 repeated 欄位的 _size(也就是說,與 Person 相關的電話號碼的個數)
  • 使用下標取得特定的電話號碼
  • 更新特定下標的電話號碼
  • 添加新的電話號碼到消息中,之後你便可以編輯。(repeated 標量類型有一個 add_ 方法,用於傳入新的值)

為了獲取 protocol 編譯器為所有欄位定義生成的方法的信息,可以查看 C++ generated code reference

枚舉和嵌套類

.proto 的枚舉相對應,生成的代碼包含了一個 PhoneType 枚舉。你可以通過 Person::PhoneType 引用這個類型,通過 Person::MOBILEPerson::HOMEPerson::WORK 引用它的值。(實現細節有點複雜,但是你無須了解它們而可以直接使用)

編譯器也生成了一個 Person::PhoneNumber 的嵌套類。如果你查看代碼,你可以發現真正的類型為 Person_PhoneNumber,但它通過在 Person 內部使用 typedef 定義,使你可以把 Person_PhoneNumber 當成嵌套類。唯一產生影響的一個例子是,如果你想要在其他文件前置聲明該類——在 C++ 中你不能前置聲明嵌套類,但是你可以前置聲明 Person_PhoneNumber

標準的消息方法

所有的消息方法都包含了許多別的方法,用於檢查和操作整個消息,包括:

  • bool IsInitialized() const; :檢查是否所有 required 欄位已經被設置。
  • string DebugString() const; :返回人類可讀的消息表示,對調試特別有用。
  • void CopyFrom(const Person& from);:使用給定的值重寫消息。
  • void Clear();:清除所有元素為空的狀態。

上面這些方法以及下一節要講的 I/O 方法實現了被所有 C++ protocol buffer 類共享的 消息 Message 介面。為了獲取更多信息,請查看 complete API documentation for Message

解析和序列化

最後,所有 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 reference 完整的列表。

Protocol Buffers 和面向對象設計

Protocol buffer 類通常只是純粹的數據存儲器(像 C++ 中的結構體);它們在對象模型中並不是一等公民。如果你想向生成的 protocol buffer 類中添加更豐富的行為,最好的方法就是在應用程序中對它進行封裝。如果你無權控制 .proto 文件的設計的話,封裝 protocol buffers 也是一個好主意(例如,你從另一個項目中重用一個 .proto 文件)。在那種情況下,你可以用封裝類來設計介面,以更好地適應你的應用程序的特定環境:隱藏一些數據和方法,暴露一些便於使用的函數,等等。但是你絕對不要通過繼承生成的類來添加行為。這樣做的話,會破壞其內部機制,並且不是一個好的面向對象的實踐。

寫消息

現在我們嘗試使用 protocol buffer 類。你的地址簿程序想要做的第一件事是將個人詳細信息寫入到地址簿文件。為了做到這一點,你需要創建、填充 protocol buffer 類實例,並且將它們寫入到一個 輸出流 output stream

這裡的程序可以從文件讀取 AddressBook,根據用戶輸入,將新 Person 添加到 AddressBook,並且再次將新的 AddressBook 寫迴文件。這部分直接調用或引用 protocol buffer 類的代碼會以「// pb」標出。

#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h" // pb
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);   // pb
  cin.ignore(256, &apos;n&apos;);

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

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

  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_phone();  //pb
    phone_number->set_number(number);   // pb

    cout << "Is this a mobile, home, or work phone? ";
    string type;
    getline(cin, type);
    if (type == "mobile") {
      phone_number->set_type(tutorial::Person::MOBILE); // pb
    } else if (type == "home") {
      phone_number->set_type(tutorial::Person::HOME);   // pb
    } else if (type == "work") {
      phone_number->set_type(tutorial::Person::WORK);   // pb
    } 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;   // pb

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

  tutorial::AddressBook address_book;   // pb

  {
    // 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)) {    // pb
      cerr << "Failed to parse address book." << endl;
      return -1;
    }
  }

  // Add an address.
  PromptForAddress(address_book.add_person());  // pb

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

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

  return 0;
}

注意 GOOGLE_PROTOBUF_VERIFY_VERSION 宏。它是一種好的實踐——雖然不是嚴格必須的——在使用 C++ Protocol Buffer 庫之前執行該宏。它可以保證避免不小心鏈接到一個與編譯的頭文件版本不兼容的庫版本。如果被檢查出來版本不匹配,程序將會終止。注意,每個 .pb.cc 文件在初始化時會自動調用這個宏。

同時注意在程序最後調用 ShutdownProtobufLibrary()。它用於釋放 Protocol Buffer 庫申請的所有全局對象。對大部分程序,這不是必須的,因為雖然程序只是簡單退出,但是 OS 會處理釋放程序的所有內存。然而,如果你使用了內存泄漏檢測工具,工具要求全部對象都要釋放,或者你正在寫一個 Protocol Buffer 庫,該庫可能會被一個進程多次載入和卸載,那麼你可能需要強制 Protocol Buffer 清除所有東西。

讀取消息

當然,如果你無法從它獲取任何信息,那麼這個地址簿沒多大用處!這個示例讀取上面例子創建的文件,並列印文件里的所有內容。

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

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

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

    for (int j = 0; j < person.phone_size(); j++) { // pb
      const tutorial::Person::PhoneNumber& phone_number = person.phone(j);  // pb

      switch (phone_number.type()) {    // pb
        case tutorial::Person::MOBILE:  // pb
          cout << "  Mobile phone #: ";
          break;
        case tutorial::Person::HOME:    // pb
          cout << "  Home phone #: ";
          break;
        case tutorial::Person::WORK:    // pb
          cout << "  Work phone #: ";
          break;
      }
      cout << phone_number.number() << endl;    // ob
    }
  }
}

// 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;   // pb

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

  tutorial::AddressBook address_book;   // pb

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

  ListPeople(address_book);

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

  return 0;
}

擴展 Protocol Buffer

或早或晚在你發布了使用 protocol buffer 的代碼之後,毫無疑問,你會想要 "改善" protocol buffer 的定義。如果你想要新的 buffers 向後兼容,並且老的 buffers 向前兼容——幾乎可以肯定你很渴望這個——這裡有一些規則,你需要遵守。在新的 protocol buffer 版本:

  • 你絕不可以修改任何已存在欄位的標籤數字
  • 你絕不可以添加或刪除任何 required 欄位
  • 你可以刪除 optionalrepeated 欄位
  • 你可以添加新的 optionalrepeated 欄位,但是你必須使用新的標籤數字(也就是說,標籤數字在 protocol buffer 中從未使用過,甚至不能是已刪除欄位的標籤數字)。

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

如果你能遵守這些規則,舊代碼則可以歡快地讀取新的消息,並且簡單地忽略所有新的欄位。對於舊代碼來說,被刪除的 optional 欄位將會簡單地賦予默認值,被刪除的 repeated 欄位會為空。新代碼顯然可以讀取舊消息。然而,請記住新的 optional 欄位不會呈現在舊消息中,因此你需要顯式地使用 has_ 檢查它們是否被設置或者在 .proto 文件在標籤數字後使用 [default = value] 提供一個合理的默認值。如果一個 optional 元素沒有指定默認值,它將會使用類型特定的默認值:對於字元串,默認值為空字元串;對於布爾值,默認值為 false;對於數字類型,默認類型為 0。注意,如果你添加一個新的 repeated 欄位,新代碼將無法辨別它被留空(被新代碼)或者從沒被設置(被舊代碼),因為 repeated 欄位沒有 has_ 標誌。

優化技巧

C++ Protocol Buffer 庫已極度優化過了。但是,恰當的用法能夠更多地提高性能。這裡是一些技巧,可以幫你從庫中擠壓出最後一點速度:

  • 儘可能復用消息對象。即使它們被清除掉,消息也會盡量保存所有被分配來重用的內存。因此,如果我們正在處理許多相同類型或一系列相似結構的消息,一個好的辦法是重用相同的消息對象,從而減少內存分配的負擔。但是,隨著時間的流逝,對象可能會膨脹變大,尤其是當你的消息尺寸(LCTT 譯註:各消息內容不同,有些消息內容多一些,有些消息內容少一些)不同的時候,或者你偶爾創建了一個比平常大很多的消息的時候。你應該自己通過調用 SpaceUsed 方法監測消息對象的大小,並在它太大的時候刪除它。
  • 對於在多線程中分配大量小對象的情況,你的操作系統內存分配器可能優化得不夠好。你可以嘗試使用 google 的 tcmalloc

高級用法

Protocol Buffers 絕不僅用於簡單的數據存取以及序列化。請閱讀 C++ API reference 來看看你還能用它來做什麼。

protocol 消息類所提供的一個關鍵特性就是 反射 reflection 。你不需要針對一個特殊的消息類型編寫代碼,就可以遍歷一個消息的欄位並操作它們的值。一個使用反射的有用方法是 protocol 消息與其他編碼互相轉換,比如 XML 或 JSON。反射的一個更高級的用法可能就是可以找出兩個相同類型的消息之間的區別,或者開發某種「協議消息的正則表達式」,利用正則表達式,你可以對某種消息內容進行匹配。只要你發揮你的想像力,就有可能將 Protocol Buffers 應用到一個更廣泛的、你可能一開始就期望解決的問題範圍上。

反射是由 Message::Reflection interface 提供的。

via: https://developers.google.com/protocol-buffers/docs/cpptutorial

作者:Google 譯者:cposture 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的電子郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國