Protobuf編碼規則

zxzhang發表於2023-05-02

支援型別

該表顯示了在 .proto 檔案中指定的型別,以及自動生成的類中的相應型別:

.proto Type Notes C++ Type Java/Kotlin Type[1] Java/Kotlin 型別 [1] Python Type[3] Go Type Ruby Type C# Type PHP Type Dart Type
double double double float float64 Float double float double
float float float float float32 Float float float double
int32 varint編碼。對於負數編碼效率低下——如果欄位可能有負值,建議改用 sint32。 int32 int int int32 Fixnum or Bignum (as required) int integer int
int64 varint編碼。對於負數編碼效率低下——如果欄位可能有負值,建議改用 sint64。 int64 long int/long int64 Bignum long integer/string Int64
uint32 varint編碼。 uint32 int int/long uint32 Fixnum or Bignum (as required) uint integer int
uint64 varint編碼。 uint64 long int/long uint64 Bignum ulong integer/string Int64
sint32 zigzag和varint編碼。有符號的 int 值。比常規的 int32 能更高效地編碼負數。 int32 int int int32 Fixnum or Bignum (as required) ) int integer int
sint64 zigzag和varint編碼。有符號的 int 值。比常規的 int64 能更高效地編碼負數。 int64 long int/long int64 Bignum long integer/string Int64
fixed32 總是四個位元組。如果值通常大於 2\(^{28}\) ,則比 uint32 更有效。 uint32 int int/long uint32 Fixnum or Bignum (as required) uint integer int
fixed64 總是八個位元組。如果值通常大於 2\({^56}\) ,則比 uint64 更有效。 uint64 long int/long uint64 Bignum ulong integer/string Int64
sfixed32 總是四個位元組。 int32 int int int32 Fixnum or Bignum (as required) int integer int
sfixed64 總是八個位元組。 int64 long int/long int64 Bignum long integer/string Int64
bool bool boolean bool bool TrueClass/FalseClass bool boolean bool
string 字串必須始終包含 UTF-8 編碼或 7 位 ASCII 文字,並且不能長於 2\(^{32}\) string String str/unicode string String (UTF-8) string string String
bytes 可以包含任何不超過 2\(^{32}\) 的任意位元組序列。 string ByteString str (Python 2) bytes (Python 3) []byte String (ASCII-8BIT) ByteString string List

訊息結構

對於傳統的 xml 或者 json 等方式的序列化中,編碼時直接將 key 本身加進去,例如:

{
    "foo": 1,
    "bar": 2
}

這樣最大的好處就是可讀性強,但是缺點也很明顯,傳輸效率低,每次都需要傳輸重複的欄位名。Protobuf 使用了另一種方式,將每一個欄位進行編號,這個編號被稱為 field number 。透過 field_number 的方式解決 json 等方式重複傳輸欄位名導致的效率低下問題,例如:

message {
  int32  foo = 1;
  string bar = 2;
}

field_number 的型別被稱為wire types,目前有六種型別:VARINTI64LENSGROUPEGROUP, and I32 (注:型別3和4已廢棄),因此需要至少3位來區分:

ID Name Used For
0 VARINT int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 I64 fixed64, sfixed64, double
2 LEN string, bytes, embedded messages, packed repeated fields
3 SGROUP group start (deprecated)
4 EGROUP group end (deprecated)
5 I32 fixed32, sfixed32, float

當 message 被編碼時,每一個 key-value 包含 <tag> <type> <paylog>,其結構如下:

+--------------+-----------+---------+
| field_number | wire_type | payload |
+--------------+-----------+---------+
    |               |             |
    |               |             |          +---------------+
    +---------------+             +--------->| (length) data |
    |      tag      |                        +---------------+
    +---------------+

  • field_number 和 wire_type 被稱為 tag,使用一個位元組來表示(這裡指編碼前的一個位元組,透過Varint編碼後可能並非一個位元組)。其值為 (field_number << 3) | wire_type ,換句話說低3位解釋了wire_type,剩餘的位則解釋了field_number。
  • payload 則為 value 具體值,根據 wire_type 的型別決定是否是採用 Length-Delimited 記錄

額外一提的是由於 tag 結構如上所述,因此對於使用 Varint 編碼的 1個位元組來說去除最高位標誌位和低三位保留給 wire_type使用,剩下四位能夠表示[0, 15] 的欄位標識,超過則需要使用多於一個位元組來儲存 tag 資訊,因此儘可能將頻繁使用的欄位的欄位標識定義在 [0, 15] 直接。

編碼規則

Protobuf 使用一種緊湊的二進位制格式來編碼訊息。編碼規則包括以下幾個方面:

  • 每個欄位都有一個唯一的識別符號和一個型別,識別符號和型別資訊一起構成了欄位的 tag。
  • 欄位的 tag 採用 Varint 編碼方式進行編碼,可以節省空間。
  • 字串型別的欄位採用長度字首方式進行編碼,先編碼字串的長度,再編碼字串本身。
  • 重複的欄位可以使用 repeated 關鍵字進行定義,編碼時將重複的值按照順序編碼成一個列表。

Varint 編碼

Varint 是一種可變長度的編碼方式,可以將一個整數編碼成一個位元組序列。值越小的數字,使用越少的位元組數表示。它的原理是透過減少表示數字的位元組數從而實現資料體積壓縮。
Varint 編碼的規則如下:

  • 對於值小於 128 的整數,直接編碼為一個位元組;
  • 對於值大於等於 128 的整數,將低 7 位編碼到第一個位元組中,將高位編碼到後續的位元組中,並在最高位新增一個標誌位(1 表示後續還有位元組,0 表示當前位元組是最後一個位元組)。每個位元組的最高位也稱 MSB(most significant bit)。
    在解碼的時候,如果讀到的位元組的 MSB 是 1 話,則表示還有後序位元組,一直讀到 MSB 為 0 的位元組為止。
    例如,int32型別、field_number為1、值位 300 的 Varint 編碼為:
// 300 的二進位制
00000001 00101100
// 按7位切割
00 0000010 0101100
// 高位全0省略
0000010 0101100
// 逆序,使用的小端位元組序
0101100 0000010
// 每一組加上msb,除了最後一組是msb是0,其他的都為1
10101100 00000010
// 十六進位制指
ac 02

// 按照 protobuf 的訊息結構,其完整位
08 ac 02
|   |__|__ payload
|   
|----------- tag (field-number << 3 | wire-type) = (1 << 3 | 0) = 0x08

ZigZag編碼

對於 int32/int64 的 proto type,值大於 0 時直接使用 Varint 編碼,而值為負數時做了符號擴充,轉換為 int64 的型別,再做 Varint 編碼。負數高位為1,因此對於負數固定需要十個位元組( ceil(64 / 7) = 10 )。(這裡有個值得思考的問題是對於 int32 型別的負數為什麼要轉換為 int64 來處理?不轉換的話使用5個位元組就能夠完成編碼了。網上的一個說法是為了轉換為 int64 型別時沒有相容性問題,此處由於還未閱讀過原始碼,不知道內部是怎麼處理的,因此暫時也沒想通為什麼因為相容性問題需要做符號擴充。因為按照 Varint 編碼規則解碼的話,直接讀取出來的值賦值給 int64 的型別也沒有問題。int32 negative numbers

很明顯,這樣對於負數的編碼是非常低效的。因此 protobuf 引入 sint32sint64,在編碼時先將數字使用 ZigZag 編碼,然後再使用 Varint 編碼。
ZigZag 編碼將有符號數對映為無符號數,對應的編解碼規則如下:

static uint32_t ZigZagEncode32(int32_t v) {  
	// Note: the right-shift must be arithmetic  
	// Note: left shift must be unsigned because of overflow
    return (static_cast<uint32_t>(v) << 1) ^ static_cast<uint32_t>(v >> 31);  
}

static uint64_t ZigZagEncode64(int64_t v) {  
	// Note: the right-shift must be arithmetic  
	// Note: left shift must be unsigned because of overflow
    return (static_cast<uint64_t>(v) << 1) ^ static_cast<uint64_t>(v >> 63);  
}

int32_t ZigZagDecode32(uint32_t n) {
    // Note: Using unsigned types prevent undefined behavior
    return static_cast<int32_t>((n >> 1) ^ (~(n & 1) + 1));
} 

static int64_t ZigZagDecode64(uint64_t n) {
    // Note: Using unsigned types prevent undefined behavior
    return static_cast<int64_t>((n >> 1) ^ (~(n & 1) + 1));
}

因此如果傳輸的資料中可能包含有負數,那麼應該使用 sint32/sint64 型別。因為 protobuf 中只定義了為這兩種資料型別進行 ZigZag 編碼再使用 Varint 編碼。

Length-delimited 編碼

wire_typeLEN,由於其具有動態長度,因此其由一個 Length 值儲存長度大小,這個 Length 同樣透過 Varint 編碼,最後是其內容。
參照以下例子:

message Test2 {
  optional string b = 2;
}

b = "testing"

12 07 [74 65 73 74 69 6e 67]
|  |   t  e  s  t  i  n  g
|  |  |__|__|__|__|__|__ body 的 ASCII 碼
|  |
|  |__ length = 6 = 0x06
|      
|__ Tag (field-number << 3 | wire-type) = (2 << 3 | 2) = 18 = 0x12

相關文章