Protocol Buffer
ProtocolBuffer是Google公司的一個開源項目,用於結構化數據串行化的靈活、高效、自動的方法,有如XML,不過它更小、更快、也更簡單。你可以定義自己的數據結構,然后使用代碼生成器生成的代碼來讀寫這個數據結構。你甚至可以在無需重新部署程序的情況下更新數據結構。
一個例子
比如有個電子商務的系統(假設用C++實現),其中的模塊A需要發送大量的訂單信息給模塊B,通訊的方式使用socket。
假設訂單包括如下屬性:
--------------------------------
時間:time(用整數表示)
客戶id:userid(用整數表示)
交易金額:price(用浮點數表示)
交易的描述:desc(用字符串表示)
--------------------------------
如果使用protobuf實現,首先要寫一個proto文件(不妨叫Order.proto),在該文件中添加一個名為"Order"的message結構,用來描述通訊協議中的結構化數據。該文件的內容大致如下:
message Order
{
required int32 time = 1;
required int32 userid = 2;
required float price = 3;
optional string desc = 4;
}
然后,使用protobuf內置的編譯器編譯該proto。由於本例子的模塊是C++,你可以通過protobuf編譯器讓它生成 C++語言的“訂單包裝類”(一般來說,一個message結構會生成一個包裝類)。
然后你使用類似下面的代碼來序列化/解析該訂單包裝類:
發送方:
Order order; order.set_time(XXXX); order.set_userid(123); order.set_price(100.0f); order.set_desc("a test order"); string sOrder; order.SerailzeToString(&sOrder);
然后調用某種socket的通訊庫把序列化之后的字符串sOrder發送出去;
接收方:
string sOrder; // 先通過網絡通訊庫接收到數據,存放到某字符串sOrder // ...... Order order; if(order.ParseFromString(sOrder)){ // 解析該字符串 cout << "userid:" << order.userid() << endl << "desc:" << order.desc() << endl; } else { cerr << "parse error!" << endl; }
有了這種代碼生成機制,開發人員再也不用吭哧吭哧地編寫那些協議解析的代碼了。萬一將來需求發生變更,要求給訂單再增加一個“狀態”的屬性,那只需要在Order.proto文件中增加一行代碼。對於發送方,只要增加一行設置狀態的代碼;對於接收方只要增加一行讀取狀態的代碼。另外,如果通訊雙方使用不同的編程語言來實現,使用這種機制可以有效確保兩邊的模塊對於協議的處理是一致的。
從某種意義上講,可以把proto文件看成是描述通訊協議的規格說明書(或者叫接口規范)。這種伎倆其實老早就有了,搞過微軟的COM編程或者接觸過CORBA的同學,應該都能從中看到IDL的影子。它們的思想是相通滴。
ProtoBuf支持向后兼容(backward compatible)和向前兼容(forward compatible):
- 向后兼容,比如說,當接收方升級了之后,它能夠正確識別發送方發出的老版本的協議。由於老版本沒有“狀態”這個屬性,在擴充協議時,可以考 慮把“狀態”屬性設置成非必填 的(optional),或者給“狀態”屬性設置一個缺省值;
- 向前兼容,比如說,當發送方升級了之后,接收方能夠正常識別發送方發出的新版本的協議。這時候,新增加的“狀態”屬性會被忽略;
向后兼容和向前兼容有啥用捏?俺舉個例子:當你維護一個很龐大的分布式系統時,由於你無法同時 升級所有 模塊,為了保證在升級過程中,整個系統能夠盡可能不受影響,就需要盡量保證通訊協議的向后兼容或向前兼容。
proto文件
如上面的例子,使用protobuf,首先需要在一個 .proto 文件中定義你需要做串行化的數據結構信息。每個ProtocolBuffer信息是一小段邏輯記錄,包含一系列的鍵值對。
例如:
syntax = "proto3";
message Person {
required string name=1;
required int32 id=2;
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),你就可以運行ProtocolBuffer編譯器,將你的 .proto 文件編譯成特定語言的類。這些類提供了簡單的方法訪問每個字段(像是 query() 和 set_query() ),像是訪問類的方法一樣將結構串行化或反串行化。例如你可以選擇C++語言,運行編譯如上的協議文件生成類叫做 Person 。隨后你就可以在應用中使用這個類來串行化的讀取報文信息。你可以這么寫代碼:
Person person;
person.set_name("John Doe"); person.set_id(1234); person.set_email("jdoe@example.com"); fstream.output("myfile",ios::out | ios::binary); person.SerializeToOstream(&output);
然后,你可以讀取報文中的數據:
fstream input("myfile",ios::in | ios:binary);
Person person;
person.ParseFromIstream(&input); cout << "Name: " << person.name() << endl; cout << "E-mail: " << person.email() << endl;
你可以在不影響向后兼容的情況下隨意給數據結構增加字段,舊有的數據會忽略新的字段。所以如果使用ProtocolBuffer作為通信協議,你可以無須擔心破壞現有代碼的情況下擴展協議。
當用protocol buffer編譯器來運行.proto文件時,編譯器將生成所選擇語言的代碼,這些代碼可以操作在.proto文件中定義的消息類型,包括獲取、設置字段值,將消息序列化到一個輸出流中,以及從一個輸入流中解析消息。
- 對C++來說,編譯器會為每個.proto文件生成一個.h文件和一個.cc文件,.proto文件中的每一個消息有一個對應的類。
- 對Java來說,編譯器為每一個消息類型生成了一個.java文件,以及一個特殊的Builder類(該類是用來創建消息類接口的)。
- 對Python來說,有點不太一樣——Python編譯器為.proto文件中的每個消息類型生成一個含有靜態描述符的模塊,,該模塊與一個元類(metaclass)在運行時(runtime)被用來創建所需的Python數據訪問類。
- 對go來說,編譯器會位每個消息類型生成了一個.pd.go文件。
protobuf3 消息定義
message由至少一個字段組合而成,類似於C語言中的結構。每個字段都有一定的格式:
字段規則 | 數據類型 | 字段名稱 | = | 字段編碼值 | [字段默認值]
字段規則:
- singular:格式良好的消息可以包含該字段中的零個或一個(但不超過一個);
- repeated:此字段可以在格式良好的消息中重復任意次數(包括零),將保留重復值的順序;
字段名稱:
字段名稱的命名與C、C++、Java等語言的變量命名方式幾乎是相同的。
protobuf建議字段的命名采用以下划線分割的駝峰式,例如 first_name 而不是firstName。
字段編碼值:
編碼值的取值范圍為 1~2^32(4294967296)。
消息中的字段的編碼值無需連續,只要是合法的,並且不能在同一個消息中有字段包含相同的編碼值。
[1,15]之內的標識號在編碼的時候會占用一個字節。[16,2047]之內的標識號則占用2個字節。所以應該為那些頻繁出現的消息元素保留 [1,15]之內的標識號。
切記:要為將來有可能添加的、頻繁出現的標識號預留一些標識號。
字段默認值:
當一個消息被解析的時候,如果被編碼的信息不包含一個特定的singular元素,被解析的對象鎖對應的域被設置位一個默認值,對於不同類型指定如下:
- 對於string,默認是一個空string
- 對於bytes,默認是一個空的bytes
- 對於bool,默認是false
- 對於數值類型,默認是0
- 對於枚舉,默認是第一個定義的枚舉值,必須為0;
- 對於消息類型(message),域沒有被設置,確切的消息是根據語言確定的;
注:對於標量消息域,一旦消息被解析,就無法判斷域釋放被設置為默認值(例如,例如boolean值是否被設置為false)還是根本沒有被設置。你應該在定義你的消息類型時非常注意。例如,比如你不應該定義boolean的默認值false作為任何行為的觸發方式。也應該注意如果一個標量消息域被設置為標志位,這個值不應該被序列化傳輸。
對於可重復域的默認值是空(通常情況下是對應語言中空列表)。
另外:
- message消息支持嵌套定義,消息可以包含另一個消息作為其字段,也可以在消息內定義一個新的消息;
- proto定義文件支持import導入其它proto定義文件;
- 每個proto文件指定一個package名稱,對於java解析為java中的包。對於C++則解析為名稱空間。
標量數值類型
| .proto Type | Notes | C++ | Java | Python | Go |
|---|---|---|---|---|---|
| double | double | double | float | float64 | |
| float | float | float | float | float32 | |
| int32 | 使用變長編碼,對於負值的效率很低,如果你的域有可能有負值,請使用sint64替代 | int32 | int | int | int32 |
| uint32 | 使用變長編碼 | uint32 | int | int/long | uint32 |
| uint64 | 使用變長編碼 | uint64 | long | int/long | uint64 |
| sint32 | 使用變長編碼,這些編碼在負值時比int32高效的多 | int32 | int | int | int32 |
| sint64 | 使用變長編碼,有符號的整型值。編碼時比通常的int64高效。 | int64 | long | int/long | int64 |
| fixed32 | 總是4個字節,如果數值總是比總是比228大的話,這個類型會比uint32高效。 | uint32 | int | int | uint32 |
| fixed64 | 總是8個字節,如果數值總是比總是比256大的話,這個類型會比uint64高效。 | uint64 | long | int/long | uint64 |
| sfixed32 | 總是4個字節 | int32 | int | int | int32 |
| sfixed64 | 總是8個字節 | int64 | long | int/long | int64 |
| bool | bool | boolean | bool | bool | |
| string | 一個字符串必須是UTF-8編碼或者7-bit ASCII編碼的文本。 | string | String | str/unicode | string |
| bytes | 可能包含任意順序的字節數據。 | string | ByteString | str | []byte |
其中:
varint(type=0),動態類型:
- 每個字節第一位表示有無后續字節,有為1,無為0,(雙字節,低字節在前,高字節在后);
- 剩余7位倒序合並。
舉例: 300 的二進制為 10 0101100
第一位:1(有后續) + 0101100
第二位:0(無后續) + 0000010
最終結果: 101011000000010
枚舉類型
在下面的例子中,在消息格式中添加了一個叫做Corpus的枚舉類型——它含有所有可能的值 ——以及一個類型為Corpus的字段:
message SearchRequest { string query = 1; int32 page_number = 2; int32 result_per_page = 3; enum Corpus { UNIVERSAL = 0; WEB = 1; IMAGES = 2; LOCAL = 3; NEWS = 4; PRODUCTS = 5; VIDEO = 6; } Corpus corpus = 4; }
如你所見,Corpus枚舉的第一個常量映射為0:每個枚舉類型必須將其第一個類型映射為0,這是因為:
- 必須有有一個0值,我們可以用這個0值作為默認值。
- 這個零值必須為第一個元素,為了兼容proto2語義,枚舉類的第一個值總是默認值。
