配圖清新的 Protobuf 編碼 - 避坑指南
我們現在所有的協議、配置、數據庫的表達都是以 protobuf 來進行承載的,所以我想深入總結一下 protobuf 這個協議,以免踩坑。
先簡單介紹一下 Protocol Buffers(protobuf),它是 Google 開發的一種數據序列化協議(與 XML、JSON 類似)。它具有很多優點,但也有一些需要注意的缺點:
優點:
缺點:
-
不直觀:由於 protobuf 是二進制格式,人不能直接閱讀和修改它。這對於調試和測試來說可能會有些困難。
-
缺乏一些數據類型:例如沒有內建的日期、時間類型,對於這些類型的數據,需要手動轉換成可以支持的類型,如 string 或 int。
-
需要額外的編譯步驟:你需要先定義數據結構,然後使用 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 種類型,其中兩種是廢棄的:
上面的例子中,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 編碼方式編碼。例如:
參照上面的表,也就是將 -1 編碼成 1,將 1 編碼成 2,全部都做映射,實際的 Zigzag 映射函數爲:
(n << 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