配圖清新的 Protobuf 編碼 - 避坑指南

我們現在所有的協議、配置、數據庫的表達都是以 protobuf 來進行承載的,所以我想深入總結一下 protobuf 這個協議,以免踩坑。

先簡單介紹一下 Protocol Buffers(protobuf),它是 Google 開發的一種數據序列化協議(與 XML、JSON 類似)。它具有很多優點,但也有一些需要注意的缺點:

優點:

缺點:

  1. 不直觀:由於 protobuf 是二進制格式,人不能直接閱讀和修改它。這對於調試和測試來說可能會有些困難。

  2. 缺乏一些數據類型:例如沒有內建的日期、時間類型,對於這些類型的數據,需要手動轉換成可以支持的類型,如 string 或 int。

  3. 需要額外的編譯步驟:你需要先定義數據結構,然後使用 protobuf 的編譯器將其編譯成目標語言的代碼,這是一個額外的步驟,可能會影響開發流程。

總的來說,Protobuf 是一個強大而高效的數據序列化工具,我們一方面看重它的性能以及兼容性,除此之外就是它強制要求清晰的定義出來,以文件的形式呈現出來方便我們維護管理。下面我們主要看它的編碼原理,以及在使用上有什麼需要注意的地方。

編碼原理

概述

對於 protobuf 它的編碼是很緊湊的,我們先看一下 message 的結構,舉一個簡單的例子:

message Student {
  string name = 1;
  int32 age = 2; 
}

message 是一系列鍵值對,編碼過之後實際上只有 tag 序列號和對應的值,這一點相比我們熟悉的 json 很不一樣,所以對於 protobuf 來說沒有 .proto 文件是無法解出來的:

對於 tag 來說,它保存了 message 字段的編號以及類型信息,我們可以做個實驗,把 name 這個 tag 編碼後的二進制打印出來:

func main() {
 student := student.Student{}
 student.Name = "t" 
 marshal, _ := proto.Marshal(&student)
 fmt.Println(fmt.Sprintf("%08b", marshal)) // 00001010 00000001 01110100
}

打印出來的結果是這樣:

上圖中,由於 name 是 string 類型,所以第一個 byte 是 tag,第二 byte 是 string 的長度,第三個 byte 是值,也就是我們上面設置的 “t”。我們下面先看看 tag:

tag 裏面會包含兩部分信息:字段序號,字段類型,計算方式就是上圖的公式。上圖中將 name 這個字段序列化成二進制我們可以看到,第一個 bit 是標記位,表示是否字段結尾,這裏是 0 表示 tag 已結尾,tag 佔用 1byte;接下來 4 個 bit 表示的是字段序號,所以範圍 1 到 15 中的字段編號只需要 1 bit 進行編碼,我們可以做個實驗看看,將 tag 改成 16:

由上圖所示,每個 byte 第一個 bit 表示是否結束,0 表示結束,所以上面 tag 用兩個 byte 表示,並且 protobuf 是小端編碼的,需要轉成大端方便閱讀,所以我們可以知道 tag 去掉每個 byte 第一個 bit 之後,後三位表示類型,是 3,其餘位是編號表示 16。

所以從上面編碼規則我們也可以知道,字段儘可能精簡一些,字段儘量不要超過 16 個,這樣就可以用一個 byte 表示了。

同時我們也可以知道,protobuf 序列化是不帶字段名的,所以如果客戶端的 proto 文件只修改了字段名,請求服務端是安全的,服務端繼續用根據序列編號還是解出來原來的字段。但是需要注意的是不要修改字段類型。

接下來我們看看類型,protobuf 共定義了 6 種類型,其中兩種是廢棄的:

MetK6U

上面的例子中,Name 是 string 類型所以上面 tag 類型解出來是 010 ,也就是 2。

Varints 編碼

對於 protobuf 來說對數字類型做了壓縮的,普通情況下一個 int32 類型需要 4 byte,而 protobuf 表示 127 以內的數字只需要 2 byte。因爲對於一個普通 int32 類型數字,如果數字很小,那麼實際上有效位很少,比如要表示 1 這個數字,二進制可能是這樣:

00000000 00000000 00000000 00000001

前 3 個字節都是 0 沒有表示任何信息,protobuf 就是將這些 0 都去除了,用 1 byte 表示 1 這個數字,再用 1 byte 表示 tag 的編號和類型,所以佔用了 2byte。

比如我們對上面 student 設置 age 等於 150:

func main() {
 student := student.Student{}
 student.Age = 150
 marshal, _ := proto.Marshal(&student)
 fmt.Println(fmt.Sprintf("%08b", marshal)) //00010000 10010110 00000001
 fmt.Println(fmt.Sprintf("%08b", "a"))
}

上面打印出來的二進制如下,因爲 150 超過 127,所以需要用兩個 byte 表示:

第一個 byte 是 tag 這裏就不再重複介紹了。後面兩個 byte 是真實的值,每個 byte 的最高位 bit 是標記位,表示是否結束。然後我們轉換成大端表示,串聯起來就可以得到它的值是 150。

ZigZag 編碼

Varints 編碼之所以可縮短數字所佔的存儲字節數是因爲去掉了 0 ,但是對於負數來說就不行了,因爲負數的符號位爲 1,並且對於 32 位的有符號數都會轉換成 64 位無符號來處理,例如 -1,用 Varints 編碼之後的二進制:

所以 Varints 編碼負數總共會恆定佔用 11 byte,tag 一個 byte,值佔用 10 byte。

爲此 Google Protocol Buffer 定義了 sint32 這種類型,採用 zigzag 編碼。將所有整數映射成無符號整數,然後再採用 varint 編碼方式編碼。例如:

kpIUKi

參照上面的表,也就是將 -1 編碼成 1,將 1 編碼成 2,全部都做映射,實際的 Zigzag 映射函數爲:

(<< 1) ^ (n >> 31)  //for 32 bit 
(n << 1) ^ (n >> 63)  //for 64 bit

對於使用來說,只是編碼方式變了,使用是不受影響,所以對於如果有很高比例負數的數據,可以嘗試使用 sint 類型,節省一些空間。

embedded messages & repeated

比如現在定義這樣的 proto:

message Lecture {
  int32 price =1 ; 
}

message Student {
  repeated int32 scores = 1;
  Lecture lecture = 2;
}

給 scores 取值爲 [1,2,3],編碼之後發現其實和上面講的 string 類型很像。第一個 byte 是 tag;第二 byte 是 len,長度爲 3;後面三個 byte 都是值,我們設定的 1,2,3。

再來看看 embedded messages 類型,讓 Lecture 的 price 設置爲 150 好了,編碼之後是這樣:

其實結構也很簡單,左邊的是 Student 類型,右邊是 Lecture 類型。有點不同的是對於 embedded messages 會將大小計算出來。

最佳實踐

字段編號

需要注意的是範圍 1 到 15 中的字段編號需要一個字節進行編碼,包括字段編號和字段類型;範圍 16 至 2047 中的字段編號需要兩個字節。所以你應該保留數字 1 到 15 作爲非常頻繁出現的消息元素。

因爲使用了 VarInts,所以單字節的最高位是零,而最低三位表示類型,所以只剩下 4 位可用了。也就是說,當你的字段數量超過 16 時,就需要用兩個以上的字節表示了。

保留字段

一般的情況下,我們是不會輕易的刪除字段的,防止客戶端和服務端出現協議不一致的情況,如果您通過完全刪除某個字段或將其註釋掉來更新消息類型,那麼未來的其他人不知道這個 tag 或字段被刪除過了,我們可以使用 reserved 來標記被刪除的字段,如:

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo""bar";
}

當然除了 message ,reserved 也可以在枚舉類型中使用。

不要修改字段 tag 編號以及字段類型

protobuf 序列化是不帶字段名的,所以如果客戶端的 proto 文件只修改了字段名,請求服務端是安全的,服務端繼續用根據序列編號還是解出來原來的字段,但是需要注意的是不要修改字段類型,以及序列編號,修改了之後就可能按照編號找錯類型。

不要使用 required 關鍵字

required 意味着消息中必須包含這個字段,並且字段的值必須被設置。如果在序列化或者反序列化的過程中,該字段沒有被設置,那麼 protobuf 庫就會拋出一個錯誤。

儘量使用小整數

如果需要傳輸負數,可以試試 sint32 或 sint64

因爲負數的符號位爲 1,並且 Varints 編碼對於負數如果是 32 位的有符號數都會轉換成 64 位無符號來處理,所以 Varints 編碼負數總共會恆定佔用 11 byte,tag 一個 byte,值佔用 10 byte。

而 sint32  和 sint64 將所有整數映射成無符號整數,然後再採用 varint 編碼方式編碼,如果數字比較還是可以節省一定的空間的。

Reference

https://sunyunqiang.com/blog/protobuf_encode/

https://halfrost.com/protobuf_encode/

https://protobuf.dev/programming-guides/encoding/

https://protobuf.dev/programming-guides/dos-donts/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7zkuWumtvJs3cLtj_JGCNw