原文https://7byte.github.io/2017/07/09/protobuf-encoding/
英文原文:https://developers.google.com/protocol-buffers/docs/encoding
本文描述了protocol buffer 消息的二進制格式。當你在你的應用中使用protocol buffer 時無需了解這些細節。但是,要想理解不同的protocol buffer 格式對最終編碼生成的消息大小有何影響,了解這些將非常有幫助。
一個簡單消息
假設你有如下簡單的消息定義:
message Test1 {
required int32 a = 1;
}
在應用中,創建一個名為Test1
的消息并且對a
賦值150。然后將這個消息序列化到一個輸出流。如果查看編碼出的消息內容,你將看到如下的三個字節:
08 96 01
只有幾個數值——這些東西代表什么?且看下文……
Base 128 Varints
在理解上面的簡單消息是如何編碼之前,你需要先了解什么是Varints。Varints是使用一個或多個字節對整型數字序列化的方法。數值越小,序列化后所占的字節數越少。
除了最后一個字節,Varints中的每個字節設置了最高有效位(msb)用來標示后續的字節也是該數字的一部分。一個以補碼表示的數字按7位一組的方式分成若干組,每組存儲在字節的低7位,低位數字在前面(即小端序)。
例如,數字1只有一個字節,所以不需要設置msb:
0000 0001
數字300要稍微復雜一些:
1010 1100 0000 0010
你怎么知道這是300?首先,去掉每個字節中的msb,因為msb的作用只是告訴我們是否到達了數字的末尾(正如你所看到的,第一個字節設置了msb,表示后續字節也是varint的一部分):
1010 1100 0000 0010
→ 010 1100 000 0010
反轉兩組7位數值,因為varints將數字的低位放在前面。然后把兩組數值拼接起來:
000 0010 010 1100
→ 000 0010 ++ 010 1100
→ 100101100
→ 256 + 32 + 8 + 4 = 300
消息結構
正如你所知道的,protocol buffer消息是一系列鍵值對。一個二進制消息使用字段的編號作為鍵——字段的名字和聲明類型只有在解碼結束后參考消息類型定義(比如.proto
文件)才能確定。
編碼消息時,所有的鍵和值被拼接在一起寫入字節流。解碼消息時,分析器需要能夠跳過無法識別的字段。這樣的話,就可以在消息中加入新的字段時無須破壞舊程序,即使舊程序不知道這些新字段。為了這個目的,每個鍵值對的“key”實際上是由兩個值組成——.proto
文件中字段的編號和一個wire type,wire type提供了“value”的長度信息。
可用的wire type如下:
類型 | 含義 | 用途 |
---|---|---|
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 |
消息流里的每個鍵是一個varint值:(field_number << 3) | wire_type
,也就是說數字的低3位用來記錄wire type。
再次回到我們的簡單示例,你現在知道了字節流的第一個數字永遠是一個varint類型的鍵值,即示例中的08,也就是(去掉msb后):
000 1000
取最后3位可得wire type(0),然后右移3位可得字段編號(1)。現在你知道了tag是1,并且字段的值是varint類型。用上一節中學到的varint解碼相關知識,你將會看到后面兩個字節存儲了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
其它值類型
有符號整型
正如你在上一節里看到的,protocol buffer中所有wire type為0的類型都被編碼成varint。然而,有符號int類型(sint32
和sint64
)和“標準”int類型(int32
和int64
)這兩者在處理負數編碼時有很重要的區別。如果你使用int32
或者int64
作為一個負數的類型,varint編碼后的結果永遠有10字節之長,實際上就像是在處理一個非常大的無符號整型。如果你使用有符號int類型(sint32
或sint64
),varint將使用更高效的ZigZag(之字形)編碼。
ZigZag編碼將有符號整數映射到無符號整數,這樣,具有較小絕對值的數字(例如-1)也具有較小的varint編碼值。它以“之字形”來回處理正負整數,所以-1被編碼成1,1被編碼成2,-2被編碼成3,以此類推,如下表所示:
有符號原始數字 | 編碼為 |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
2147483647 | 4294967294 |
-2147483648 | 4294967295 |
換句話說,對每個sint32
類型的n
值以如下方式編碼:
(n << 1) ^ (n >> 31)
對64位:
(n << 1) ^ (n >> 63)
注意,第二個位移操作——(n >> 31)
——是一個算數位移。也就是說,位移的結果要么所有位全是0(如果n
是正數),要么全是1(如果n
是負數)。
當解析到sint32
或是sint64
時,對應的值被解碼為原始的、有符號形式。
非varint數字
非varint數字類型比較簡單——double
和fixed64
使用wire type 1來告訴解析器有一塊64位大小的數據,同理,float
和fixed32
使用wire type 5來告訴解析器有一塊32位大小的數據。兩種類型中的數值均以小端字節序編碼。
字符串
wire type 2(長度分隔)表示值為一個包含長度信息、攜帶指定個數字節的varint。
message Test2 {
required string b = 2;
}
對b賦值“testing”將會得到:
12 07 74 65 73 74 69 6e 67
紅色的字節(74 65 73 74 69 6e 67
)是“testing”的UTF8編碼。這里的鍵是0x12→ tag = 2, type = 2。表示長度的varint值為7,你瞧,我們看到在它后面有七個字節——我們的字符串。
嵌套消息
這里有一個message定義,它以嵌套了我們的示例消息:
message Test3 {
required Test1 c = 3;
}
同樣對Test1中的a
賦值為150,編碼后:
1a 03 08 96 01
可以看到,最后三個字節與第一個例子中的完全相同(08 96 01
)。在它們前面是數字3——嵌套消息與字符串(wire type = 2)的處理方式完全相同。
可選和repeated元素
如果一個proto2消息定義了repeated
元素(沒有設置[packed]=true
),那么編碼后的消息會有0個或多個擁有相同tag編號的鍵值對。這些重復值不必連續,它們可能和其它的字段交錯在一起。在解析時,元素相對于彼此的順序保持不變,但是相對于其它字段的順序信息會丟失。在proto3中,repeated字段使用packed 編碼,你可以閱讀下面的內容。
對proto3中的任何一個non-repeated字段,或是proto2中的optional
字段,編碼后的消息可能有,也可能沒有這個tag編號字段的鍵值對。
通常來說,編碼的消息永遠不會有一個non-repeated字段的多個示例。然而,真碰到這種情況時我們期望解析器也能夠正常處理。對數字類型和字符串,如果同一個字段出現多次,解析器將接受它所看到的最后哪個值。對于嵌套消息字段,解析器會合并同一個字段的的多個實例,就像使用Message::MergeFrom
方法——所有的歧義字段都會用后一個實例中的字段替換,歧義嵌套消息被合并,并且repeated字段會拼接起來。這些規則的效果就是,解析兩個消息的串聯,與你分別解析這兩條消息然后合并的結果完全相同。即:
MyMessage message;
message.ParseFromString(str1 + str2);
等于:
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
這個特性有時候很有用,因為它允許你在完全不知道兩個消息的類型時合并它們。
Packed repeated字段
2.1.0版本中引入了packed repeated字段,在proto2中它被聲明成帶有[packed=true]
選項的repeated字段。在proto3中,repeated字段默認會按packed處理。這些功能與repeated字段很類似,但是有不一樣的編碼規則。編碼生成的消息中不會出現包含零元素的packed repeated字段。否則,所有的元素都被打包成一個wire type為2(長度分隔)的鍵值對。每個元素都按正常的、相同的方式編碼,除了前面沒有tag。
例如,想象你有這樣一個消息類型:
message Test4 {
repeated int32 d = 4 [packed=true];
}
現在,假設你構造了一個Test4
,給repeated字段d
設值3、270和86942。然后,編碼的形式將是:
22 // tag (field number 4, wire type 2)
06 // payload size (6 bytes)
03 // first element (varint 3)
8E 02 // second element (varint 270)
9E A7 05 // third element (varint 86942)
只有原始數字類型的repeated字段(使用varint、32-bit、或者64-bit類型)才能聲明為“packed”。
請注意,盡管通常沒有理由將多個鍵值對編碼成一個packed repeated字段,但編碼器必須做好接受多個鍵值對的準備。在這種情況下,所有載荷(payloads)應該拼接到一起。每一對都必須包含完整的元素。
Protocol buffer解析器必須能夠解析以packed
方式編譯而成的repeated字段,就好像它們沒有被打包一樣,反之亦然。這樣就能允許以向前和向后兼容的方式向現有字段添加[packed=true]
。
字段順序
雖然可以在.proto
中以任意順序使用字段號,但當消息被序列化時,已知字段應該按字段號順序寫入,正如所提供的C++、Java和Python序列化代碼。這使得解析代碼可以使用依賴于字段號的優化。但是,protocol buffer解析器必須能夠以任意順序解析字段,因為并非所有消息都是通過簡單地序列化一個對象來創建的——例如,通過簡單地拼接兩個消息來合并它們有時候是很有用的。
如果一個消息具有未知字段,當前的Java和C++實現會在順序排列已知字段之后按任意順序寫入未知字段。當前的Python實現不處理未知字段。
版權說明
除另有說明外,本頁面的內容是根據知識共享署名3.0許可證授權的,代碼示例根據Apache 2.0許可證授權。有關詳細信息,請參閱我們的網站政策。Java是甲骨文和/或其子公司的注冊商標。