本教程提供protocol buffer在C++程序中的基礎用法。通過創建一個簡單的示例程序,向你展示如何:
- 在
.proto
中定義消息格式 - 使用protocol buffer編譯器
- 使用C++ protocol buffer API讀寫消息
這並不是protocol buffer在C++中使用的完整指南。更多細節,詳見Protocol Buffer Language Guide、C++ API Reference、C++ Generated Code Guide和Encoding Reference。
為什么使用Protocol Buffer
我們要使用的例子是一個非常簡單的“通訊錄”應用程序,它可以從文件中讀寫聯系人的信息。通訊錄中每個人都有一個姓名、ID、郵箱和練習電話。
你如何序列化並取回這樣結構化的數據呢?下面有幾條建議:
- 原始內存中數據結構可以發送/保存為二進制。這是一種隨時間推移而變得脆弱的方法,因為接收/讀寫的代碼必須編譯成相同的內存布局,endianness等。另外,文件已原始格式積累數據和在網絡中到處傳輸副本,因此擴展這種格式十分困難。
- 你可以編寫已臨時的方法來講數據元素編碼到單個字符串中 --- 例如用“12:3:-23:67”來編碼4個int。這是一種簡單而靈活的方法,盡管它確實需要編寫一次性的編碼和解析代碼,並且解析會增加少量的運行時成本。這對於編碼非常簡單的數據最有效。
- 序列化為XML。這種方法非常有吸引力,因為XML(某種程度上)是人類可讀的,而且有許多語言的綁定庫。如果您希望與其他應用程序/項目共享數據,這可能是一個不錯的選擇。然而,XML是出了名的空間密集型,對它進行編碼/解碼會給應用程序帶來巨大的性能損失。而且,在XML DOM樹中導航要比在類中導航簡單字段復雜得多。
Protocol buffer是解決上述問題的一個靈活、高效、高度自動化的解決方案。使用Protocol buffer,你只需在.proto
文件中描述你想要存儲的數據結構。從文件中,protocol buffer編譯器會創建一個類 --- 實現了可以自動編解碼的、高效的二進制protocol buffer數據。生成的類為組成protocol buffer的字段提供getter和setter方法,並負責將protocol buffer作為一個整體進行讀寫的細節。重要的是,protocol buffer協議支持擴展格式,以便新的代碼仍可讀取舊格式的編碼。
從哪能找到示例代碼呢?
你可以從這里下載。
定義你的Protocol格式
要創建通訊錄程序,始於.proto
文件。.proto
文件中的定義很簡單:為你想要序列化的每一個數據結構添加一個消息,然后聲明消息中每個字段的名稱和類型。示例使用的.proto
文件為addressbook.proto
,其中定義如下:
syntax = "proto3";
package tutorial;
message Person {
string name = 1;
int32 id = 2;
string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
}
如你所見,語法與C++/Java類似。接下來介紹文件中的每一部分以及它們如何工作。
.proto
開頭聲明使用proto3
語法,若不明確指出,編譯器默認使用proto2
語法。之后是包聲明,用來解決不同項目的命名沖突。在C++中,你生成的代碼會被放在與包名對應的命名空間。
接着,定義你的消息。消息只是一系列字段類型的集合體。很多標准的、簡單的數據類型可以作為字段類型,包括bool
、int32
、float
、double
和string
。你也可以使用其它消息類型作為字段類型來添加復雜結構到你的消息中 --- 就像上面例子中,Person
消息包含PhoneNumber
消息,同時Person
消息包含在AddressBook
消息中。你甚至可以定義消息類型嵌套在其它消息中 --- 就像上面PhoneNumber
定義在Person
中。你也可以定義enum
類型,如果你想讓你的字段只是用預定義列表中的一個值 --- 這里你想聲明的電話類型可以是MOBILE
、HOME
或WORK
其中之一。
“= 1”,“= 2”標記每個字段在二進制編碼中的唯一的“tag”。序號1-15編碼的字節數比較高的數字少一個,因此,作為一種優化,您可以決定對常用或重復的元素使用這些標記,而對不常用的可選元素使用標記16或更高。重復字段中的每個元素都需要重新編碼標記號,因此重復字段是此優化的特別好的候選項。
每個字段都必須遵循下列規則之一:
- singular:符合語法規則的消息可以擁有0個或1個該字段(但不能超過1個)。這是proto3默認的字段規則。
- repeated:在符合語法規則的消息中,該字段可以重復任意次數(包括0次)。重復變量的順序將被保留。
完整的編寫.proto
文件指南,詳見Language Guide(proto3)。
編譯Protocol Buffers
現在你已經有.proto
文件了,接下來你需要生成讀寫AddressBook
(包括Person
和PhoneNumber
)消息的類。現在,你需要運行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
現在我們來看看部分生成的代碼,看看編譯器生成了什么類和函數。打開addressbook.pb.h
,你會發現你在addressbook.proto
中聲明的每個消息類型都有一個對應的類。在Person
類中,你會看到編譯器已經為每個字段生成了訪問器。例如,對於name
、id
、email
和phones
字段,有如下方法:
// name
void clear_name();
const std::string& name() const;
void set_name(const std::string& value);
void set_name(std::string&& value);
void set_name(const char* value);
void set_name(const char* value, size_t size);
std::string* mutable_name();
// email
void clear_email();
const std::string& email() const;
void set_email(const std::string& value);
void set_email(std::string&& value);
void set_email(const char* value);
void set_email(const char* value, size_t size);
std::string* mutable_email();
// id
void clear_id();
::PROTOBUF_NAMESPACE_ID::int32 id() const;
void set_id(::PROTOBUF_NAMESPACE_ID::int32 value);
// phones
int phones_size() const;
void clear_phones();
::tutorial::Person_PhoneNumber* mutable_phones(int index);
::PROTOBUF_NAMESPACE_ID::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phones();
const ::tutorial::Person_PhoneNumber& phones(int index) const;
::tutorial::Person_PhoneNumber* add_phones();
如你所見,getters方法實際是字段名的小寫,setters方法以set_
開頭。每個字段都有一個clear_
方法來清空重置該字段。盡管數字的id
字段只有上面描述的基本訪問器,但由於name
和email
是字符串,所以它們還有一對額外的方法 --- mutable_
可以讓你獲取直指字符串的指針,以及額外的setter方法。如果在例子中有一個單一消息字段,那它也會有一個mutable_
方法,但沒有set_
方法。
重復字段也有一些特有的方法 --- 如何你查看重復字段phones
的話,你會看到:
_size
檢查重復字段的數量(換句話說,Person
有多少個電話號碼)。- 使用索引來獲取指定的電話號碼。
- 使用索引更新指定的電話
- 添加新的號碼到消息中,之后再編輯(重復標量字段類型都有個
add_
方法,僅可以通過它來訪問新的變量)。
有關編譯器為其它字段定義生成的成員的詳情,參見C++ Generated Code Guide。
枚舉和內嵌類
生成的代碼中包含一個PhoneType
的枚舉來匹配.proto
中的枚舉。你可以通過Person::PhoneType
來訪問該類型,其值可以通過Person::MOBILE
、Person::HOME
和Person::WORK
訪問(實現細節有點復雜,但使用枚舉時並不需要關心實現細節)。
編譯器也為你調用Person::PhoneNumber
生成了內嵌類。如果你看了生成的代碼,你會發現“真的”有個類叫做Person_PhoneNumber
,但是Person
中的typedef定義允許你像內嵌類一樣使用它。唯一有區別的情況是,如果你想在另一個文件中forward-declare這個類——在c++中你不能forward-declare嵌套類型,但你可以forward-declare Person_PhoneNumber
。
標准消息方法
每個消息類也包含很多你可以用來檢查/操作整個消息的其它方法,包括:
bool IsInitialized() const
:檢查所有字段是否都已初始化。string DebugString() const
:返回人類可讀的消息描述,debug時非常有用。void CopyFrom(const Person& from);
:使用給定的消息變量重寫消息。void Clear();
:重置所有元素為空狀態。
這些方法和接下來描述的I/O方法實現了所有c++ protocol buffer類共享的消息接口。詳見complete API documentation for Message。
解析和序列化
最后,每個類都提供了使用你所選方式來讀寫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
中解析消息。
這些只是所提供用於解析和序列化選項的一部分,完整列表,詳見complete API documentation for Message。
寫入消息
現在來試試protocol buffer類。你的通訊錄程序首先要做的是可以將信息寫入通訊錄里。為此,你需要創建並實例化你的protocol buffer類,然后將它們寫入輸出流。
下面是一個可以從一個文件中讀取通訊錄,並根據用戶輸入向其中添加一個新Person
,然后再次將新的通訊錄寫回文件。
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;
//從用戶輸入解析通訊錄
void PromptFromAddress(tutorial::Person *person)
{
cout << "Enter person ID number: ";
int id;
cin >> id;
person->set_id(id);
cin.ignore(256, '\n');
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 << "Unknow phone type, Use default: home. " << endl;
phone_number->set_type(tutorial::Person::HOME);
}
}
}
int main(int argc, char const *argv[])
{
if (argc != 2)
{
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}
tutorial::AddressBook address_book;
fstream input(argv[1], ios::in | ios::binary);
if (!input)
cout << argv[1] << ": File not found. Create a new file." << endl;
else if (!address_book.ParseFromIstream(&input))
{
cerr << "Failed to parse address book." << endl;
return -2;
}
else
{
PromptFromAddress(address_book.add_people());
fstream output(argv[1], ios::out | ios::binary);
if (!address_book.SerializeToOstream(&output))
{
cerr << "Failed to write address book." << endl;
return -3;
}
}
//可選操作,用於清除libprotobuf申請的所有全局對象
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
注意,在程序末尾調用了google::protobuf::ShutdownProtobufLibrary()
。它所做的工作就是清除libprotobuf申請的所有全局對象。對大多數程序而言,這一步不是必須的,因為進程一旦結束,系統會自動回收程序開辟的所有內存。然而,如果你使用的是要求每個遺留對象都必須釋放或者你在寫一個會被單個進程多次導入導出的庫,那么你可能會希望protocol buffer來幫你清理這些。
讀取消息
當然,如果你無法從中讀取任何消息的通訊錄是沒用的。下面的例子是從上面例子中創建的文件中讀取並輸出其中的所有消息。
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;
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 << "\t Name: " << person.name() << endl;
if (!person.email().empty())
cout << "\t Email: " << 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 << "\t\t Mobile phone: ";
break;
case tutorial::Person::HOME:
cout << "\t\t Home phone: ";
break;
case tutorial::Person::WORK:
cout << "\t\t Work phone: ";
break;
default:
break;
}
cout << phone_number.number() << endl;
}
}
}
int main(int argc, char const *argv[])
{
if (argc != 2)
{
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}
tutorial::AddressBook address_book;
fstream input(argv[1], ios::in | ios::binary);
if (!address_book.ParseFromIstream(&input))
{
cerr << "Failed to parse address book." << endl;
return -2;
}
ListPeople(address_book);
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
擴展
在發布protocol buffer生成的代碼后不久,你肯定會想提升
你的protocol buffer定義。如果你想新的buffer可以被后向兼容,並且舊的buffer可以被前向兼容,--- 你確實想這樣做 --- 那你需要遵守下面的規則。在新版的protocol buffer中:
- 你必須不能改變已有字段的序號。
- 你可以刪除repeated字段。
- 你可以新增repeated字段,但必須使用新的序號(序號在protocol buffer中沒被用過,也沒被刪除)。
還有一些其它的擴展要遵守,但很少會用到它們。
如果你遵守這些規則,那么舊代碼可以輕松讀取新的消息,忽略新的字段。對舊代碼而言,刪除的重復字段是空的。新代碼可以正常讀取舊消息。
優化建議
C++ Protocol Buffer庫是高度優化過的。但是,恰當的用法還是可以提高效率的。下面的一些技巧可以讓你進一步壓榨庫的性能:
- 盡可能重用消息對象。重用時,消息會保留它開辟的所有內存,即使被清理過。這樣,如果你正在連續處理許多具有相同類型和相似結構的消息,那么每次最好重用相同的消息對象以減小內存分配的開銷。但是,隨着時間的推移,對象可能會變得非常龐大,特別是當您的消息在“形狀”上發生變化,或者您偶爾構造一個比通常大得多的消息時。您應該通過調用
SpaceUsed
方法來監視消息對象的大小,並在它們變得太大時刪除它們。 - 在多線程調用時,針對大量小對象的創建,系統的內存分配可能優化的不夠好。可以使用Google`s tcmalloc替代。
高級用法
Protocol Buffer的用途不僅限於簡單的訪問器和序列化。一定要研究C++ API Reference,看看還可以用它們做什么。
protocol 消息提供的一個最重要的功能是反射
。你可以迭代消息的字段並操作它們的值,而無需針對任何特定的消息類型編寫代碼。使用反射的一個非常有用的方法是將協議消息與其他編碼(如XML或JSON)進行轉換。反射的一個更高級的用途可能是發現相同類型的兩個消息之間的差異,或者開發一種“協議消息的正則表達式”,在這種表達式中可以編寫與特定消息內容匹配的表達式。如果您發揮您的想象力,可能會將協議緩沖區應用到比您你初預期的范圍更廣的問題上!
關於反射,詳見Message::Reflection interface。
更多信息,參見這里。