該系列Blog的內容主體主要源自於Protocol Buffer的官方文檔,而代碼示例則抽取於當前正在開發的一個公司內部項目的Demo。這樣做的目的主要在於不僅可以保持Google文檔的良好風格和系統性,同時再結合一些比較實用和通用的用例,這樣就更加便於公司內部的培訓,以及和廣大網友的技術交流。需要說明的是,Blog的內容並非line by line的翻譯,其中包含一些經驗性總結,與此同時,對於一些不是非常常用的功能並未予以說明,有興趣的開發者可以直接查閱Google的官方文檔。
一、爲什麼使用Protocol Buffer?
在回答這個問題之前,我們還是先給出一個在實際開發中經常會遇到的系統場景。比如:我們的客戶端程序是使用Java開發的,可能運行自不同的平臺,如:Linux、Windows或者是Android,而我們的服務器程序通常是基於Linux平臺並使用C++開發完成的。在這兩種程序之間進行數據通訊時存在多種方式用於設計消息格式,如:
1. 直接傳遞C/C++語言中一字節對齊的結構體數據,只要結構體的聲明爲定長格式,那麼該方式對於C/C++程序而言就非常方便了,僅需將接收到的數據按照結構體類型強行轉換即可。事實上對於變長結構體也不會非常麻煩。在發送數據時,也只需定義一個結構體變量並設置各個成員變量的值之後,再以char*的方式將該二進制數據發送到遠端。反之,該方式對於Java開發者而言就會非常繁瑣,首先需要將接收到的數據存於ByteBuffer之中,再根據約定的字節序逐個讀取每個字段,並將讀取後的值再賦值給另外一個值對象中的域變量,以便於程序中其他代碼邏輯的編寫。對於該類型程序而言,聯調的基準是必須客戶端和服務器雙方均完成了消息報文構建程序的編寫後才能展開,而該設計方式將會直接導致Java程序開發的進度過慢。即便是Debug階段,也會經常遇到Java程序中出現各種域字段拼接的小錯誤。
2. 使用SOAP協議(WebService)作爲消息報文的格式載體,由該方式生成的報文是基於文本格式的,同時還存在大量的XML描述信息,因此將會大大增加網絡IO的負擔。又由於XML解析的複雜性,這也會大幅降低報文解析的性能。總之,使用該設計方式將會使系統的整體運行性能明顯下降。
對於以上兩種方式所產生的問題,Protocol Buffer均可以很好的解決,不僅如此,Protocol Buffer還有一個非常重要的優點就是可以保證同一消息報文新舊版本之間的兼容性。至於具體的方式我們將會在後續的博客中給出。
二、定義第一個Protocol Buffer消息。
創建擴展名爲.proto的文件,如:MyMessage.proto,並將以下內容存入該文件中。
message LogonReqMessage {
required int64 acctID = 1;
required string passwd = 2;
}
這裏將給出以上消息定義的關鍵性說明。
1. message是消息定義的關鍵字,等同於C++中的struct/class,或是Java中的class。
2. LogonReqMessage爲消息的名字,等同於結構體名或類名。
3. required前綴表示該字段爲必要字段,即在序列化和反序列化之前該字段必須已經被賦值。與此同時,在Protocol Buffer中還存在另外兩個類似的關鍵字,optional和repeated,帶有這兩種限定符的消息字段則沒有required字段這樣的限制。相比於optional,repeated主要用於表示數組字段。具體的使用方式在後面的用例中均會一一列出。
4. int64和string分別表示長整型和字符串型的消息字段,在Protocol Buffer中存在一張類型對照表,即Protocol Buffer中的數據類型與其他編程語言(C++/Java)中所用類型的對照。該對照表中還將給出在不同的數據場景下,哪種類型更爲高效。該對照表將在後面給出。
5. acctID和passwd分別表示消息字段名,等同於Java中的域變量名,或是C++中的成員變量名。
6. 標籤數字1和2則表示不同的字段在序列化後的二進制數據中的佈局位置。在該例中,passwd字段編碼後的數據一定位於acctID之後。需要注意的是該值在同一message中不能重複。另外,對於Protocol
Buffer而言,標籤值爲1到15的字段在編碼時可以得到優化,即標籤值和類型信息僅佔有一個byte,標籤範圍是16到2047的將佔有兩個bytes,而Protocol Buffer可以支持的字段數量則爲2的29次方減一。有鑑於此,我們在設計消息結構時,可以儘可能考慮讓repeated類型的字段標籤位於1到15之間,這樣便可以有效的節省編碼後的字節數量。
三、定義第二個(含有枚舉字段)Protocol Buffer消息。
//在定義Protocol Buffer的消息時,可以使用和C++/Java代碼同樣的方式添加註釋。
enum UserStatus {
OFFLINE = 0; //表示處於離線狀態的用戶
ONLINE = 1; //表示處於在線狀態的用戶
}
message UserInfo {
required int64 acctID = 1;
required string name = 2;
required UserStatus status = 3;
}
這裏將給出以上消息定義的關鍵性說明(僅包括上一小節中沒有描述的)。
1. enum是枚舉類型定義的關鍵字,等同於C++/Java中的enum。
2. UserStatus爲枚舉的名字。
3. 和C++/Java中的枚舉不同的是,枚舉值之間的分隔符是分號,而不是逗號。
4. OFFLINE/ONLINE爲枚舉值。
5. 0和1表示枚舉值所對應的實際整型值,和C/C++一樣,可以爲枚舉值指定任意整型值,而無需總是從0開始定義。如:
enum OperationCode {
LOGON_REQ_CODE = 101;
LOGOUT_REQ_CODE = 102;
RETRIEVE_BUDDIES_REQ_CODE = 103;
LOGON_RESP_CODE = 1001;
LOGOUT_RESP_CODE = 1002;
RETRIEVE_BUDDIES_RESP_CODE = 1003;
}
四、定義第三個(含有嵌套消息字段)Protocol Buffer消息。
我們可以在同一個.proto文件中定義多個message,這樣便可以很容易的實現嵌套消息的定義。如:
enum UserStatus {
OFFLINE = 0;
ONLINE = 1;
}
message UserInfo {
required int64 acctID = 1;
required string name = 2;
required UserStatus status = 3;
}
message LogonRespMessage {
required LoginResult logonResult = 1;
required UserInfo userInfo = 2;
}
這裏將給出以上消息定義的關鍵性說明(僅包括上兩小節中沒有描述的)。
1. LogonRespMessage消息的定義中包含另外一個消息類型作爲其字段,如UserInfo userInfo。
2. 上例中的UserInfo和LogonRespMessage被定義在同一個.proto文件中,那麼我們是否可以包含在其他.proto文件中定義的message呢?Protocol Buffer提供了另外一個關鍵字import,這樣我們便可以將很多通用的message定義在同一個.proto文件中,而其他消息定義文件可以通過import的方式將該文件中定義的消息包含進來,如:
import "myproject/CommonMessages.proto"
五、限定符(required/optional/repeated)的基本規則。
1. 在每個消息中必須至少留有一個required類型的字段。
2. 每個消息中可以包含0個或多個optional類型的字段。
3. repeated表示的字段可以包含0個或多個數據。需要說明的是,這一點有別於C++/Java中的數組,因爲後兩者中的數組必須包含至少一個元素。
4. 如果打算在原有消息協議中添加新的字段,同時還要保證老版本的程序能夠正常讀取或寫入,那麼對於新添加的字段必須是optional或repeated。道理非常簡單,老版本程序無法讀取或寫入新增的required限定符的字段。
六、類型對照表。
.proto Type | Notes | C++ Type | Java Type |
double | double | double | |
float | float | float | |
int32 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint32 instead. | int32 | int |
int64 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint64 instead. | int64 | long |
uint32 | Uses variable-length encoding. | uint32 | int |
uint64 | Uses variable-length encoding. | uint64 | long |
sint32 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int32s. | int32 | int |
sint64 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int64s. | int64 | long |
fixed32 | Always four bytes. More efficient than uint32 if values are often greater than 228. | uint32 | int |
fixed64 | Always eight bytes. More efficient than uint64 if values are often greater than 256. | uint64 | long |
sfixed32 | Always four bytes. | int32 | int |
sfixed64 | Always eight bytes. | int64 | long |
bool | bool | boolean | |
string | A string must always contain UTF-8 encoded or 7-bit ASCII text. | string | String |
bytes | May contain any arbitrary sequence of bytes. | string | ByteString |
七、Protocol Buffer消息升級原則。
在實際的開發中會存在這樣一種應用場景,即消息格式因爲某些需求的變化而不得不進行必要的升級,但是有些使用原有消息格式的應用程序暫時又不能被立刻升級,這便要求我們在升級消息格式時要遵守一定的規則,從而可以保證基於新老消息格式的新老程序同時運行。規則如下:
1. 不要修改已經存在字段的標籤號。
2. 任何新添加的字段必須是optional和repeated限定符,否則無法保證新老程序在互相傳遞消息時的消息兼容性。
3. 在原有的消息中,不能移除已經存在的required字段,optional和repeated類型的字段可以被移除,但是他們之前使用的標籤號必須被保留,不能被新的字段重用。
4. int32、uint32、int64、uint64和bool等類型之間是兼容的,sint32和sint64是兼容的,string和bytes是兼容的,fixed32和sfixed32,以及fixed64和sfixed64之間是兼容的,這意味着如果想修改原有字段的類型時,爲了保證兼容性,只能將其修改爲與其原有類型兼容的類型,否則就將打破新老消息格式的兼容性。
5. optional和repeated限定符也是相互兼容的。
八、Packages。
我們可以在.proto文件中定義包名,如:
package ourproject.lyphone;
該包名在生成對應的C++文件時,將被替換爲名字空間名稱,即namespace ourproject { namespace lyphone。而在生成的Java代碼文件中將成爲包名。
九、Options。
Protocol Buffer允許我們在.proto文件中定義一些常用的選項,這樣可以指示Protocol Buffer編譯器幫助我們生成更爲匹配的目標語言代碼。Protocol Buffer內置的選項被分爲以下三個級別:
1. 文件級別,這樣的選項將影響當前文件中定義的所有消息和枚舉。
2. 消息級別,這樣的選項僅影響某個消息及其包含的所有字段。
3. 字段級別,這樣的選項僅僅響應與其相關的字段。
下面將給出一些常用的Protocol Buffer選項。
1. option java_package = "com.companyname.projectname";
java_package是文件級別的選項,通過指定該選項可以讓生成Java代碼的包名爲該選項值,如上例中的Java代碼包名爲com.companyname.projectname。與此同時,生成的Java文件也將會自動存放到指定輸出目錄下的com/companyname/projectname子目錄中。如果沒有指定該選項,Java的包名則爲package關鍵字指定的名稱。該選項對於生成C++代碼毫無影響。
2. option java_outer_classname = "LYPhoneMessage";
java_outer_classname是文件級別的選項,主要功能是顯示的指定生成Java代碼的外部類名稱。如果沒有指定該選項,Java代碼的外部類名稱爲當前文件的文件名部分,同時還要將文件名轉換爲駝峯格式,如:my_project.proto,那麼該文件的默認外部類名稱將爲MyProject。該選項對於生成C++代碼毫無影響。
注:主要是因爲Java中要求同一個.java文件中只能包含一個Java外部類或外部接口,而C++則不存在此限制。因此在.proto文件中定義的消息均爲指定外部類的內部類,這樣才能將這些消息生成到同一個Java文件中。在實際的使用中,爲了避免總是輸入該外部類限定符,可以將該外部類靜態引入到當前Java文件中,如:import static com.company.project.LYPhoneMessage.*。
3. option optimize_for = LITE_RUNTIME;
optimize_for是文件級別的選項,Protocol Buffer定義三種優化級別SPEED/CODE_SIZE/LITE_RUNTIME。缺省情況下是SPEED。
SPEED: 表示生成的代碼運行效率高,但是由此生成的代碼編譯後會佔用更多的空間。
CODE_SIZE: 和SPEED恰恰相反,代碼運行效率較低,但是由此生成的代碼編譯後會佔用更少的空間,通常用於資源有限的平臺,如Mobile。
LITE_RUNTIME: 生成的代碼執行效率高,同時生成代碼編譯後的所佔用的空間也是非常少。這是以犧牲Protocol Buffer提供的反射功能爲代價的。因此我們在C++中鏈接Protocol Buffer庫時僅需鏈接libprotobuf-lite,而非libprotobuf。在Java中僅需包含protobuf-java-2.4.1-lite.jar,而非protobuf-java-2.4.1.jar。
注:對於LITE_MESSAGE選項而言,其生成的代碼均將繼承自MessageLite,而非Message。
4. [pack = true]: 因爲歷史原因,對於數值型的repeated字段,如int32、int64等,在編碼時並沒有得到很好的優化,然而在新近版本的Protocol Buffer中,可通過添加[pack=true]的字段選項,以通知Protocol Buffer在爲該類型的消息對象編碼時更加高效。如:
repeated int32 samples = 4 [packed=true]。
注:該選項僅適用於2.3.0以上的Protocol Buffer。
5. [default = default_value]: optional類型的字段,如果在序列化時沒有被設置,或者是老版本的消息中根本不存在該字段,那麼在反序列化該類型的消息是,optional的字段將被賦予類型相關的缺省值,如bool被設置爲false,int32被設置爲0。Protocol Buffer也支持自定義的缺省值,如:
optional int32 result_per_page = 3 [default = 10]。
十、命令行編譯工具。
protoc --proto_path=IMPORT_PATH --cpp_out=DST_DIR --java_out=DST_DIR --python_out=DST_DIR path/to/file.proto
這裏將給出上述命令的參數解釋。
1. protoc爲Protocol Buffer提供的命令行編譯工具。
2. --proto_path等同於-I選項,主要用於指定待編譯的.proto消息定義文件所在的目錄,該選項可以被同時指定多個。
3. --cpp_out選項表示生成C++代碼,--java_out表示生成Java代碼,--python_out則表示生成Python代碼,其後的目錄爲生成後的代碼所存放的目錄。
4. path/to/file.proto表示待編譯的消息定義文件。
注:對於C++而言,通過Protocol Buffer編譯工具,可以將每個.proto文件生成出一對.h和.cc的C++代碼文件。生成後的文件可以直接加載到應用程序所在的工程項目中。如:MyMessage.proto生成的文件爲MyMessage.pb.h和MyMessage.pb.cc。
//C++實例
一、生成目標語言代碼。
下面的命令幫助我們將MyMessage.proto文件中定義的一組Protocol Buffer格式的消息編譯成目標語言(C++)的代碼。至於消息的內容,我們會在後面以分段的形式逐一列出,同時也會在附件中給出所有源代碼。
protoc -I=./message --cpp_out=./src ./MyMessage.proto
從上面的命令行參數中可以看出,待編譯的文件爲MyMessage.proto,他存放在當前目錄的message子目錄下。--cpp_out參數則指示編譯工具我們需要生成目標語言是C++,輸出目錄是當前目錄的src子目錄。在本例中,生成的目標代碼文件名是MyMessage.pb.h和MyMessage.pb.cc。
二、簡單message生成的C++代碼。
這裏先定義一個最簡單的message,其中只是包含原始類型的字段。
option optimize_for = LITE_RUNTIME;
message LogonReqMessage {
required int64 acctID = 1;
required string passwd = 2;
}
由於我們在MyMessage文件中定義選項optimize_for的值爲LITE_RUNTIME,因此由該.proto文件生成的所有C++類的父類均爲::google::protobuf::MessageLite,而非::google::protobuf::Message。MessageLite類是Message的父類,在MessageLite中將缺少Protocol Buffer對反射的支持,而此類功能均在Message類中提供了具體的實現。對於我們的項目而言,整個系統相對比較封閉,不會和更多的外部程序進行交互,與此同時,我們的客戶端部分又是運行在Android平臺,有鑑於此,我們考慮使用LITE版本的Protocol
Buffer。這樣不僅可以得到更高編碼效率,而且生成代碼編譯後所佔用的資源也會更少,至於反射所能帶來的靈活性和極易擴展性,對於該項目而言完全可以忽略。下面我們來看一下由message LogonReqMessage生成的C++類的部分聲明,以及常用方法的說明性註釋。
1 class LogonReqMessage : public ::google::protobuf::MessageLite { 2 public: 3 LogonReqMessage(); 4 virtual ~LogonReqMessage(); 5 6 // implements Message ---------------------------------------------- 7 //下面的成員函數均實現自MessageLite中的虛函數。 8 //創建一個新的LogonReqMessage對象,等同於clone。 9 LogonReqMessage* New() const; 10 //用另外一個LogonReqMessage對象初始化當前對象,等同於賦值操作符重載(operator=) 11 void CopyFrom(const LogonReqMessage& from); 12 //清空當前對象中的所有數據,既將所有成員變量置爲未初始化狀態。 13 void Clear(); 14 //判斷當前狀態是否已經初始化。 15 bool IsInitialized() const; 16 //在給當前對象的所有變量賦值之後,獲取該對象序列化後所需要的字節數。 17 int ByteSize() const; 18 //獲取當前對象的類型名稱。 19 ::std::string GetTypeName() const; 20 21 // required int64 acctID = 1; 22 //下面的成員函數都是因message中定義的acctID字段而生成。 23 //這個靜態成員表示AcctID的標籤值。命名規則是k + FieldName(駝峯規則) + FieldNumber。 24 static const int kAcctIDFieldNumber = 1; 25 //如果acctID字段已經被設置返回true,否則false。 26 inline bool has_acctid() const; 27 //執行該函數後has_acctid函數將返回false,而下面的acctid函數則返回acctID的缺省值。 28 inline void clear_acctid(); 29 //返回acctid字段的當前值,如果沒有設置則返回int64類型的缺省值。 30 inline ::google::protobuf::int64 acctid() const; 31 //爲acctid字段設置新值,調用該函數後has_acctid函數將返回true。 32 inline void set_acctid(::google::protobuf::int64 value); 33 34 // required string passwd = 2; 35 //下面的成員函數都是因message中定義的passwd字段而生成。這裏生成的函數和上面acctid 36 //生成的那組函數基本相似。因此這裏只是列出差異部分。 37 static const int kPasswdFieldNumber = 2; 38 inline bool has_passwd() const; 39 inline void clear_passwd(); 40 inline const ::std::string& passwd() const; 41 inline void set_passwd(const ::std::string& value); 42 //對於字符串類型字段設置const char*類型的變量值。 43 inline void set_passwd(const char* value); 44 inline void set_passwd(const char* value, size_t size); 45 //可以通過返回值直接給passwd對象賦值。在調用該函數之後has_passwd將返回true。 46 inline ::std::string* mutable_passwd(); 47 //釋放當前對象對passwd字段的所有權,同時返回passwd字段對象指針。調用此函數之後,passwd字段對象 48 //的所有權將移交給調用者。此後再調用has_passwd函數時將返回false。 49 inline ::std::string* release_passwd(); 50 private: 51 ... ... 52 };
下面是讀寫LogonReqMessage對象的C++測試代碼和說明性註釋。
1 void testSimpleMessage() 2 { 3 printf("==================This is simple message.================\n"); 4 //序列化LogonReqMessage對象到指定的內存區域。 5 LogonReqMessage logonReq; 6 logonReq.set_acctid(20); 7 logonReq.set_passwd("Hello World"); 8 //提前獲取對象序列化所佔用的空間並進行一次性分配,從而避免多次分配 9 //而造成的性能開銷。通過該種方式,還可以將序列化後的數據進行加密。 10 //之後再進行持久化,或是發送到遠端。 11 int length = logonReq.ByteSize(); 12 char* buf = new char[length]; 13 logonReq.SerializeToArray(buf,length); 14 //從內存中讀取並反序列化LogonReqMessage對象,同時將結果打印出來。 15 LogonReqMessage logonReq2; 16 logonReq2.ParseFromArray(buf,length); 17 printf("acctID = %I64d, password = %s\n",logonReq2.acctid(),logonReq2.passwd().c_str()); 18 delete [] buf; 19 }
三、嵌套message生成的C++代碼。
enum UserStatus {
OFFLINE = 0;
ONLINE = 1;
}
enum LoginResult {
LOGON_RESULT_SUCCESS = 0;
LOGON_RESULT_NOTEXIST = 1;
LOGON_RESULT_ERROR_PASSWD = 2;
LOGON_RESULT_ALREADY_LOGON = 3;
LOGON_RESULT_SERVER_ERROR = 4;
}
message UserInfo {
required int64 acctID = 1;
required string name = 2;
required UserStatus status = 3;
}
message LogonRespMessage {
required LoginResult logonResult = 1;
required UserInfo userInfo = 2; //這裏嵌套了UserInfo消息。
}
對於上述消息生成的C++代碼,UserInfo因爲只是包含了原始類型字段,因此和上例中的LogonReqMessage沒有太多的差別,這裏也就不在重複列出了。由於LogonRespMessage消息中嵌套了UserInfo類型的字段,在這裏我們將僅僅給出該消息生成的C++代碼和關鍵性註釋。
1 class LogonRespMessage : public ::google::protobuf::MessageLite { 2 public: 3 LogonRespMessage(); 4 virtual ~LogonRespMessage(); 5 6 // implements Message ---------------------------------------------- 7 ... ... //這部分函數和之前的例子一樣。 8 9 // required .LoginResult logonResult = 1; 10 //下面的成員函數都是因message中定義的logonResult字段而生成。 11 //這一點和前面的例子基本相同,只是類型換做了枚舉類型LoginResult。 12 static const int kLogonResultFieldNumber = 1; 13 inline bool has_logonresult() const; 14 inline void clear_logonresult(); 15 inline LoginResult logonresult() const; 16 inline void set_logonresult(LoginResult value); 17 18 // required .UserInfo userInfo = 2; 19 //下面的成員函數都是因message中定義的UserInfo字段而生成。 20 //這裏只是列出和非消息類型字段差異的部分。 21 static const int kUserInfoFieldNumber = 2; 22 inline bool has_userinfo() const; 23 inline void clear_userinfo(); 24 inline const ::UserInfo& userinfo() const; 25 //可以看到該類並沒有生成用於設置和修改userInfo字段set_userinfo函數,而是將該工作 26 //交給了下面的mutable_userinfo函數。因此每當調用函數之後,Protocol Buffer都會認爲 27 //該字段的值已經被設置了,同時has_userinfo函數亦將返回true。在實際編碼中,我們可以 28 //通過該函數返回userInfo字段的內部指針,並基於該指針完成userInfo成員變量的初始化工作。 29 inline ::UserInfo* mutable_userinfo(); 30 inline ::UserInfo* release_userinfo(); 31 private: 32 ... ... 33 };
下面是讀寫LogonRespMessage對象的C++測試代碼和說明性註釋。
1 void testNestedMessage() 2 { 3 printf("==================This is nested message.================\n"); 4 LogonRespMessage logonResp; 5 logonResp.set_logonresult(LOGON_RESULT_SUCCESS); 6 //如上所述,通過mutable_userinfo函數返回userInfo字段的指針,之後再初始化該對象指針。 7 UserInfo* userInfo = logonResp.mutable_userinfo(); 8 userInfo->set_acctid(200); 9 userInfo->set_name("Tester"); 10 userInfo->set_status(OFFLINE); 11 int length = logonResp.ByteSize(); 12 char* buf = new char[length]; 13 logonResp.SerializeToArray(buf,length); 14 15 LogonRespMessage logonResp2; 16 logonResp2.ParseFromArray(buf,length); 17 printf("LogonResult = %d, UserInfo->acctID = %I64d, UserInfo->name = %s, UserInfo->status = %d\n" 18 ,logonResp2.logonresult(),logonResp2.userinfo().acctid(),logonResp2.userinfo().name().c_str(),logonResp2.userinfo().status()); 19 delete [] buf; 20 }
四、repeated嵌套message生成的C++代碼。
message BuddyInfo {
required UserInfo userInfo = 1;
required int32 groupID = 2;
}
message RetrieveBuddiesResp {
required int32 buddiesCnt = 1;
repeated BuddyInfo buddiesInfo = 2;
}
對於上述消息生成的代碼,我們將只是針對RetrieveBuddiesResp消息所對應的C++代碼進行詳細說明,其餘部分和前面小節的例子基本相同,可直接參照。而對於RetrieveBuddiesResp類中的代碼,我們也僅僅是對buddiesInfo字段生成的代碼進行更爲詳細的解釋。
1 class RetrieveBuddiesResp : public ::google::protobuf::MessageLite { 2 public: 3 RetrieveBuddiesResp(); 4 virtual ~RetrieveBuddiesResp(); 5 6 ... ... //其餘代碼的功能性註釋均可參照前面的例子。 7 8 // repeated .BuddyInfo buddiesInfo = 2; 9 static const int kBuddiesInfoFieldNumber = 2; 10 //返回數組中成員的數量。 11 inline int buddiesinfo_size() const; 12 //清空數組中的所有已初始化成員,調用該函數後,buddiesinfo_size函數將返回0。 13 inline void clear_buddiesinfo(); 14 //返回數組中指定下標所包含元素的引用。 15 inline const ::BuddyInfo& buddiesinfo(int index) const; 16 //返回數組中指定下標所包含元素的指針,通過該方式可直接修改元素的值信息。 17 inline ::BuddyInfo* mutable_buddiesinfo(int index); 18 //像數組中添加一個新元素。返回值即爲新增的元素,可直接對其進行初始化。 19 inline ::BuddyInfo* add_buddiesinfo(); 20 //獲取buddiesInfo字段所表示的容器,該函數返回的容器僅用於遍歷並讀取,不能直接修改。 21 inline const ::google::protobuf::RepeatedPtrField< ::BuddyInfo >& 22 buddiesinfo() const; 23 //獲取buddiesInfo字段所表示的容器指針,該函數返回的容器指針可用於遍歷和直接修改。 24 inline ::google::protobuf::RepeatedPtrField< ::BuddyInfo >* 25 mutable_buddiesinfo(); 26 private: 27 ... ... 28 };
下面是讀寫RetrieveBuddiesResp對象的C++測試代碼和說明性註釋。
1 void testRepeatedMessage() 2 { 3 printf("==================This is repeated message.================\n"); 4 RetrieveBuddiesResp retrieveResp; 5 retrieveResp.set_buddiescnt(2); 6 BuddyInfo* buddyInfo = retrieveResp.add_buddiesinfo(); 7 buddyInfo->set_groupid(20); 8 UserInfo* userInfo = buddyInfo->mutable_userinfo(); 9 userInfo->set_acctid(200); 10 userInfo->set_name("user1"); 11 userInfo->set_status(OFFLINE); 12 13 buddyInfo = retrieveResp.add_buddiesinfo(); 14 buddyInfo->set_groupid(21); 15 userInfo = buddyInfo->mutable_userinfo(); 16 userInfo->set_acctid(201); 17 userInfo->set_name("user2"); 18 userInfo->set_status(ONLINE); 19 20 int length = retrieveResp.ByteSize(); 21 char* buf = new char[length]; 22 retrieveResp.SerializeToArray(buf,length); 23 24 RetrieveBuddiesResp retrieveResp2; 25 retrieveResp2.ParseFromArray(buf,length); 26 printf("BuddiesCount = %d\n",retrieveResp2.buddiescnt()); 27 printf("Repeated Size = %d\n",retrieveResp2.buddiesinfo_size()); 28 //這裏僅提供了通過容器迭代器的方式遍歷數組元素的測試代碼。 29 //事實上,通過buddiesinfo_size和buddiesinfo函數亦可循環遍歷。 30 RepeatedPtrField<BuddyInfo>* buddiesInfo = retrieveResp2.mutable_buddiesinfo(); 31 RepeatedPtrField<BuddyInfo>::iterator it = buddiesInfo->begin(); 32 for (; it != buddiesInfo->end(); ++it) { 33 printf("BuddyInfo->groupID = %d\n", it->groupid()); 34 printf("UserInfo->acctID = %I64d, UserInfo->name = %s, UserInfo->status = %d\n" 35 , it->userinfo().acctid(), it->userinfo().name().c_str(),it->userinfo().status()); 36 } 37 delete [] buf; 38 }
最後需要說明的是,Protocol Buffer仍然提供了很多其它非常有用的功能,特別是針對序列化的目的地,比如文件流和網絡流等。與此同時,也提供了完整的官方文檔和規範的命名規則,在很多情況下,可以直接通過函數的名字便可獲悉函數所完成的工作。
//protocol buffer數據編碼
讓你對Protocol Buffer知其然亦知其所以然的文檔,即便你在並不瞭解這其中的技術細節和處理機制的情況下,仍然能夠在你的應用程序中正常的使用Protocol Buffer,然而我相信,通過對這些細節和機制的深入瞭解,不僅可以讓你更好的使用和駕馭Protocol Buffer,而且還能深深地感受到Google工程師的智慧和高超的編程技藝,因此在我看來,深入的研習對我們編程能力的提高和思路的拓寬都是大有裨益的。不積跬步無以致千里。
一、簡單消息編碼佈局:
讓我們先看一下下面的消息定義示例:
message Test1 {
required int32 a = 1;
}
假設我們在應用程序中將字段a的值設置爲150(十進制),此後再將該對象序列化到Binary文件中,你可以看到文件的數據爲:
08 96 01
這3個字節的含義又是什麼呢?它們又是按照什麼樣的編碼規則生成的呢?讓我們拭目以待。
二、Base 128 Varints:
在理解Protocol Buffer的編碼規則之前,你首先需要了解varints。varints是一種使用一個或多個字節表示整型數據的方法。其中數值本身越小,其所佔用的字節數越少。
在varint中,除了最後一個字節之外的每個字節中都包含一個msb(most significant bit)設置(使用最高位),這意味着其後的字節是否和當前字節一起來表示同一個整型數值。而字節中的其餘七位將用於存儲數據本身。由此我們可以簡單的解釋一下Base 128,通常而言,整數數值都是由字節表示,其中每個字節爲8位,即Base 256。然而在Protocol Buffer的編碼中,最高位成爲了msb,只有後面的7位存儲實際的數據,因此我們稱其爲Base
128(2的7次方)。
比如數字1,它本身只佔用一個字節即可表示,所以它的msb沒有被設置,如:
0000 0001
再比如十進制數字300,它的編碼後表示形式爲:
1010 1100 0000 0010
對於Protocol Buffer而言又是如何將上面的字節佈局還原成300呢?這裏我們需要做的第一步是drop掉每個字節的msb。從上例中可以看出第一個字節(1010 1100)的msb(最高位)被設置爲1,這說明後面的字節將連同該字節表示同一個數值,而第二個字節(0000 0010)的msb爲0,因此該字節將爲表示該數值的最後一個字節了,後面如果還有其他的字節數據,將表示其他的數據。
1010 1100 0000 0010
-> 010 1100 000 0010
上例中的第二行已經將第一行中每一個字節的msb去除。由於Protocol Buffer是按照Little Endian的方式進行數據佈局的,因此我們這裏需要將兩個字節的位置進行翻轉。
010 1100 000 0010
-> 000 0010 010 1100 //翻轉第一行的兩個字節
-> 100101100 //將翻轉後的兩個字節直接連接並去除高位0
-> 256 + 32 + 8 + 4 = 300 //將上一行的二進制數據換算成十進制,其值爲300
三、消息結構:
Protocol Buffer中的消息都是由一系列的鍵值對構成的。每個消息的二進制版本都是使用標籤號作爲key,而每一個字段的名字和類型均是在解碼的過程中根據目標類型(反序列化後的對象類型)進行配對的。在進行消息編碼時,key/value被連接成字節流。在解碼時,解析器可以直接跳過不識別的字段,這樣就可以保證新老版本消息定義在新老程序之間的兼容性,從而有效的避免了使用older消息格式的older程序在解析newer程序發來的newer消息時,一旦遇到未知(新添加的)字段時而引發的解析和對象初始化的錯誤。最後,我們介紹一下字段標號和字段類型是如何進行編碼的。下面先列出Protocol
Buffer可以支持的字段類型。
Type | Meaning | Used For |
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64-bit | fixed64, sfixed64, double |
2 | Length-delimited | string, bytes, embedded messages, packed repeated fields |
3 | Start group | groups (deprecated) |
4 | End group | groups (deprecated) |
5 | 32-bit | fixed32, sfixed32, float |
由於在編碼後每一個字段的key都是varint類型,key的值是由字段標號和字段類型合成編碼所得,其公式如下:
field_number << 3 | field_type
由此看出,key的最後3個bits用於存儲字段的類型信息。那麼在使用該編碼時,Protocol Buffer所支持的字段類型將不會超過8種。這裏我們可以進一步計算出Protocol Buffer在一個消息中可以支持的字段數量爲2的29次方減一。現在我們再來回顧一下之前給出的Test1消息被序列化後的第一個字節08的由來。
0000 1000
-> 000 1000 //drop掉msb(最高位)
最低的3位表示字段類型,即0爲varint。我們再將結果右移3位( >> 3),此時得到的結果爲1,即字段a在消息Test1中的標籤號。通過這樣的結果,Protocol Buffer的解碼器可以獲悉當前字段的標籤號是1,其後所跟隨數據的類型爲varint。現在我們可以繼續利用上面講到的知識分析出後兩個字節(96 01)的由來。
96 01 = 1001 0110 0000 0001
-> 001 0110 000 0001 //drop兩個字節的msb
-> 000 0001 001 0110 //翻轉高低字節
-> 10010110 //去掉最高位中沒用的0
-> 128 + 16 + 4 + 2 = 150
四、更多的值類型:
1. 有符號整型
如前所述,類型0表示varint,其中包含int32/int64/uint32/uint64/sint32/sint64/bool/enum。在實際使用中,如果當前字段可以表示爲負數,那麼對於int32/int64和sint32/sint64而言,它們在進行編碼時將存在着較大的差別。如果使用int32/int64表示一個負數,該字段的值無論是-1還是-2147483648,其編碼後長度將始終爲10個字節,就如同對待一個很大的無符號整型一樣。反之,如果使用的是sint32/sint64,Protocol
Buffer將會採用ZigZag編碼方式,其編碼後的結果將會更加高效。
這裏簡單講述一下ZigZag編碼,該編碼會將有符號整型映射爲無符號整型,以便絕對值較小的負數仍然可以有較小的varint編碼值,如-1。下面是ZigZag對照表:
Signed Original | Encoded As |
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
2147483647 | 4294967294 |
-2147483648 | 4294967295 |
其公式爲:
(n << 1) ^ (n >> 31) //sint32
(n << 1> ^ (n >> 63) //sint64
需要補充說明的是,Protocol Buffer在實現上述位移操作時均採用的算術位移,因此對於(n >> 31)和(n >> 63)而言,如果n爲負值位移後的結果就是-1,否則就是0。
注:簡單解釋一下C語言中的算術位移和邏輯位移。他們的左移操作都是相同的,即低位補0,高位直接移除。不同的是右移操作,邏輯位移比較簡單,高位全部補0。而算術位移則需要視當前值的符號位而定,補進的位和符號位相同,即正數全補0,負數全補1。換句話說,算術位移右移時要保證符號位的一致性。在C語言中,如果使用 int變量位移時就是算術位移,uint變量位移時是邏輯位移。
2. Non-varint數值型
double/fixed64始終都佔用8個字節,float/fixed32始終佔用4個字節。
3. Strings
其類型值爲2,key信息之後是字節數組的長度信息,最後在緊隨指定長度的實際數據值信息。如:
message Test2 {
required string b = 2;
}
現在我們設置b的值爲"testing"。其編碼後數據如下:
12 07 74 65 73 74 69 6E 67
第一個字節0x12表示key,通過解碼可以得到字段類型2和字段標號2。第二個字節07表示testing的長度。後面7個紅色高亮的字節則表示testing。
五、嵌入消息:
這裏是一個包含嵌入消息的消息定義。
message Test3 {
required Test1 c = 3;
}
此時我們先將Test1的a字段值設置爲150,其編碼結果如下:
1A 03 08 96 01
從上面的結果可以看出08 96 01和之前直接編碼Test1時是完全一致的,只是在前面增加了key(字段類型 + 標號)和長度信息。新增信息的解碼方式和含義與前面的Strings完全相同,這裏不再重複解釋了。
六、Packed Repeated Fields:
Protocol Buffer從2.1.0版本開始引入了[pack = true]的字段級別選項。如果設置該選項,那麼元素數量爲0的repeated字段將不會被編碼,否則數組中的所有元素會被編碼成一個單一的key/value形式。畢竟數組中的每一個元素都具有相同的字段類型和標號。該編碼形式,對包含較小值的整型元素而言,優化後的編碼結果可以節省更多的空間。如:
message Test4 {
repeated int32 d = 4 [pack=true];
}
這裏我們假設d字段包含3個元素,值分別爲3,270,86942。編碼結果如下:
22 //key (字段標號4,類型爲2)
06 //數據中所有元素所佔用的字節數量
03 //第一個元素(varint 3)
8E 02 //第二個元素(varint 270)
9E A7 05 //第三個元素(varint 86942)
七、字段順序:
在.proto文件中定義消息的字段標號時,可以是不連續的,但是如果將其定義爲連續遞增的數值,將獲得更好的編碼和解碼性能。
結束語:
本篇博客是Protocol Buffer技術詳解系列的最後一篇博客,同時該系列博客又將是開源學習之旅系列主題中的第一個系列,希望今後能夠藉此平臺與大家進行更多的技術交流,共同提高。如有意見或問題,歡迎留言。