Protocol Buffer Basics: C++
這篇教程提供了一個面向 C++ 程序員、關於 protocol buffers
的基礎介紹。通過創建一個簡單的示例應用程序,它將向我們展示:
- 在
.proto
文件中定義消息格式 - 使用
protocol buffer
編譯器 - 使用
C++ protocol buffer API
讀寫消息
這不是一個關於使用 C++ protocol buffers 的全面指南。要獲取更詳細的信息,請參考 Protocol Buffer Language Guide 和 Encoding Reference。
為什么使用 Protocol Buffers
我們接下來要使用的例子是一個非常簡單的"地址簿"應用程序,它能從文件中讀取聯系人詳細信息。地址簿中的每一個人都有一個名字,ID,郵件地址和聯系電話。
如何序列化和獲取結構化的數據?這里有幾種解決方案:
-
以二進制形式發送/接收原生的內存數據結構。通常,這是一種脆弱的方法,因為接收/讀取代碼的編譯必須基於完全相同的內存布局、大小端等等。同時,當文件增加時,原始格式數據會隨着與該格式相連的軟件拷貝而迅速擴散,這將很難擴展文件格式。
-
你可以創造一種
ad-hoc
方法,將數據項編碼為一個字符串——比如將 4 個整數編碼為 "12:3:-23:67"。雖然它需要編寫一次性的編碼和解碼代碼且解碼需要耗費小的運行時成本,但這是一種簡單靈活的方法。這最適合編碼非常簡單的數據。 -
序列化數據為
XML
。這種方法是非常吸引人的,因為XML
是一種適合人閱讀的格式,並且有為許多語言開發的庫。如果你想與其他程序和項目共享數據,這可能是一種不錯的選擇。然而,眾所周知,XML
是空間密集型的,且在編碼和解碼時,它對程序會造成巨大的性能損失。同時,使用 XML DOM 樹被認為比操作一個類的簡單字段更加復雜。
Protocol buffers
是針對這個問題的一種靈活、高效、自動化的解決方案。使用 Protocol buffers
,你需要寫一個 .proto
說明,用於描述你所希望存儲的數據結構。利用 .proto
文件,protocol buffer 編譯器可以創建一個類,用於實現自動化編碼和解碼高效的二進制格式的 protocol buffer 數據。產生的類提供了構造 protocol buffer
的字段的 getters 和 setters,並且作為一個單元,關注讀寫 protocol buffer
的細節。重要的是,protocol buffer
格式支持擴展格式,代碼仍然可以讀取以舊格式編碼的數據。
在哪可以找到示例代碼
示例代碼被包含於源代碼包,位於 "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)。消息只是一個包含一系列類型字段的集合。大多標准簡單數據類型是可以作為字段類型的,包括 bool
、int32
、float
、double
和 string
。你也可以通過使用其他消息類型作為字段類型,將更多的數據結構添加到你的消息中——在以上的示例,Person
消息包含了 PhoneNumber
消息,同時 AddressBook
消息包含 Person
消息。你甚至可以定義嵌套在其他消息內的消息類型——如你所見,PhoneNumber
類型定義於 Person
內部。如果你想要其中某一個字段擁有預定義值列表中的某個值,你也可以定義 enum
類型——這兒你想指定一個電話號碼可以是 MOBILE
、HOME
或 WORK
中的某一個。
每一個元素上的 “=1”、"=2" 標記確定了用於二進制編碼的唯一"標簽"(tag)。標簽數字 1-15 的編碼比更大的數字少需要一個字節,因此作為一種優化,你可以將這些標簽用於經常使用或 repeated 元素,剩下 16 以及更高的標簽用於非經常使用或 optional 元素。每一個 repeated 字段的元素需要重新編碼標簽數字,因此 repeated 字段對於這優化是一個特別好的候選者。
每一個字段必須使用下面的修飾符加以標注:
-
required:必須提供字段的值,否則消息會被認為是 "未初始化的"(uninitialized)。如果
libprotobuf
以 debug 模式編譯,序列化未初始化的消息將引起一個斷言失敗。以優化形式構建,將會跳過檢查,並且無論如何都會寫入消息。然而,解析未初始化的消息總是會失敗(通過 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 可能會遇到問題——舊版本的讀者(譯者注:即讀取、解析舊版本 Protocol Buffer 消息的一方)會認為不含該字段的消息是不完整的,從而有可能會拒絕解析。在這種情況下,你應該考慮編寫特別針對於應用程序的、自定義的消息校驗函數。Google 的一些工程師得出了一個結論:使用 required 弊多於利;他們更願意使用 optional 和 repeated 而不是 required。當然,這個觀點並不具有普遍性。
編譯你的 Protocol Buffers
既然你有了一個 .proto
,那你需要做的下一件事就是生成一個將用於讀寫 AddressBook
消息的類(從而包括 Person
和 PhoneNumber
)。為了做到這樣,你需要在你的 .proto
上運行 protocol buffer 編譯器 protoc
:
- 如果你沒有安裝編譯器,請下載這個包,並按照 README 中的指令進行安裝。
- 現在運行編譯器,知道源目錄(你的應用程序源代碼位於哪里——如果你沒有提供任何值,將使用當前目錄),目標目錄(你想要生成的代碼放在哪里;常與
$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
讓我們看看生成的一些代碼,了解一下編譯器為你創建了什么類和函數。如果你查看 tutorial.pb.h
,你可以看到有一個在 tutorial.proto
中指定所有消息的類。關注 Person
類,可以看到編譯器為每個字段生成了讀寫函數(accessors)。例如,對於 name
、id
、email
和 phone
字段,有下面這些方法:
// 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)(required 或 optional)字段都有 has_
方法,該方法在字段被設置了值的情況下返回 true。最后,所有字段都有一個 clear_
方法,用以清除字段到空(empty)狀態。
數字 id
字段僅有上述的基本讀寫函數集合(accessors),而 name
和 email
字段有兩個額外的方法,因為它們是字符串——一個是可以獲得字符串直接指針的mutable_
getter ,另一個為額外的 setter。注意,盡管 email
還沒被設置(set),你也可以調用 mutable_email
;因為 email
會被自動地初始化為空字符串。在本例中,如果你有一個單一的(required 或 optional)消息字段,它會有一個 mutable_
方法,而沒有 set_
方法。
repeated 字段也有一些特殊的方法——如果你看看 repeated phone
字段的方法,你可以看到:
- 檢查 repeated 字段的
_size
(也就是說,與Person
相關的電話號碼的個數) - 使用下標取得特定的電話號碼
- 更新特定下標的電話號碼
- 添加新的電話號碼到消息中,之后你便可以編輯。(repeated 標量類型有一個
add_
方法,用於傳入新的值)
為了獲取 protocol 編譯器為所有字段定義生成的方法的信息,可以查看 C++ generated code reference。
枚舉和嵌套類(Enums and Nested Classes)
與 .proto
的枚舉相對應,生成的代碼包含了一個 PhoneType
枚舉。你可以通過 Person::PhoneType
引用這個類型,通過 Person::MOBILE
、Person::HOME
和 Person::WORK
引用它的值。(實現細節有點復雜,但是你無須了解它們而可以直接使用)
編譯器也生成了一個 Person::PhoneNumber
的嵌套類。如果你查看代碼,你可以發現真正的類型為 Person_PhoneNumber
,但它通過在 Person
內部使用 typedef 定義,使你可以把 Person_PhoneNumber
當成嵌套類。唯一產生影響的一個例子是,如果你想要在其他文件前置聲明該類——在 C++ 中你不能前置聲明嵌套類,但是你可以前置聲明 Person_PhoneNumber
。
標准的消息方法
所有的消息方法都包含了許多別的方法,用於檢查和操作整個消息,包括:
bool IsInitialized() const;
:檢查是否所有required
字段已經被設置。string DebugString() const;
:返回人類可讀的消息表示,對 debug 特別有用。void CopyFrom(const Person& from);
:使用給定的值重寫消息。void Clear();
:清除所有元素為空(empty)的狀態。
上面這些方法以及下一節要講的 I/O 方法實現了被所有 C++ protocol buffer 類共享的消息(Message)接口。為了獲取更多信息,請查看 complete API documentation for Message。
解析和序列化(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 reference
完整的列表。
Protocol Buffers 和 面向對象設計的 Protocol buffer 類通常只是純粹的數據存儲器(像 C++ 中的結構體);它們在對象模型中並不是一等公民。如果你想向生成的 protocol buffer 類中添加更豐富的行為,最好的方法就是在應用程序中對它進行封裝。如果你無權控制 .proto 文件的設計的話,封裝 protocol buffers 也是一個好主意(例如,你從另一個項目中重用一個 .proto 文件)。在那種情況下,你可以用封裝類來設計接口,以更好地適應你的應用程序的特定環境:隱藏一些數據和方法,暴露一些便於使用的函數,等等。但是你絕對不要通過繼承生成的類來添加行為。這樣做的話,會破壞其內部機制,並且不是一個好的面向對象的實踐。
寫消息(Writing A Message)
現在我們嘗試使用 protocol buffer 類。你的地址簿程序想要做的第一件事是將個人詳細信息寫入到地址簿文件。為了做到這一點,你需要創建、填充 protocol buffer 類實例,並且將它們寫入到一個輸出流(output stream)。
這里的程序可以從文件讀取 AddressBook
,根據用戶輸入,將新 Person
添加到 AddressBook
,並且再次將新的 AddressBook
寫回文件。這部分直接調用或引用 protocol buffer 類的代碼會高亮顯示。
#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 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.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 版本:
- 你絕不可以修改任何已存在字段的標簽數字
- 你絕不可以添加或刪除任何 required 字段
- 你可以刪除 optional 或 repeated 字段
- 你可以添加新的 optional 或 repeated 字段,但是你必須使用新的標簽數字(也就是說,標簽數字在 protocol buffer 中從未使用過,甚至不能是已刪除字段的標簽數字)。
(這是對於上面規則的一些異常情況,但它們很少用到。)
如果你能遵守這些規則,舊代碼則可以歡快地讀取新的消息,並且簡單地忽略所有新的字段。對於舊代碼來說,被刪除的 optional 字段將會簡單地賦予默認值,被刪除的 repeated
字段會為空。新代碼顯然可以讀取舊消息。然而,請記住新的 optional 字段不會呈現在舊消息中,因此你需要顯式地使用 has_
檢查它們是否被設置或者在 .proto
文件在標簽數字后使用 [default = value]
提供一個合理的默認值。如果一個 optional 元素沒有指定默認值,它將會使用類型特定的默認值:對於字符串,默認值為空字符串;對於布爾值,默認值為 false;對於數字類型,默認類型為 0。注意,如果你添加一個新的 repeated 字段,新代碼將無法辨別它被留空(left empty)(被新代碼)或者從沒被設置(被舊代碼),因為 repeated 字段沒有 has_
標志。
優化技巧
C++ Protocol Buffer 庫已極度優化過了。但是,恰當的用法能夠更多地提高性能。這里是一些技巧,可以幫你從庫中擠壓出最后一點速度:
-
盡可能復用消息對象。即使它們被清除掉,消息也會盡量保存所有被分配來重用的內存。因此,如果我們正在處理許多相同類型或一系列相似結構的消息,一個好的辦法是重用相同的消息對象,從而減少內存分配的負擔。但是,隨着時間的流逝,對象可能會膨脹變大,尤其是當你的消息尺寸(譯者注:各消息內容不同,有些消息內容多一些,有些消息內容少一些)不同的時候,或者你偶爾創建了一個比平常大很多的消息的時候。你應該自己通過調用 SpaceUsed 方法監測消息對象的大小,並在它太大的時候刪除它。
-
對於在多線程中分配大量小對象的情況,你的操作系統內存分配器可能優化得不夠好。你可以嘗試使用 google 的 tcmalloc。
高級用法
Protocol Buffers 絕不僅用於簡單的數據存取以及序列化。請閱讀 C++ API reference 來看看你還能用它來做什么。
protocol 消息類所提供的一個關鍵特性就是反射。你不需要編寫針對一個特殊的消息類型的代碼,就可以遍歷一個消息的字段並操作它們的值。一個使用反射的有用方法是 protocol 消息與其他編碼互相轉換,比如 XML 或 JSON。反射的一個更高級的用法可能就是可以找出兩個相同類型的消息之間的區別,或者開發某種 "協議消息的正則表達式",利用正則表達式,你可以對某種消息內容進行匹配。只要你發揮你的想像力,就有可能將 Protocol Buffers 應用到一個更廣泛的、你可能一開始就期望解決的問題范圍上。
反射是由 Message::Reflection interface 提供的。
via: https://developers.google.com/protocol-buffers/docs/cpptutorial