Protobuf 編碼指南

KevinYan發表於2019-12-05

這個文件會介紹protocol buffer的二進位制有線格式(binary wire format)。你並不是需要理解這些後才能在應用裡使用protocol buffer,但是當你想知道不同的protocol buffer格式是如何影響編碼後的訊息體的體積時,這些知識會非常有用。

一個簡單的訊息

假設有一個非常簡單的訊息定義:

message Test1 {
  optional int32 a = 1;
}

在應用中,你建立了一個Test1訊息並把a設定為150。然後你把訊息序列化到輸出流中,如果你能檢視編碼後的訊息,你會看到三個位元組:

08 96 01

到目前為止,如此小而且都是數字-但是這是什麼意思呢?繼續往下看

Varint編碼

要理解上面protocol buffer編碼的資料,你需要先理解vaintsVarints是一種使用一個或多個位元組編碼整數的方法。較小的數字使用較少的位元組。

除了最後一個位元組外,varint編碼中的每個位元組都設定了最高有效位(most significant bit - msb)–msb為1則表明後面的位元組還是屬於當前資料的,如果是0那麼這是當前資料的最後一個位元組資料。每個位元組的低7位用於以7位為一組儲存數字的二進位制補碼錶示,最低有效組在前,或者叫最低有效位元組在前。這表明varint編碼後資料的位元組是按照小端序排列的。

舉例來說,對於數字1-它佔用單個位元組,所以位元組的最高位上是0

0000 0001

對於數字300會有一點複雜,它佔用倆個位元組

1010 1100 0000 0010

那麼是怎麼計算出來是300的呢?首先你需要把每個位元組的msb去掉,因為它只用來告訴我們是否已經到達數字的最後一個位元組(本例的varint佔用倆個位元組所以第一個位元組的msb為1)

 1010 1100 0000 0010
→ 010 1100  000 0010

將兩組7位反轉,因為你記得,varint儲存的數字最低有效組在前。然後,將它們連線起來以獲得最終值

000 0010  010 1100 (去掉最高有效位,並反轉7位組)
→  000 0010 ++ 010 1100
→  100101100
→  256 + 32 + 8 + 4 = 300

:varint編碼理解起來有點難,可以看之前寫的varint編碼原理解析

訊息的組成

如你所知,一個protocol buffer是一系列鍵值對。訊息的二進位制格式只使用訊息欄位的欄位編號作為鍵--欄位名和宣告的型別只能在解析端透過引用參考訊息型別定義(即.proto檔案)才能確定。

當一個訊息被編碼時,鍵和值會被連線放入位元組流中。當訊息被解碼時,分析器需要能夠跳過未識別的欄位。這樣,新加入訊息的欄位就不會破壞不知道他們存在的那些老程式。為此,有線格式訊息中每個對的“鍵”實際上是兩個值-.proto檔案中的欄位編號,加上一種有線型別,該型別僅提供足夠的資訊來查詢隨後的值的長度。在大多數語言實現中,這個鍵稱為標籤。

可用的有線型別如下:

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

在訊息流中的每個鍵都是varint,使用(filed_number << 3) | wire_type 獲得--也就是說位元組的後三位儲存的是有線型別。

現在讓我們再回到上面的訊息示例。你現在知道位元組流中的首個位元組永遠都是一個varint鍵,在我們的例子中它是08或者下面的二進位制(去掉了msb)。

000 1000

透過後三位得出有線型別(0),然後右移三位得到欄位編號(1)。現在你知道欄位的編號是1對應的值是一個varint。使用前面學到的解碼varint的知識,你可以看到下面的兩個位元組儲存著值150。

96 01 = 1001 0110  0000 0001
       → 000 0001  ++  001 0110 (去掉最高有效位,並反轉7位組)
       → 10010110
       → 128 + 16 + 4 + 2 = 150

更多值型別

有符號整數

就像你在上一部分看到的那樣,protocol buffer中所有與有線型別0關聯的型別都會被編碼為varint。但是,在編碼負數時,帶符號的int型別(sint32和sint64)與“標準” int型別(int32和int64)之間存在著巨大區別。如果將int32或int64用作負數的型別,則結果varint總是十個位元組長––實際上,它被視為一個非常大的無符號整數。如果使用帶符號型別(sint32和sint64)之一,則生成的varint使用ZigZag編碼,效率更高

ZigZag編碼將有符號數對映到無符號數以便具有較小絕對值的數字(比如-1)也具有較小的varint編碼值。這樣做的方式是透過正整數和負整數來回“曲折”,將-1編碼為1,將1編碼為2,將-2編碼為3,依此類推,可以在下表中看到:

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

非varint數字

對與非可varint編碼的數字來說比較簡單--doublefixed64使用有線型別1,這會告訴解析器期望固定的64-bit的資料塊。相似地floatfixed32使用有線型別5,這會告訴解析器期望固定的32-bit資料塊。這兩種情況都是使用小端序排列位元組儲存資料的。

字串

有線型別2(長度分隔)表示該值是varint編碼的長度值,後跟長度值指定數量的資料位元組。

message Test2 {
  optional string b = 2;
}

設定b的值為"testing"後訊息對應的二進位制有線格式為

12 07 74 65 73 74 69 6e 67

紅色的位元組是UTF-8編碼後的"testing"

這裡的鍵是0x12→0001 0010→欄位號= 2,型別=2(第一個位元組的後三位表示有線型別的編號,然後右移三位變成000 0010得到欄位號)。值中的varint表示的資料位元組長度是7,如你所見我們在它後面找到的七個位元組–就是解析器要找的字串。

內嵌訊息

下面是一個擁有內嵌訊息的訊息定義Test3,內嵌的訊息型別是我們上面示例中定義的Test1

message Test3 {
  optional Test1 c = 3;
}

下面則是內嵌的Test1中的a設定為150,Test3`被編碼後的版本

1a 03 08 96 01

如你所見,最後三個位元組和我們第一個例子編碼後的結果一樣(08 96 01),在他們之前是數字3,--內嵌訊息會像字串一樣被對對待(有線格式=2)。

可選和可重複元素

如果proto2訊息定義具有重複的元素(不帶[packed = true]選項),則編碼訊息具有零個或多個具有相同欄位編號的鍵值對。這些重複的值不必連續出現。它們可能與其他欄位交錯。解析時,元素之間的順序會保留下來,儘管其他欄位的順序會丟失。在proto3中,重複欄位使用packed編碼,可以在下面看到相關編碼。

通常,編碼訊息永遠不會有一個以上非重複欄位的例項。但是,解析器能處理這種實際情況,對於數字型別和字串,如果同一欄位多次出現,則解析器將接受它看到的最後一個值。對於嵌入式訊息欄位,解析器將合併同一欄位的多個例項,就像使用Message :: MergeFrom方法一樣-也就是說,後一個例項中的所有單個標量欄位將替換前一個例項中的單個標量欄位,可重複欄位會被串聯到一塊。這些規則的作用是,解析兩個編碼的訊息的連線所產生的結果與您分別解析兩個訊息併合並結果物件的結果完全相同。也就是說:

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

等同於

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

這個特性有時很有用,因為即使您不知道它們的型別,也允許你合併兩個訊息。

壓縮重複欄位

proto版本2.1.0引入了壓縮重複欄位,在proto2中宣告為重複欄位,並使用特殊的[packed = true]選項。在proto3中,預設情況下壓縮標量數字型別的重複欄位。這些功能類似於重複的欄位,但編碼方式不同。包含零元素的壓縮重複欄位不會出現在編碼的訊息中。否則,該欄位的所有元素都將打包為有線型別為2(定界)的單個鍵值對。每個元素的編碼方式與通常相同,不同之處在於元素之前沒有鍵。

舉例來說,你有以下訊息型別:

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

現在假設您構造一個Test4,為重複的欄位d提供值3、270和86942。然後,訊息編碼後的形式為:

22        // key (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)

只能將原始數字型別(使用varint,32位或64位線型的型別)的重複欄位宣告為“packed”。

欄位順序

欄位編號可以在.proto檔案中以任何順序使用。選擇使用的順序對訊息的序列化方式沒有影響。

序列化訊息時,對於如何寫入其已知欄位或未知欄位沒有保證的順序。序列化順序是一個實現細節,將來任何特定實現的細節都可能更改。因此,protocol buffer解析器必須能夠以任何順序解析欄位。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
公眾號:網管叨bi叨 | Golang、Laravel、Docker、K8s等學習經驗分享

相關文章