Protobuf 教程:基於C++
前言
參考官方Protocol Buffer Basics: C++文檔,主要是參考了官方文檔。
本文使用C++實現一個簡單的應用程序,介紹 protocol buffer C++ API,並展示創建和使用.proto文件的基礎知識。還提供了完整示例代碼。
該教程是基於proto2的
簡介
Protocol buffer是一種靈活、高效、自動化的解決方案。
使用 protocol buffer,可以編寫要存儲的數據結構的.proto描述。由此, protocol buffer編譯器創建了一個類,該類以有效的二進制格式實現 protocol buffer數據的自動編碼和解析。生成的類爲構成 protocol buffer的字段提供getter和setter,並負責作爲一個單元讀取和寫入協議緩衝區的詳細信息。
重要的是, protocol buffer格式支擴展格式的思想,這樣代碼仍然可以讀取用舊格式編碼的數據。
定義協議格式
.proto
文件中的定義:爲要序列化的每個數據結構添加消息,然後爲消息中的每個字段指定名稱和類型。這裏是定義消息的.proto
文件,addressbook.proto
。
syntax = "proto2";
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 phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
語法類似於C++或Java。
.proto
文件以包聲明開頭,這有助於防止不同項目之間的命名衝突。在C++中,生成的類將放置在與包名匹配的命名空間中。
接下來,是message定義。message只是包含一組類型化字段的集合。許多標準的簡單數據類型作爲字段類型,包括bool
、int32
、float
、double
和string
。還可以使用其他message類型作爲字段類型向消息添加進一步的結構(在上面的示例中,Person
message包含PhoneNumber
messages,而AddressBook
message包含Person
messages)。可以定義嵌套在其他message中的message類型(PhoneNumber
類型是在Person中定義的)。如果希望某個字段具有預定義值列表中的一個值,也可以定義枚舉類型(要指定電話號碼可以是MOBILE
、HOME
或WORK
)。
每個元素上的“=1”
、“=2”
標記標識字段在二進制編碼中使用的唯一“標記”。標籤號1-15需要比更高的數字少一個字節進行編碼,因此作爲優化,您可以決定將這些標籤用於常用或重複的元素,而將標籤16和更高的標籤用於不常用的可選元素。重複字段中的每個元素都需要重新編碼標記號,因此重複字段尤其適合於此優化。
每個字段必須用以下修飾符之一進行修飾:
-
required
:必須提供值,否則消息將被視爲“未初始化”。如果在調試模式下編譯libprotobuf,則序列化未初始化的消息將導致斷言失敗。在優化的生成中,將跳過檢查,並無論如何寫入消息。但是,解析未初始化的消息總是失敗(通過從parse方法返回false)。除此之外,required字段與optional完全相同。 -
optional
:字段值可以設置,也可以不設置。如果未設置可選字段值,則使用默認值。對於簡單類型,您可以指定自己的默認值,正如示例中optional PhoneType type = 2 [default = HOME];
。否則,將使用系統默認值:對於數值類型爲零,對於字符串爲空字符串,對於布爾值爲假。對於嵌入的消息,默認值始終是消息的“默認實例”或“原型”,它沒有設置任何字段。調用訪問器以獲取尚未顯式設置的可選(或必需)字段的值,始終返回該字段的默認值。 -
repeated
:字段可以重複任意次數(包括零)。重複值的順序將保留在protocol buffer中。將重複字段視爲動態大小的數組。
“
required
”是永久性的,應該非常小心地根據需要標記字段。如果在某個時刻希望停止寫入或發送一個required
字段,將該字段更改爲optional
字段將是一個問題——舊的讀者會認爲沒有該字段的消息是不完整的,並且可能會無意中拒絕或刪除它們。應該考慮爲緩衝區編寫特定於應用程序的自定義驗證例程。谷歌的一些工程師得出了這樣的結論:按需使用弊大於利;他們寧願只使用可選的和重複的。然而,這種觀點並不是共識。
編譯寫好的Protocol Buffer文件
生成需要讀寫AddressBook (以及Person和PhoneNumber)消息的類。爲此,需要在.proto
上運行protocol buffer編譯器protoc:
-
如果尚未安裝編譯器,請下載該包並按照描述文件中的說明進行操作。
-
現在運行編譯器,指定源目錄(應用程序源代碼所在的目錄——如果不提供值,則使用當前目錄)、目標目錄(希望生成的代碼所在的目錄,通常與
$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類,可以看到編譯器已經爲每個字段生成了訪問器。例如,對於名稱、ID、電子郵件和電話字段,有以下方法:
// 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_
開頭。對於每個單數(required 或 optional)字段,也有has_
方法,如果設置了該字段,則返回“真”。最後,每個字段都有clear_
的方法,可以取消將字段設置回其空狀態。
id
字段只有上面描述的基本訪問集,但是name
和email
字段有一些額外的方法,因爲它們是字符串——mutable_
的getter,可以讓您直接獲得指向字符串的指針,以及一個額外的setter。即使尚未設置email,也可以調用mutable_email()
;它將自動初始化爲空字符串。如果您在這個例子中有一個單一的message 字段,那麼它也將有一個mutable_
的方法,而不是set_
方法。
Repeated
字段也有一些特殊的方法:
-
檢查
Repeated
字段的_size
(換句話說,與此人關聯的電話號碼有多少)。 -
使用其索引獲取指定的電話號碼。
-
在指定索引處更新現有電話號碼。
-
將另一個電話號碼添加到您可以編輯的message中(重複的標量類型有一個“
add_
”,只允許您傳入新值) 。
枚舉和嵌套類
生成的代碼包含與.proto
枚舉對應的PhoneType
枚舉。您可以將此類型稱爲Person::PhoneType
,其值爲Person::MOBILE
、Person::HOME
和Person::WORK
。
編譯器還爲您生成了一個名爲Person::PhoneNumber
的嵌套類。如果查看代碼,可以看到“real”類實際上被稱爲Person_PhoneNumber
,但是Person
內部定義的typedef
允許您將其視爲嵌套類。唯一不同的是,如果您想在另一個文件中向前聲明該類,則不能向前聲明C++中的嵌套類型,而是可以向前聲明Person_PhoneNumber
。
標準消息方法
每個message 類還包含許多其他方法,這些方法允許檢查或操作整個message ,包括:
-
bool IsInitialized() const;
:檢查是否已設置所有必需字段。 -
string DebugString() const;
:返回消息的可讀表示形式,對於調試特別有用。 -
void CopyFrom(const Person& from);
:用給定消息的值覆蓋消息。 -
void Clear();
:將所有元素清除回空狀態。
解析和序列化
每個 protocol buffer 類都有使用 protocol buffer 二進制格式寫入和讀取所選類型的消息的方法。其中包括:
-
bool SerializeToString(string* output) const;
:序列化消息並將字節存儲在給定的字符串中。注意,字節是二進制的,而不是文本;我們只使用字符串類作爲方便的容器。 -
bool ParseFromString(const string& data);
:解析給定字符串中的消息。 -
bool SerializeToOstream(ostream* output) const;
:將消息寫入給定的C++ostream
。 -
bool ParseFromIstream(istream* input);
;從給定的C++istream
解析消息。
寫 Message
創建和填充protocol buffer類的實例,然後將它們寫入輸出流。
這裏有一個程序,它從一個文件中讀取一個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::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_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()
。刪除協議緩衝區庫分配的所有全局對象。對於大多數程序來說,這是不必要的,因爲進程無論如何都將退出,操作系統將負責回收其所有內存。但是,如果使用要求釋放每個對象的內存泄漏檢查器,或者編寫的庫可能由單個進程多次加載和卸載,則可能需要強制協議緩衝區清除所有內容。
讀取Message
此示例讀取由上述示例創建的文件並打印其中的所有信息。
#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::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向後兼容,而舊的緩衝區向前兼容,需要遵循一些規則。在新版本的協議緩衝區中:
-
不能更改任何現有字段的標記號。
-
不能添加或刪除任何必需字段。
-
您可以刪除可選或重複的字段。
-
您可以添加新的可選或重複字段,但必須使用新的標記號(即從未在此協議緩衝區中使用過的標記號,即使是已刪除的字段)。
這些規則有一些例外,但很少使用。
如果遵循這些規則,舊代碼將很高興地讀取新消息,並且只忽略任何新字段。對於舊代碼,刪除的optional
字段具有其默認值,刪除的重複字段將爲空。新代碼還將透明地讀取舊消息。
但是,請記住,新的可選字段不會出現在舊消息中,因此需要檢查它們是用has_
設置的,或者在.proto
文件中在標記號後用[default = value]
提供一個合理的默認值。
如果沒有爲可選元素指定默認值,則使用特定於類型的默認值:對於字符串,默認值爲空字符串。對於布爾值,默認值爲false。對於數字類型,默認值爲零。
還要注意,如果添加了一個新的重複字段,那麼您的新代碼將無法分辨它是保留爲空(由新代碼)還是從未設置過(由舊代碼),因爲它沒有has_
標誌。
優化提示
C++Protocol Buffer庫已經被極大地優化。但是,正確的使用可以進一步提高性能。下面是一些提示:
-
儘可能重用message 對象。message 試圖保留它們分配給重用的任何內存,即使它們被清除。因此,如果您連續處理許多具有相同類型和類似結構的消息,那麼最好每次都重用相同的消息對象,以減輕內存分配器的負載。但是,隨着時間的推移,對象可能會變得膨脹,特別是如果消息在“形狀”上有所不同,或者偶爾構建的消息比平常大得多。您應該通過調用
SpaceUsed
方法來監視消息對象的大小,並在它們變得太大時將其刪除。 -
對於從多個線程分配大量小對象,系統的內存分配器可能沒有得到很好的優化。嘗試使用
Google's tcmalloc
。
高級用法
Protocol buffers提供的一個關鍵特性是反射。您可以迭代消息的字段並操作它們的值,而無需針對任何特定的消息類型編寫代碼。使用反射的一個非常有用的方法是將protocol message與其他編碼(如XML或JSON)進行相互轉換。反射的一個更高級的用途可能是發現同一類型的兩個消息之間的差異,或者開發一種“protocol message的正則表達式”,在這種表達式中可以編寫與特定message內容匹配的表達式。
反射由Message::Reflection interface
提供。
歡迎關注我的公衆號,持續分析優質技術文章