【C/C++編程系列】Google Protocol Buffer 實踐

簡介

什么是 Google Protocol Buffer? 假如您在網(wǎng)上搜索,應(yīng)該會得到類似這樣的文字介紹:
Google Protocol Buffer( 簡稱 Protobuf) 是 Google 公司內(nèi)部的混合語言數(shù)據(jù)標準,目前已經(jīng)正在使用的有超過 48,162 種報文格式定義和超過 12,183 個 .proto 文件。他們用于 RPC 系統(tǒng)和持續(xù)數(shù)據(jù)存儲系統(tǒng)。
Protocol Buffers 是一種輕便高效的結(jié)構(gòu)化數(shù)據(jù)存儲格式,可以用于結(jié)構(gòu)化數(shù)據(jù)串行化,或者說序列化。它很適合做數(shù)據(jù)存儲或 RPC 數(shù)據(jù)交換格式??捎糜谕ㄓ崊f(xié)議、數(shù)據(jù)存儲等領(lǐng)域的語言無關(guān)、平臺無關(guān)、可擴展的序列化結(jié)構(gòu)數(shù)據(jù)格式。目前提供了 C++、Java、Python 三種語言的 API。

或許您和我一樣,在第一次看完這些介紹后還是不明白 Protobuf 究竟是什么,那么我想一個簡單的例子應(yīng)該比較有助于理解它。

Google Protocol Buffer Demo

安裝Google Protocol Buffer

在網(wǎng)站 http://code.google.com/p/protobuf/downloads/list上可以下載 Protobuf 的源代碼。然后解壓編譯安裝便可以使用它了。
安裝步驟如下所示:

tar -xzf protobuf-2.1.0.tar.gz 
cd protobuf-2.1.0 
./configure --prefix=$INSTALL_DIR 
make 
make check 
make install

Demo描述

我打算使用 Protobuf 和 C++ 開發(fā)一個十分簡單的例子程序。
該程序由兩部分組成。第一部分被稱為 Writer,第二部分叫做 Reader。
Writer 負責將一些結(jié)構(gòu)化的數(shù)據(jù)寫入一個磁盤文件,Reader 則負責從該磁盤文件中讀取結(jié)構(gòu)化數(shù)據(jù)并打印到屏幕上。
準備用于演示的結(jié)構(gòu)化數(shù)據(jù)是 HelloWorld,它包含兩個基本數(shù)據(jù):

  • ID,為一個整數(shù)類型的數(shù)據(jù)
  • Str,這是一個字符串

創(chuàng)建編寫.proto 文件

首先我們需要編寫一個 proto 文件,定義我們程序中需要處理的結(jié)構(gòu)化數(shù)據(jù),在 protobuf 的術(shù)語中,結(jié)構(gòu)化數(shù)據(jù)被稱為 Message。proto 文件非常類似 java 或者 C 語言的數(shù)據(jù)定義。代碼清單 1 顯示了例子應(yīng)用中的 proto 文件內(nèi)容。

清單1. lm.helloworld.proto

// lm.helloworld.proto
package lm; 
message helloworld 
{ 
   required int32     id = 1;  // ID 
   required string    str = 2;  // str 
   optional int32     opt = 3;  //optional field 
}

一個比較好的習(xí)慣是認真對待 proto 文件的文件名。比如將命名規(guī)則定于如下:

packageName.MessageName.proto

在上例中,package 名字叫做 lm,定義了一個消息 helloworld,該消息有三個成員,類型為 int32 的 id,另一個為類型為 string 的成員 str。opt 是一個可選的成員,即消息中可以不包含該成員。

編譯 .proto 文件

寫好 proto 文件之后就可以用 Protobuf 編譯器將該文件編譯成目標語言了。本例中我們將使用 C++。
假設(shè)您的 proto 文件存放在 $SRC_DIR 下面,您也想把生成的文件放在同一個目錄下,則可以使用如下命令:

protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/addressbook.proto

命令將生成兩個文件:

  • lm.helloworld.pb.h , 定義了 C++ 類的頭文件
  • lm.helloworld.pb.cc , C++ 類的實現(xiàn)文件

在生成的頭文件中,定義了一個 C++ 類 helloworld,后面的 Writer 和 Reader 將使用這個類來對消息進行操作。諸如對消息的成員進行賦值,將消息序列化等等都有相應(yīng)的方法。

編寫 writer 和 Reader

如前所述,Writer 將把一個結(jié)構(gòu)化數(shù)據(jù)寫入磁盤,以便其他人來讀取。假如我們不使用 Protobuf,其實也有許多的選擇。一個可能的方法是將數(shù)據(jù)轉(zhuǎn)換為字符串,然后將字符串寫入磁盤。轉(zhuǎn)換為字符串的方法可以使用 sprintf(),這非常簡單。數(shù)字 123 可以變成字符串”123”。
這樣做似乎沒有什么不妥,但是仔細考慮一下就會發(fā)現(xiàn),這樣的做法對寫 Reader 的那個人的要求比較高,Reader 的作者必須了 Writer 的細節(jié)。比如”123”可以是單個數(shù)字 123,但也可以是三個數(shù)字 1,2 和 3,等等。這么說來,我們還必須讓 Writer 定義一種分隔符一樣的字符,以便 Reader 可以正確讀取。但分隔符也許還會引起其他的什么問題。最后我們發(fā)現(xiàn)一個簡單的 Helloworld 也需要寫許多處理消息格式的代碼。

如果使用 Protobuf,那么這些細節(jié)就可以不需要應(yīng)用程序來考慮了。
使用 Protobuf,Writer 的工作很簡單,需要處理的結(jié)構(gòu)化數(shù)據(jù)由 .proto 文件描述,經(jīng)過上一節(jié)中的編譯過程后,該數(shù)據(jù)化結(jié)構(gòu)對應(yīng)了一個 C++ 的類,并定義在 lm.helloworld.pb.h 中。對于本例,類名為 lm::helloworld。
Writer 需要 include 該頭文件,然后便可以使用這個類了。
現(xiàn)在,在 Writer 代碼中,將要存入磁盤的結(jié)構(gòu)化數(shù)據(jù)由一個 lm::helloworld 類的對象表示,它提供了一系列的 get/set 函數(shù)用來修改和讀取結(jié)構(gòu)化數(shù)據(jù)中的數(shù)據(jù)成員,或者叫 field。
當我們需要將該結(jié)構(gòu)化數(shù)據(jù)保存到磁盤上時,類 lm::helloworld 已經(jīng)提供相應(yīng)的方法來把一個復(fù)雜的數(shù)據(jù)變成一個字節(jié)序列,我們可以將這個字節(jié)序列寫入磁盤。
對于想要讀取這個數(shù)據(jù)的程序來說,也只需要使用類 lm::helloworld 的相應(yīng)反序列化方法來將這個字節(jié)序列重新轉(zhuǎn)換會結(jié)構(gòu)化數(shù)據(jù)。這同我們開始時那個“123”的想法類似,不過 Protobuf 想的遠遠比我們那個粗糙的字符串轉(zhuǎn)換要全面,因此,我們不如放心將這類事情交給 Protobuf 吧。代碼清單2列出了Writer的核心代碼。

清單2. Writer.cpp代碼

#include "lm.helloworld.pb.h"
…
 
 int main(void) 
 { 
  lm::helloworld msg1; 
  msg1.set_id(101); 
  msg1.set_str(“hello”); 
     
  // Write the new address book back to disk. 
  fstream output("./log", ios::out | ios::trunc | ios::binary); 
         
  if (!msg1.SerializeToOstream(&output)) { 
      cerr << "Failed to write msg." << endl; 
      return -1; 
  }         
  return 0; 
 }

其中,Msg1 是一個 helloworld 類的對象,set_id() 用來設(shè)置 id 的值。SerializeToOstream 將對象序列化后寫入一個 fstream 流。

代碼清單 3 列出了 reader 的主要代碼。
清單2. Reader.cpp代碼

#include "lm.helloworld.pb.h" 
…
 void ListMsg(const lm::helloworld & msg) { 
  cout << msg.id() << endl; 
  cout << msg.str() << endl; 
 } 
  
 int main(int argc, char* argv[]) 
{ 
  lm::helloworld msg1; 
  
  { 
    fstream input("./log", ios::in | ios::binary); 
    if (!msg1.ParseFromIstream(&input)) { 
      cerr << "Failed to parse address book." << endl; 
      return -1; 
    } 
  } 
  
  ListMsg(msg1); 
  … 
 }

同樣,Reader 聲明類 helloworld 的對象 msg1,然后利用 ParseFromIstream 從一個 fstream 流中讀取信息并反序列化。此后,ListMsg 中采用 get 方法讀取消息的內(nèi)部信息,并進行打印輸出操作。

運行結(jié)果

運行 Writer 和 Reader 的結(jié)果如下:

>writer 
>reader 
101 
Hello

Reader 讀取文件 log 中的序列化信息并打印到屏幕上。

這個例子本身并無意義,但只要您稍加修改就可以將它變成更加有用的程序。比如將磁盤替換為網(wǎng)絡(luò) socket,那么就可以實現(xiàn)基于網(wǎng)絡(luò)的數(shù)據(jù)交換任務(wù)。而存儲和交換正是 Protobuf 最有效的應(yīng)用領(lǐng)域。

和其他類似技術(shù)的比較

看完這個簡單的例子之后,希望您已經(jīng)能理解 Protobuf 能做什么了,那么您可能會說,世上還有很多其他的類似技術(shù)啊,比如 XML,JSON,Thrift 等等。和他們相比,Protobuf 有什么不同呢?

簡單說來 Protobuf 的主要優(yōu)點就是:簡單,快。
這有測試為證,項目 thrift-protobuf-compare 比較了這些類似的技術(shù),圖 1 顯示了該項目的一項測試結(jié)果,Total Time.

圖1 性能測試結(jié)果

Total Time 指一個對象操作的整個時間,包括創(chuàng)建對象,將對象序列化為內(nèi)存中的字節(jié)序列,然后再反序列化的整個過程。從測試結(jié)果可以看到 Protobuf 的成績很好,感興趣的讀者可以自行到網(wǎng)站 [https://github.com/eishay/jvm-serializers/wiki)上了解更詳細的測試結(jié)果。

Protobuf 的優(yōu)點

  • Protobuf 有如 XML,不過它更小、更快、也更簡單。你可以定義自己的數(shù)據(jù)結(jié)構(gòu),然后使用代碼生成器生成的代碼來讀寫這個數(shù)據(jù)結(jié)構(gòu)。你甚至可以在無需重新部署程序的情況下更新數(shù)據(jù)結(jié)構(gòu)。只需使用 Protobuf 對數(shù)據(jù)結(jié)構(gòu)進行一次描述,即可利用各種不同語言或從各種不同數(shù)據(jù)流中對你的結(jié)構(gòu)化數(shù)據(jù)輕松讀寫。

  • 它有一個非常棒的特性,即“向后”兼容性好,人們不必破壞已部署的、依靠“老”數(shù)據(jù)格式的程序就可以對數(shù)據(jù)結(jié)構(gòu)進行升級。這樣您的程序就可以不必擔心因為消息結(jié)構(gòu)的改變而造成的大規(guī)模的代碼重構(gòu)或者遷移的問題。因為添加新的消息中的 field 并不會引起已經(jīng)發(fā)布的程序的任何改變。

  • Protobuf 語義更清晰,無需類似 XML 解析器的東西(因為 Protobuf 編譯器會將 .proto 文件編譯生成對應(yīng)的數(shù)據(jù)訪問類以對 Protobuf 數(shù)據(jù)進行序列化、反序列化操作)。

  • 使用 Protobuf 無需學(xué)習(xí)復(fù)雜的文檔對象模型,Protobuf 的編程模式比較友好,簡單易學(xué),同時它擁有良好的文檔和示例,對于喜歡簡單事物的人們而言,Protobuf 比其他的技術(shù)更加有吸引力。

Protobuf 的不足

  • Protbuf 與 XML 相比也有不足之處。它功能簡單,無法用來表示復(fù)雜的概念。

  • XML 已經(jīng)成為多種行業(yè)標準的編寫工具,Protobuf 只是 Google 公司內(nèi)部使用的工具,在通用性上還差很多。

  • 由于文本并不適合用來描述數(shù)據(jù)結(jié)構(gòu),所以 Protobuf 也不適合用來對基于文本的標記文檔(如 HTML)建模。另外,由于 XML 具有某種程度上的自解釋性,它可以被人直接讀取編輯,在這一點上 Protobuf 不行,它以二進制的方式存儲,除非你有 .proto 定義,否則你沒法直接讀出 Protobuf 的任何內(nèi)容

高級應(yīng)用話題

更復(fù)雜的 Message

到這里為止,我們只給出了一個簡單的沒有任何用處的例子。在實際應(yīng)用中,人們往往需要定義更加復(fù)雜的 Message。我們用“復(fù)雜”這個詞,不僅僅是指從個數(shù)上說有更多的 fields 或者更多類型的 fields,而是指更加復(fù)雜的數(shù)據(jù)結(jié)構(gòu):

嵌套 Message

代碼清單 4 給出一個嵌套 Message 的例子。

清單 4. 嵌套 Message 的例子

message Person { 
 required string name = 1; 
 required int32 id = 2;        // Unique ID number for this person. 
 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 Person 中,定義了嵌套消息 PhoneNumber,并用來定義 Person 消息中的 phone 域。這使得人們可以定義更加復(fù)雜的數(shù)據(jù)結(jié)構(gòu)。

Import Message

在一個 .proto 文件中,還可以用 Import 關(guān)鍵字引入在其他 .proto 文件中定義的消息,這可以稱做 Import Message,或者 Dependency Message。例子如下所示:
清單 5. Import Message

import common.header; 
 
message youMsg{ 
 required common.info_header header = 1; 
 required string youPrivateData = 2; 
}

其中 ,common.info_header定義在common.header包內(nèi)。

Import Message 的用處主要在于提供了方便的代碼管理機制,類似 C 語言中的頭文件。您可以將一些公用的 Message 定義在一個 package 中,然后在別的 .proto 文件中引入該 package,進而使用其中的消息定義。
Google Protocol Buffer 可以很好地支持嵌套 Message 和引入 Message,從而讓定義復(fù)雜的數(shù)據(jù)結(jié)構(gòu)的工作變得非常輕松愉快。

動態(tài)編譯

一般情況下,使用 Protobuf 的人們都會先寫好 .proto 文件,再用 Protobuf 編譯器生成目標語言所需要的源代碼文件。將這些生成的代碼和應(yīng)用程序一起編譯。

可是在某且情況下,人們無法預(yù)先知道 .proto 文件,他們需要動態(tài)處理一些未知的 .proto 文件。比如一個通用的消息轉(zhuǎn)發(fā)中間件,它不可能預(yù)知需要處理怎樣的消息。這需要動態(tài)編譯 .proto 文件,并使用其中的 Message。

Protobuf 提供了 google::protobuf::compiler 包來完成動態(tài)編譯的功能。主要的類叫做 importer,定義在 importer.h 中。使用 Importer 非常簡單,下圖展示了與 Import 和其它幾個重要的類的關(guān)系。

圖 2. Importer 類

首先構(gòu)造一個 importer 對象。構(gòu)造函數(shù)需要兩個入口參數(shù),一個是 source Tree 對象,該對象指定了存放 .proto 文件的源目錄。第二個參數(shù)是一個 error collector 對象,該對象有一個 AddError 方法,用來處理解析 .proto 文件時遇到的語法錯誤。
之后,需要動態(tài)編譯一個 .proto 文件時,只需調(diào)用 importer 對象的 import 方法。非常簡單。

那么我們?nèi)绾问褂脛討B(tài)編譯后的 Message 呢?我們需要首先了解幾個其他的類。
Package google::protobuf::compiler 中提供了以下幾個類,用來表示一個 .proto 文件中定義的 message,以及 Message 中的 field,如圖3所示。


圖 3. 各個 Compiler 類之間的關(guān)系

類 FileDescriptor 表示一個編譯后的 .proto 文件;類 Descriptor 對應(yīng)該文件中的一個 Message;類 FieldDescriptor 描述一個 Message 中的一個具體 Field。

比如編譯完 lm.helloworld.proto 之后,可以通過如下代碼得到 lm.helloworld.id 的定義:

清單 7. 得到 lm.helloworld.id 的定義的代碼

const protobuf::Descriptor *desc = 
   importer_.pool()->FindMessageTypeByName(“l(fā)m.helloworld”); 
const protobuf::FieldDescriptor* field = 
   desc->pool()->FindFileByName (“id”);

通過 Descriptor,F(xiàn)ieldDescriptor 的各種方法和屬性,應(yīng)用程序可以獲得各種關(guān)于 Message 定義的信息。比如通過 field->name() 得到 field 的名字。這樣,您就可以使用一個動態(tài)定義的消息了。

編寫新的 proto 編譯器

隨 Google Protocol Buffer 源代碼一起發(fā)布的編譯器 protoc 支持 3 種編程語言:C++,java 和 Python。但使用 Google Protocol Buffer 的 Compiler 包,您可以開發(fā)出支持其他語言的新的編譯器。

類 CommandLineInterface 封裝了 protoc 編譯器的前端,包括命令行參數(shù)的解析,proto 文件的編譯等功能。您所需要做的是實現(xiàn)類 CodeGenerator 的派生類,實現(xiàn)諸如代碼生成等后端工作:

程序的大體框架如圖所示:


圖 4. XML 編譯器框圖

在 main() 函數(shù)內(nèi),生成 CommandLineInterface 的對象 cli,調(diào)用其 RegisterGenerator() 方法將新語言的后端代碼生成器 yourG 對象注冊給 cli 對象。然后調(diào)用 cli 的 Run() 方法即可。

這樣生成的編譯器和 protoc 的使用方法相同,接受同樣的命令行參數(shù),cli 將對用戶輸入的 .proto 進行詞法語法等分析工作,最終生成一個語法樹。該樹的結(jié)構(gòu)如圖所示。


圖 5. 語法樹

其根節(jié)點為一個 FileDescriptor 對象(請參考“動態(tài)編譯”一節(jié)),并作為輸入?yún)?shù)被傳入 yourG 的 Generator() 方法。在這個方法內(nèi),您可以遍歷語法樹,然后生成對應(yīng)的您所需要的代碼。簡單說來,要想實現(xiàn)一個新的 compiler,您只需要寫一個 main 函數(shù),和一個實現(xiàn)了方法 Generator() 的派生類即可。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容