該系列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。