[翻譯] ProtoBuf 官方文檔(五)- 編碼

翻譯查閱外網(wǎng)資料過程中遇到的比較優(yōu)秀的文章和資料,一是作為技術(shù)參考以便日后查閱,二是訓(xùn)練英文能力。

此文翻譯自 Protocol Buffers 官方文檔 Encoding 部分

翻譯為意譯,不會照本宣科的字字對照翻譯
以下為原文內(nèi)容翻譯

編碼

本文檔描述了 Protocol Buffer 的 Message 的二進(jìn)制格式。你無需了解這個知識點也能夠在你的應(yīng)用程序中順利使用 protocol buffers,但了解不同 protocol buffer 的格式如何影響最終編碼消息的大小這一點非常有用。

一個簡單的 Message

假設(shè)你有以下一個非常簡單的消息定義:

message Test1 {
  optional int32 a = 1;
}

在一個應(yīng)用程序中,你創(chuàng)建一個 Test1 message,并將 a 設(shè)置為150。然后,將 message 序列化為輸出流。如果你能夠查看相應(yīng)的編碼后的結(jié)果,你會看到以下三個字節(jié):

08 96 01

到目前為止,是如此的小巧和簡單-但是這幾個字節(jié)具體代表什么含義?請往下讀...

Base 128 Varints (編碼方法)

要理解上述涉及到的簡單編碼,你首先需要理解 varints 的概念。所謂的 varints 是一種用一個或多個字節(jié)序列化(編碼)整數(shù)的方法。較小的數(shù)字將占用較少的字節(jié)數(shù)。

varint 中的每個字節(jié)都設(shè)置了一個標(biāo)識位(msb) - msb 表示后面還有字節(jié)需要讀取(當(dāng)前編碼還未結(jié)束)。每個字節(jié)的低 7 位用于以 7 位一組的方式存儲數(shù)字的二進(jìn)制補碼,二進(jìn)制補碼的低位排在編碼序列的前頭。

譯者注:
可以總結(jié)出 varint 編碼最主要的三點:

  • 存儲數(shù)字對應(yīng)的二進(jìn)制補碼
  • 在每個字節(jié)開頭設(shè)置了 msb,標(biāo)識是否需要繼續(xù)讀取下一個字節(jié)(這種標(biāo)識實際上代替長度的作用)
  • 補碼的低位排在前面

另外這里原文為 Each byte in a varint, except the last byte, has the most significant bit (msb) set,即 varint 中的每個字節(jié),除了最后一個字節(jié),都設(shè)置了最高有效位 msb。

原文的意思不容易讓人理解,實際上應(yīng)該是每個字節(jié)的最高 bit 都作為一個標(biāo)識作用,1 標(biāo)識需要繼續(xù)讀取下一個字節(jié),0 標(biāo)識當(dāng)前字節(jié)為最后一個字節(jié)。

而原文中 msb 可能僅僅指得是最高比特位等于 1 的情況,所以它有個 “除了最后一個字節(jié)” 這樣的表述。

我們來看一個例子:數(shù)字 1 該如何編碼 – 這只需要單個字節(jié),所以無需設(shè)置 msb:

0000 0001

譯者注:
第一個 bit 位還是需要用來標(biāo)識是否還有后續(xù)字節(jié),這里為 0 表示無后續(xù)字節(jié)。

因為 msb 的作用相當(dāng)于長度,所以哪怕只有一個字節(jié),也是需要設(shè)置 msb 的,不然解碼的時候無法識別該讀多少個字節(jié)。這里的 “無需設(shè)置 msb” 估計又是將 msb 寓意成 bit = 1 的 msb。

來看一個稍微復(fù)雜點的例子,數(shù)字 300 該如何編碼:

1010 1100 0000 0010

以上編碼如何得出是整型 300?首先我們將每個字節(jié)的 msb 位去掉,因為這只是告訴我們是否已達(dá)到數(shù)字的末尾(如你所見,它在第一個字節(jié)中設(shè)置成 1 ,因為后面還有字節(jié)(第二個字節(jié))需要繼續(xù)讀取):

1010 1100 0000 0010
→ 010 1100  000 0010

譯者注:
這里去掉 msb 位時又將第二個字節(jié)的首位比特 0 當(dāng)做 msb 去掉了。說實話 PB 的官方文檔在很多細(xì)節(jié)還是容易讓人產(chǎn)生誤解的。

接下來你可以反轉(zhuǎn)這兩組 7 位,因為如前所述,varints 會將補碼的低位排在前面。反轉(zhuǎn)過程如下所示:

000 0010  010 1100            // 將兩個 7 位組反轉(zhuǎn)
→  000 0010 + 010 1100
→  100101100                  // 去掉計算時多余的 0 
→  256 + 32 + 8 + 4 = 300     // 計算對應(yīng)的整型

譯者注:
varints 之所以將低位的放在前頭,是為了進(jìn)行位操作(還原數(shù)值)的時候更加方便。

Message 結(jié)構(gòu)

如我們所知,一個 protocol buffer message 實際上是一系列的鍵值對。消息的二進(jìn)制版本只使用字段的數(shù)字作為 key - 而每個字段的名稱和聲明的類型只能通過引用 message 類型的定義(即 .proto 文件)在解碼端確定。

在對一個 message 進(jìn)行編碼時,其鍵值將連接成字節(jié)流。在解碼消息時,解析器需要能夠跳過它無法識別的字段。這樣,可以將新字段添加到消息中,而不會破壞那些無法識別(新字段)的舊程序。為此,識別 message 編碼中每個字段的 key 實際上是兩個值 - 來自 .proto 文件的字段編號,以及一個提供足夠信息以查找 “值的(字節(jié))長度”類型。在大多數(shù)語言實現(xiàn)中,該 key 被稱為一個 tag (標(biāo)記)。

可用的類型如下:

Type Meaning Used For
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

message 消息流中的每個 Tag (field_number + wire_type) 都使用 varint 進(jìn)行編碼,且最后三位 bit 存儲類型 wire_type(其它位存儲字段編號 field_number)。

現(xiàn)在讓我們再看一下上面提到的簡單例子。你現(xiàn)在知道 message 消息流中的第一個數(shù)字總是一個 varint 編碼的 tag ,這里是 08,即(刪除 msb):

000 1000

我們?nèi)∽詈笕?bit 從而得到類型為 0 (varint),右移 3 位得到 varint 編碼的 1。所以我們現(xiàn)在知道字段編號為 1,并且接下來要讀取的 value 的類型為 varint。使用上一節(jié)講解的 varint 編碼相關(guān)知識,我們可以得到接下來的兩個字節(jié)代表 150。

96 01 = 1001 0110  0000 0001
       → 000 0001  +  001 0110 (drop the msb and reverse the groups of 7 bits)
       → 10010110
       → 128 + 16 + 4 + 2 = 150

更多的值類型

Signed Integers

正如我們在上一節(jié)中看到的,類型 0 對應(yīng)的各種 protocol buffer types 會被編碼為 varints。但是,在對負(fù)數(shù)進(jìn)行編碼時,signed int 類型(sint32 和 sint64)與標(biāo)準(zhǔn)的 int 類型(int32 和 int64)之間存在著重要差異。如果使用 int32 或 int64 作為負(fù)數(shù)的類型,則生成的 varint 總是十個字節(jié)長-它實際上被視為一個非常大的無符號整數(shù)。如果使用 signed int 類型(sint32 和 sint64),則生成的 varint 將使用 ZigZag 編碼,這樣效率更高。

譯者注:
如果是負(fù)數(shù),那么必須有最高位表示符號位,也就是說天然的會用盡所有字節(jié)。

如果是 4 個字節(jié)的 int,那么負(fù)數(shù)總是要占 4 個字節(jié)。可以為什么 int32 會占十個字節(jié)呢?不是應(yīng)該占 5 個字節(jié)嗎(每個字節(jié)還需要拿出一個 bit 位做 msb,所以要 5 個字節(jié))?

這里是 protobuf 為了兼容性做的一種措施,為了 int32 和 int64 能夠兼容(比如在 .proto 文件中將 int32 改成 int64),所以 int32 的負(fù)數(shù)會擴展到 int64 的長度。

那么正數(shù)的兼容呢?請仔細(xì)品味上述的 varints 編碼,這種編碼天然使得 int32 和 int64 的正數(shù)兼容。

ZigZag 編碼將有符號整數(shù)映射到無符號整數(shù),因此具有較小絕對值(例如 -1)的數(shù)字也具有較小的 varint 編碼值。它通過正負(fù)整數(shù)來回 “zig-zags” 的方式做到這一點,因此 -1 被編碼為 1, 1 被編碼為 2,-2 被編碼為 3,依此類推,如同下表所示:

Signed Original Encoded As
0
-1 1
1 2
-2 3
2 4
... ...
2147483647 4294967294
-2147483648 4294967295

換句話說,每個 sint32 類型的 n 編碼處理如下:

(n << 1) ^ (n >> 31)

而對于每個 sint64 類型的 n:

(n << 1) ^ (n >> 63)

注意,第二個移位(n >> 31)部分是算術(shù)移位。因此,換句話說,移位的結(jié)果將會是一個全 0(如果 n 為正)或是全 1(如果n為負(fù))。

解析 sint32 或 sint64 時,其值將被解碼回原始的 signed 版本。

Non-varint Numbers

non-varint 數(shù)字類型很簡單 - double 和 fixed64 對應(yīng)的類型(wire type)為 1,它告訴解析器期讀取一個固定的 64 位數(shù)據(jù)塊。類似地,float 和 fixed32 對應(yīng)的類型(wire type)為 5,這意味著它期望 32 位。在這兩種情況下,值都以 little-endian (二進(jìn)制補碼低位在前)字節(jié)順序存儲。

Strings

類型(wire type)為 2(長度劃分)表示接下來的字節(jié)將是 varint 編碼的長度,之后跟指定長度的數(shù)據(jù)字節(jié)。

message Test2 {
  optional string b = 2;
}

將 b 的值設(shè)置為 "testing" 后得到如下編碼:

12 07 | 74 65 73 74 69 6e 67

后七個字節(jié)為 "testing" 的 UTF8 編碼。第一個字節(jié) 0x12 為 key → 字段編碼 field_number = 2, 類型 wire type = 2。 varint 值的長度為 7,并且看到它后面有七個字節(jié) -即為我們需要讀取的值(字符串)。

譯者注:
0x12 -> 0001 0010,先進(jìn)行 varints 解碼,解碼結(jié)果為 001 0010,如果還是不清楚這個結(jié)果是怎么得出來的,可能需要重新看一遍上面的內(nèi)容。取后面三位 010 得出 wire_type = 2。剩下的位表示 field_number,容易看出 0010 = 2。

Embedded Messages(內(nèi)嵌 message)

下面的 message,內(nèi)嵌了我們之前的簡單例子 Test1:

message Test3 {
  optional Test1 c = 3;
}

設(shè)置其中的 Test1 的 a = 150,最終編碼如下:

1a 03 08 96 01

正如我們所見,后三個字節(jié)和我們的第一個例子結(jié)果相同(08 96 01),在這三個字節(jié)之前為 03 編碼(代表著字節(jié)長度)-嵌入消息的處理方式與字符串完全相同(wire type = 2)。

Optional 和 Repeated 元素

如果一個 proto2 message 定義有 repeated 字段(沒有使用 [packed=true] 選項),則對應(yīng)的 message 編碼將具有零個或多個相同字段編號的鍵值對。這些重復(fù)值不必連續(xù)出現(xiàn)。它們可能與其他字段交錯。解析時將保留這些 repeated 元素彼此的相對順序,雖然相對于其他字段的排序?qū)G失。 而在 proto3 中,repeated 字段默認(rèn)使用 packed 編碼,下面將講解該方法。

譯者注:
這里官方文檔的描述又不太嚴(yán)謹(jǐn),到目前為止,proto3 只會對 primitive 類型的 repeated 字段默認(rèn)進(jìn)行打包處理,string 之類的 repeated 字段是不會默認(rèn)進(jìn)行打包的。詳見下面 [打包 Repeated 字段] 部分。

對于 proto3 中的任何 non-repeated 字段或 proto2 中的 optional 字段,message 編碼可能具有或不具有該字段編號對應(yīng)的鍵值對。

通常,編碼消息永遠(yuǎn)不會有多個 non-repeated 字段的實例。但是,解析器應(yīng)該能夠處理這個情況。對于數(shù)字類型和字符串,如果多次出現(xiàn)相同的字段,則解析器接受它 “看到” 的最后一個值。
對于嵌入式消息字段,解析器合并同一字段的多個實例,就像使用 Message::MergeFrom 方法一樣 - 也就是說,后面實例中的所有單個標(biāo)量字段都替換前者,單個嵌入消息被合并,而 repeated 字段將被串聯(lián)起來。這些規(guī)則的作用是使得兩個消息(字符串)串聯(lián)起來解析產(chǎn)生的結(jié)果與分別解析兩個消息(字符串)后合并結(jié)果對象產(chǎn)生的結(jié)果完全相同。也就是:

MyMessage message;
message.ParseFromString(str1 + str2);

等價于:

MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);

此屬性偶爾會有用,因為它允許我們合并兩條消息,即使我們不知道它們的類型。

打包 Repeated 字段

protobuf 版本 2.1.0 引入了對打包的 repeated 字段,在 proto2 中聲明為具有特殊的 [packed = true] 選項的 repeated 字段。在 proto3 中,repeated 字段默認(rèn)被打包。packed repeated 和 repeated 在編碼方式上會有所不同。沒有包含任何元素的 packed repeated 字段不會出現(xiàn)在編碼消息中。否則(包含有元素),該字段的所有元素都被打包成一個鍵值對,其中類型(wire type)為 2(length-delimited)。每個元素的編碼方式與正常情況相同,只是前面沒有鍵。

譯者注:
string 類型不會被打包,為什么?

譯者認(rèn)為的原因:
將 primitive 進(jìn)行打包后編碼格式為 tag-length-value-value...,因為 primitive 使用 varints 進(jìn)行編碼,讀取 length 長度的字節(jié)后有 msb 作為讀取 value 的邊界標(biāo)識,因此可以對 value-value-value 進(jìn)行正確的邊界識別,正確的一個個取出 value。

如果 value 是 string 類型,對應(yīng)的 packed 結(jié)果如果是 tag-length-value-value-value...(其中 length 是后續(xù)的字節(jié)數(shù))因為 string 直接 UTF-8 編碼,讀取出對應(yīng)的字節(jié)后,無法正確劃分 value 邊界。
如果 packed 結(jié)果為 tag-length-[length-value-length-value],那么和不打包的編碼結(jié)果 tag-length-value-tag-length-value-tag-length-value.... 相比,packed 結(jié)果多出了一個 length (第一個出現(xiàn)的 length,這個 length 表達(dá)后續(xù)所有的字節(jié)數(shù),這個可能會很大),同時少了后續(xù)每個字節(jié) tag (tag 的大小通常很小,最大不會超過 5 字節(jié))的開銷,那么最后 packed 的結(jié)果是增大開銷還是減少開銷就不一定了。所以不對 Length-delimited 系列的類型進(jìn)行打包。

例如,假設(shè)我們有如下消息類型:

message Test4 {
  repeated int32 d = 4 [packed=true];
}

現(xiàn)在假設(shè)你構(gòu)造一個 Test4,為重復(fù)的字段 d 分別賦值為 3、270 和 86942。那么對應(yīng)的編碼將是:

22        // key (或稱 tag,字段編碼 4, wire type 2)
06        // payload 長度 (即后面需要讀取的所有值的長度6 bytes)
03        // first element (varint 3)
8E 02     // second element (varint 270)
9E A7 05  // third element (varint 86942)

只有原始數(shù)字類型(使用 Varint,32-bit 或 64-bit)的 repeated 字段才能聲明為 “packed”。

請注意,雖然通常沒有理由為打包的 repeated 字段編碼多個鍵值對,但編碼器必須準(zhǔn)備好接受多個鍵值對。在這種情況下,應(yīng)該連接 payload (對應(yīng)的 value)。每對必須包含許多元素。

protocol buffer 解析器必須能夠解析打包的 repeated 字段,就好像它們沒有打包一樣,反之亦然。這允許以前向和后向兼容的方式將 [packed = true] 添加到現(xiàn)有字段。

字段排序

雖然我們可以在 .proto 中以任何順序使用字段編號,但在序列化消息時,其已知字段應(yīng)按字段編號順序編寫,如提供的 C ++,Java 和 Python 序列化代碼實現(xiàn)的那樣。這允許解析代碼能夠依賴于字段序列進(jìn)行優(yōu)化。但是,protocol buffer 解析器必須能夠以任何順序解析字段,因為并非所有 message 都是通過簡單地序列化對象來創(chuàng)建的-例如,通過簡單連接來合并兩個消息(有時很有用)。

如果一個 message 具有 [未知字段],當(dāng)前的 Java 和 C++ 實現(xiàn)將在按順序排序的已知字段之后以任意順序?qū)懭胨鼈儯?dāng)前的 Python 實現(xiàn)不會記錄(跟蹤)未知字段。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容