nano版本的protobuf下載地址為http://koti.kapsi.fi/~jpa/nanopb/download/
該系列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。