1. 概述
前三篇文章《Google Protocol Buffers 概述》《Google Protocol Buffers 入門》《Protocol Buffers 語法指南》 一步一步將大家帶入Protocol Buffers的世界,我們已經基本能夠使用Protocol Buffers生成代碼,編碼,解析,輸出級讀入序列化數據。該篇主要講述PB message的底層二進制格式。不了解該部分內容,並不影響我們在項目中使用Protocol Buffers,但是了解一下PB格式是如何做到smaller這一層,確實是很有必要的。Protobuf 序列化后所生成的二進制消息非常緊湊,這得益於 Protobuf 采用的非常巧妙的 Encoding 方法。
2. 一個簡單的例子
.proto文件定義一條簡單的message:
message Test1 { required int32 a = 1; }
使用該.proto生成相應類並寫入一條message到一個文件中,這里我寫入test.txt文件:
public static void main(String[] args) throws IOException { Simple simple = Simple.newBuilder().setId(150).build(); FileOutputStream output = new FileOutputStream("abc.txt"); simple.writeTo(output); output.close(); }
使用UltraEdit打開,二進制格式查看,發現只占用了三個字節:
整條message存儲只用了三個字節,甚至小於一個整形的大小,這是什么意思?怎么做到的?Protobuf 序列化后所生成的二進制消息非常緊湊,這得益於 Protobuf 采用的非常巧妙的 Encoding 方法。
3. Varint
在了解PB encoding之前,我們先來了解一下varint。Varint 是一種緊湊的表示數字的方法。它用一個或多個字節來表示一個數字,值越小的數字使用越少的字節數。這能減少用來表示數字的字節數。
Varint 中的每個 byte 的最高位 bit 有特殊的含義,如果該位為 1,表示后續的 byte 也是該數字的一部分,如果該位為 0,則結束。其他的 7 個 bit 都用來表示數字。因此小於 128 的數字都可以用一個 byte 表示。大於 128 的數字,會用兩個字節。
例如整數1的表示,僅需一個字節:
0000 0001
例如300的表示,需要兩個字節:
1010 1100 0000 0010
采 用 Varint,對於很小的 int32 類型的數字,則可以用 1 個 byte 來表示。當然凡事都有好的也有不好的一面,采用 Varint 表示法,大的數字則需要 5 個 byte 來表示。從統計的角度來說,一般不會所有的消息中的數字都是大數,因此大多數情況下,采用 Varint 后,可以用更少的字節數來表示數字信息。
下圖演示了 Google Protocol Buffer 如何解析兩個 bytes。注意到最終計算前將兩個 byte 的位置相互交換過一次,這是因為 Google Protocol Buffer 字節序采用 little-endian 的方式。
4. Message 格式
消息經過序列化后會成為一個二進制數據流,該流中的數據為一系列的 Key-Value 對。如下圖所示:
采用這種 Key-Pair 結構無需使用分隔符來分割不同的 Field。對於可選的 Field,如果消息中不存在該 field,那么在最終的 Message Buffer 中就沒有該 field,這些特性都有助於節約消息本身的大小。
二進制格式的message使用數字標簽作為key,Key 用來標識具體的 field,在解包的時候,Protocol Buffer 根據 Key 就可以知道相應的 Value 應該對應於消息中的哪一個 field。
將 message編碼后,key-values被編碼成字節流存儲。在message解碼時,PB 解析器會跳過(忽略)不能夠識別的字段,所以,message即使增加新的字段,也不會影響老程序代碼,因為老程序代碼根本就不能識別這些新添加的字段。 為此,該處,key需要特殊設計。
上邊我們說,“二進制格式的message使用數字標簽作為key”,此處的數字標簽,並非單純的數字標簽,而是數字標簽與傳輸類型的組合,根據傳輸類型能夠確定出值的長度。
key的定義:
(field_number << 3) | wire_type
可以看到 Key 由兩部分組成。第一部分是 field_number,第二部分為 wire_type。表示 Value 的傳輸類型。也就是說,key中的后三位,是值得傳輸類型。有關移位操作簡單知識,可以參見:Java位操作基本知識
Wire Type 可能的類型如下表所示:
Type | Meaning | Used For |
---|---|---|
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64-bit | fixed64, sfixed64, double |
2 | Length-delimi | string, bytes, embedded messages, packed repeated fields |
3 | Start group | Groups (deprecated) |
4 | End group | Groups (deprecated) |
5 | 32-bit | fixed32, sfixed32, float |
5. 分析產生數據
在第二部分簡單的例子中,寫入message后,我們看到最終輸出文件中包含三個數字:08 96 01,這是如何得來的呢?
如圖:
至此我們知道數字標簽是1,值類型為varint。使用第四部分我們分析的,來解碼96 01,即為150:
96 01 = 1001 0110 0000 0001 → 000 0001 ++ 001 0110 (drop the msb and reverse the groups of 7 bits) → 10010110 → 2 + 4 + 16 + 128 = 150
注意:數值部分,低位在前,高位在后。
6. 其他數值類型
6.1 有符號整數
細 心的讀者或許會看到在 Type 0 所能表示的數據類型中有 int32 和 sint32 這兩個非常類似的數據類型。Google Protocol Buffer 區別它們的主要意圖也是為了減少 encoding 后的字節數。這部分,主要是針對負數來設計的。
在計 算機內,一個負數一般會被表示為一個很大的整數,因為計算機定義負數的符號位為數字的最高位。如果采用 Varint 表示一個負數,那么一定需要 10 個 byte長度。為此 Google Protocol Buffer 定義了 sint32 這種類型,采用 zigzag 編碼。將所有整數映射成無符號整數,然后再采用varint編碼方式編碼,這樣,絕對值小的整數,編碼后也會有一個較小的varint編碼值。
Zigzag映射函數為:
Zigzag(n) = (n << 1) ^ (n >> 31), n為sint32時
Zigzag(n) = (n << 1) ^ (n >> 63), n為sint64時
按照這種方法,-1將會被編碼成1,1將會被編碼成2,-2會被編碼成3,如下表所示:
Signed Original | Encoded As |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
2 | 4 |
-3 | 5 |
… | … |
2147483647 | 4294967294 |
-2147483648 | 4294967295 |
6.2 Non-varint 數字
Non-varint數字比較簡單,double 、fixed64 的線路類型為 1,在解析式告訴解析器,該類型的數據需要一個64位大小的數據塊即可。同理,float和fixed32的線路類型為5,給其32位數據塊即可。兩種情況下,都是高位在后,低位在前。
6.3 String
線路類型為2的數據,是一種指定長度的編碼方式:key+length+content,key的編碼方式是統一的,length采用varints編碼方式,content就是由length指定長度的Bytes。定義如下的message格式:
message Test2 { required string b = 2; }
設置該值為"testing",二進制格式查看:
12 07 74 65 73 74 69 6e 67
紅色字節為“testing”的UTF8代碼。
此處,key是16進制表示的,所以展開是:
12 -> 0001 0010,后三位010為wire type = 2,0001 0010右移三位為0000 0010,即tag=2。
length此處為7,后邊跟着7個bytes,即我們的字符創"testing"。
6.4 嵌套message
定義如下嵌套消息:
message Test3 { required Test1 c = 3; }
同第二部分一樣,設置字段為整數150,編碼后的字節為:
1a 03 <span style="color: red;">08 96 01</span>
我們發現,后三個字節跟我們第一個例子中的一摸一樣(08 96 01),他們前邊有一個長度限制03,課件嵌套消息跟string是一摸一樣的,其wire type 也為2。
6.5 wire type = 3、4
該兩個字段已經廢棄不再使用,故忽略吧~
7. 可選字段和重復字段
假 如定義的message中有repeated元素並且該聲明后並未使用[packed=true]選項,編碼后的message有一個或者多個包含相同 tag數字的key-value對。這些重復的value不需要連續的出現;他們可能與其他的字段間隔的出現。盡管他們是無序的,但是在解析時,他們是需 要有序的。
對於可選字段,編碼后的message中,擁有該數字標簽的key-value對可有可無。
通常,編碼后的 message,其required字段和optional字段最多只有一個實例。但是解析器卻需要處理多余一個的情況。對於數字類型和string類 型,如果同一值出現多次,解析器接受最后一個它收到的值。對於內嵌字段,解析器合並(merge)它接收到的同一字段的多個實例。就如MergeFrom 方法一樣,所有單數的字段,后來的會替換先前的,所有單數的內嵌message都會被合並(merge),所有的repeated字段,都會串聯起來。這 樣的規則的結果是,解析兩個串聯的編碼后的message,與分別解析兩個message然后merge,結果是一樣的。例如:
MyMessage message; message.ParseFromString(str1 + str2);
這種做法,等價於:
MyMessage message, message2; message.ParseFromString(str1); message2.ParseFromString(str2); message.MergeFrom(message2);
這種方法有時是非常有用的。比如,即使不知道message的類型,也能夠將其合並。
7.1 設置了[packed = true]的repeated字段
在 2.1.0后,PB引入了該種類型,其與repeated字段一樣,只是在末尾聲明了[packed=true]。類似repeated字段卻又不同。對 於packed repeated字段,如果message中沒有賦值,則不會出現在編碼后的數據中。否則的話,該字段所有的元素會被打包到單一一個key-value對 中,且它的wire type=2,長度確定。每個元素正常編碼,只不過其前沒有標簽。例如有如下message類型:
message Test4 { repeated int32 d = 4 [packed=true]; }
構造一個Test4字段,並且設置repeated字段d兩個值:3、270和86942,編碼后:
22 // tag 0010 0010(field number 010 0 = 4, wire type 010 = 2) 06 // payload size (設置的length = 6 bytes) 03 // first element (varint 3) 8E 02 // second element (varint 270) 9E A7 05 // third element (varint 86942)
僅有原子數字類型(varint, 32-bit, or 64-bit)可以被聲明為“packed”
有一點需要注意,對於packed的repeated字段,盡管通常沒有理由將其編碼為多個key-value對,編碼器必須有接收多個key-pair對的准備。這種情況下,payload 必須是串聯的,每個pair必須包含完整的元素。
8. 字段順序
簡單來說只有兩點:
- 編碼/解碼與字段順序無關,這一點由key-value機制就能保證
- 對於未知的字段,編碼的時候會把它寫在序列化完的已知字段后面。
推薦閱讀順序,希望給你帶來收獲~