在C++中使用Protocol Buffers
下載并編譯Protocol Buffer
這份教程為C++開發者提供了使用 Protocol Buffer 的基本介紹。通過創建一個簡單應用,它展示了
- 在 .proto 文件中定義消息格式。
- 使用 Protocol Buffer 編譯器。
- 使用C++ Protocol Buffer API讀寫消息。
這不是一個在C++中使用 Protocol Buffer 的全面指南。更多詳細的信息,請參考Protocol Buffer語言指南, C++ API參考,C++ Generated Code Guide,和 編碼參考。
為什么使用Protocol Buffers?
我們將使用的例子是一個非常簡單的 "address book" 應用,它可以從文件讀取和向文件寫入人們的聯系人詳情。地址簿中的每個人具有一個名字 (name),ID,電子郵件地址 (email address),和聯系人電話號碼 (contact phone)。
你要如何序列化和提取這樣的結構化數據呢?有一些方法可以解決這個問題:
-
原始的內存數據結構可以以二進制的形式發送/保存。隨著時間的流逝,這是一種脆弱的方法,因為接收/讀取的代碼必須以完全相同的內存布局、尾端等等編譯。此外,隨著文件以原始的格式累積數據及處理那種格式的軟件的復制,那種格式被不斷傳播,則它是非常難以擴展的格式。
-
你可以發明一種特別的方式來將數據項編碼編碼為一個字符串 —— 比如將4個int值編碼為"12:3:-23:67"。這是一個簡單而靈活的方法,盡管它需要編寫一次性的編碼和解析代碼,而且解析消耗一小段運行時代價。這對于編碼非常簡單的數據是最好的方式。
將數據序列化為XML。這種方法可能非常具有吸引力,因為XML是 (有點) 人類可讀的,而且它有大量編程語言的bindings庫。如果你想要與其它的應用/項目共享數據的話,這可能是一個很好的選擇。然而,XML是臭名昭著的空間密集,而且編碼/解碼它需要消耗應用大量的性能開銷。而且,瀏覽一個XML DOM樹也被認為比通常瀏覽類中的簡單字段更復雜。
Protocol buffers 是解決這個問題靈活,高效,自動化的方案。通過 Protocol buffers ,你可以編寫一個 .proto 描述你想要存儲的數據結構。通過它, Protocol buffers 編譯器創建一個類,以一種高效的二進制格式實現自動的編碼和解析 Protocol buffers 數據。生成的類為構成一個 Protocol buffers 的字段提供了getters和setters方法,并處理讀取和寫入 Protocol buffers 的細節。重要地是, 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 文件以一個包聲明開始,這用于防止不同項目間的命名沖突。在C++中,生成的類將被放置在與包名匹配的命名空間中。
接下來,定義你的消息。消息只是包含了具有類型的字段的聚合。許多標準的簡單數據類型可用作字段類型,包括bool,int32,float,double,和string。你也可以通過使用消息類型作為字段類型來給你的消息添加更多結構 —— 在上面的例子中,Person消息包含了多個PhoneNumber消息,同時AddressBook消息包含Person消息。你甚至可以在其它消息中嵌套的定義消息類型 —— 如你所見,PhoneNumber類型是在Person中定義的。如果你想要你的字段值為某個預定義的值列表中的某個值的話,你也可以定義enum類型 —— 這里你想要指定電話號碼是MOBILE,HOME,或WORK中的一個。
每個元素上的 " = 1"," = 2"標記標識在二進制編碼中使用的該字段唯一的 "tag" 。Tag數字 1-15 比更大的數字在編碼上少一個字節,因而作為一種優化,你可以決定將那些數字用作常用的或重復的元素的tag,而將16及更大的數字tag留給更加不常用的可選元素。重復字段中的每個元素需要重編碼tag數字,因而這種優化特別適用于重復字段。
每個字段必須用下面的修飾符中的一個來注解:
-
required:字段必須提供,否則消息將被認為是 "未初始化的 (uninitialized)"。如果libprotobuf以debug模式編譯,則序列化未初始化的消息將導致斷言失敗。在優化的構建中,檢查將被跳過,消息仍將被寫入。然而,解析未初始化的消息將總是失敗 (通過喜愛parse方法中返回false)。否則,required字段的行為將與optional字段完全相同。
-
optional:字段可以設置也可以不設置。如果可選的字段值沒有設置,則將使用默認值。對于簡單的類型,你可以指定你自己的默認值,如我們在例子中為電話號碼類型做的那樣。否則,將使用系統默認值:數字類型為0,字符串類型為空字符串,bools值為false。對于內嵌的消息,默認值總是消息的 "默認實例 (default instance)" 或 "原型(prototype)",它們沒有自己的字段集。調用accessor獲取還沒有顯式地設置的 optional (或required) 字段的值總是返回字段的默認值。
-
repeated:字段可以重復任意多次 (包括0)。在 protocol buffer 中,重復值的順序將被保留。將重復字段想象為動態大小的數組。
你將找到一個編寫 .proto 文件的完整指南 —— 包括所有可能的字段類型 —— 在Protocol Buffer Language Guide 一文中。不要尋找與類繼承類似的設施 —— protocol buffer 不那樣做。
編譯你的Protocol Buffers
現在你有了一個.proto,接下來你需要做的事情是生成讀寫 AddressBook (及Person 和 PhoneNumber) 消息所需的類。要做到這一點,你需要在你的 .proto 上運行 Protocol Buffers 編譯器protoc:
如果你還沒有安裝編譯器,則下載包,并按照README的指示進行。
現在運行編譯器,指定源目錄 (放置你的應用程序源代碼的地方 —— 如果你沒有提供則使用當前目錄),目的目錄 (你希望放置生成的代碼的位置;通常與$SRC_DIR相同),你的.proto的路徑。在這個例子中,你... :
由于你想要C++類,所以使用 --cpp_out 選項 —— 也為其它支持的語言提供了類似的選項。
這將在你指定的目的目錄下生成下面的文件:
- addressbook.pb.h,聲明你的生成類的頭文件。
- addressbook.pb.cc,包含了你的類的實現。
Protocol Buffer API
讓我們看一下生成的代碼,并看一下編譯器都為你創建了什么類和函數。如果查看tutorial.pb.h,你可以看到你在tutorial.proto中描述的每個消息都有一個類。進一步看Person類的話,你可以看到編譯器已經為每個字段生成了accessors。比如,name,id,email,和phone字段,你具有這些方法:
// nameinline 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();// idinline bool has_id() const;inline void clear_id();inline int32_t id() const;inline void set_id(int32_t value);// emailinline 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();// phoneinline 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_開頭。每個單數的 (required 或 optional) 字段還有has_ 方法,如果那個字段已經被設置了則它們放回true。最后,每個字段具有一個 clear_ 方法,用于將字段設置回它的空狀態。
數字的id字段只有基本的如上所述的accessor set,而name和email字段則有一對額外的方法,因為它們是字符串 —— 一個mutable_ getter,讓你獲取指向字符串的直接的指針,及一個額外的setter。注意你可以調用mutable_email(),即使email還沒有設置;它將被自動地初始化為一個空字符竄。如果在這個例子中你有一個單數的消息字段,它將還有一個mutable_方法,而沒有set_方法。
重復的字段還有一些特別的方法 —— 如果你查看重復的phone字段的方法的話,你將看到你可以
- 檢查重復字段的 _size (換句話說,與這個Person關聯的電話號碼有多少個)。
- 使用索引得到一個特定的電話號碼。
- 更新特定位置處的已有電話號碼。
- 給消息添加另一個后面你可以編輯的電話號碼 (重復的標量類型具有一個add_ 以使你可以傳入新值)。
關于protocol編譯器為任何特定的字段定義產生什么成員的更多信息,請參考 C++ 生成代碼參考。
枚舉和嵌套類
生成的代碼包含一個PhoneType枚舉,它對應于你的.proto枚舉。你可以以Person::PhoneType引用這個類型,它的值包括 Person::MOBILE,Person::HOME,和Person::WORK (實現細節要復雜一點,但你使用枚舉時無需理解它們)。
編譯器還為你生成了稱為Person::PhoneNumber的嵌套類。如果你看代碼,會發現 "真實的" 類實際稱為 Person_PhoneNumber,但定義在Person內的typedef使你可以像一個嵌套類一樣使用它。會影響到的僅有的情況是,如果你想要在另一個文件中前向聲明類 —— 你不能在C++中前向聲明嵌套類型,但你可以前向聲明Person_PhoneNumber。
標準的消息方法
每個消息類還包含大量的其它方法,來讓你檢查或管理整個消息,包括:
- bool IsInitialized() const;: 檢查是否所有的required字段都已經被設置了。
- string DebugString() const;: 返回一個人類可讀的消息表示,對調試特別有用。
- void CopyFrom(const Person& from);: 用給定消息的值覆寫消息。
- void Clear();: 清空所有的元素為空狀態。
這些方法以及在后面的小節中描述的I/O方法實現了所有C++ protocol buffer類共享的Message接口。更多信息,請參考 Message的完整API文檔。
Parsing and Serialization解析和序列化
最后,每個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_phone();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::MOBILE);} else if (type == "home") {phone_number->set_type(tutorial::Person::HOME);} else if (type == "work") {phone_number->set_type(tutorial::Person::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_person());{// 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庫分配的全局對象。這對大多數程序都是不必要的,因為進程退出后,OS將回收它的所有內從。然而,如果你使用了內存泄漏檢查工具,它需要每個對象都被釋放,或者如果你在編寫一個庫,它可能被單獨的進程加載和卸載多次,則你可能想要強制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 (int i = 0; i < address_book.person_size(); i++) {const tutorial::Person& person = address_book.person(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.phone_size(); j++) {const tutorial::Person::PhoneNumber& phone_number = person.phone(j);switch (phone_number.type()) {case tutorial::Person::MOBILE:cout << " Mobile phone #: ";break;case tutorial::Person::HOME:cout << " Home phone #: ";break;case tutorial::Person::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的定義。如果你想要你的新buffers向后兼容,你的老buffers向前兼容 —— 你當然幾乎總是想要這樣 —— 然后你有一些規則要遵守。在新版本的protocol buffer中:
- 你 一定不能 修改任何已有字段的tag數字。
- 你 一定不能 添加或刪除required字段。
- 你 可以 刪除可選的或重復的字段。
- 你 可以 添加可選或重復的字段,但你必須使用新的tag數字 (比如,從未在這個protocol buffer中使用過的tag數字,甚至是在刪除的字段中也是)。
(這些規則有 一些例外 ,但它們幾乎從未用到)
如果你按照這些規則,老代碼將開心地讀取新消息并簡單地忽略新字段。對于老代碼來說,刪除的可選字段將簡單的具有它們的默認值,刪除的重復字段將是空的。新代碼將透明地讀取老消息。然而,請記住新的可選字段將不會出現在老的消息中,因此你將需要顯示地檢查它們是否通過has_設置了,或通過 [default = value] 在你的 .proto 文件中的tag數字后面提供一個合理的默認值。如果沒有為可選元素指定默認值,則會使用特定于類型的默認值代替:對于字符串,默認值是空字符串。對于booleans,默認值是false。對于數字類型,默認值是0。還要注意如果你添加了一個新的重復字段,你的新代碼將不能區別他是空的 (通過新代碼) 還是從來沒有設置 (通過老代碼) ,因為它沒有 has_ 標記。
優化建議
C++ Protocol Buffers是經過高度優化了的。然而,適當的使用可以提升更多性能。這里是一些提示,用于從庫中擠出每一滴性能:
-
只要可能就重用消息。消息消息會嘗試保留它們分配的內存以復用,甚至當它們被清理的時候。這樣,如果你在連續處理相同類型及類似結構的許多消息,則每次復用相同消息對象就是一個降低內存分配器負載的好主意。然后,對象可能隨著變得膨脹,特別是如果你的消息在 "形狀(shape)" 上經常改變,或如果你偶然構造了一個比通常情況大很多的消息。你應該通過調用 SpaceUsed 方法監視你的消息對象的大小,并在它們變的太大時刪除它們。
-
你的系統的內存分配器可能沒有針對在多線程中分配大量小對象做個很好的優化。則嘗試使用 Google的tcmalloc 來代替。
高級用法
Protocol buffers的使用場景不僅僅是簡單的存取器和序列化。確保瀏覽 C++ API 參考 來了解你還可以用它做什么。
由protocol消息類提供的一個重要功能是 反射 。你可以迭代一個消息的字段,并在不針對特定的消息類型編寫你的代碼的情況下,管理它們的值。使用反射的一個非常有用的方式是將protocol消息轉換為其它編碼方式,或從其它編碼方式轉換,比如XML或JSON。反射的一個更高級的使用可能是查找相同類型的兩個消息之間的差異,或者開發某種"protocol消息正則表達式",你可以編寫表達式用它匹配某一消息內容。如果使用你想象力,則將Protocol Buffers用到比你最初期望的更加廣泛的問題的解決中是有可能的!
Message::Reflection 接口提供了反射.
原文
總結
以上是生活随笔為你收集整理的在C++中使用Protocol Buffers的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: HTTP/2 流量调试
- 下一篇: 在Java中使用Protocol Buf