我們現(xiàn)在所有的協(xié)議、配置、數(shù)據(jù)庫(kù)的表達(dá)都是以 protobuf 來(lái)進(jìn)行承載的,所以我想深入總結(jié)一下 protobuf 這個(gè)協(xié)議,以免踩坑。
先簡(jiǎn)單介紹一下 Protocol Buffers(protobuf),它是 Google 開(kāi)發(fā)的一種數(shù)據(jù)序列化協(xié)議(與 XML、JSON 類似)。它具有很多優(yōu)點(diǎn),但也有一些需要注意的缺點(diǎn):
優(yōu)點(diǎn):
-
效率高:Protobuf 以二進(jìn)制格式存儲(chǔ)數(shù)據(jù),比如 XML 和 JSON 等文本格式更緊湊,也更快。序列化和反序列化的速度也很快。 -
跨語(yǔ)言支持:Protobuf 支持多種編程語(yǔ)言,包括 C++、Java、Python 等。 -
清晰的結(jié)構(gòu)定義:使用 protobuf,可以清晰地定義數(shù)據(jù)的結(jié)構(gòu),這有助于維護(hù)和理解。 -
向后兼容性:你可以添加或者刪除字段,而不會(huì)破壞老的應(yīng)用程序。這對(duì)于長(zhǎng)期的項(xiàng)目來(lái)說(shuō)是非常有價(jià)值的。
缺點(diǎn):
-
不直觀:由于 protobuf 是二進(jìn)制格式,人不能直接閱讀和修改它。這對(duì)于調(diào)試和測(cè)試來(lái)說(shuō)可能會(huì)有些困難。 -
缺乏一些數(shù)據(jù)類型:例如沒(méi)有內(nèi)建的日期、時(shí)間類型,對(duì)于這些類型的數(shù)據(jù),需要手動(dòng)轉(zhuǎn)換成可以支持的類型,如 string 或 int。 -
需要額外的編譯步驟:你需要先定義數(shù)據(jù)結(jié)構(gòu),然后使用 protobuf 的編譯器將其編譯成目標(biāo)語(yǔ)言的代碼,這是一個(gè)額外的步驟,可能會(huì)影響開(kāi)發(fā)流程。
總的來(lái)說(shuō),Protobuf 是一個(gè)強(qiáng)大而高效的數(shù)據(jù)序列化工具,我們一方面看重它的性能以及兼容性,除此之外就是它強(qiáng)制要求清晰的定義出來(lái),以文件的形式呈現(xiàn)出來(lái)方便我們維護(hù)管理。下面我們主要看它的編碼原理,以及在使用上有什么需要注意的地方。
?文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-645060.html
編碼原理
概述
對(duì)于 protobuf 它的編碼是很緊湊的,我們先看一下 message 的結(jié)構(gòu),舉一個(gè)簡(jiǎn)單的例子:
message Student {
string name = 1;
int32 age = 2;
}
message 是一系列鍵值對(duì),編碼過(guò)之后實(shí)際上只有 tag 序列號(hào)和對(duì)應(yīng)的值,這一點(diǎn)相比我們熟悉的 json 很不一樣,所以對(duì)于 protobuf 來(lái)說(shuō)沒(méi)有?.proto
?文件是無(wú)法解出來(lái)的:

對(duì)于 tag 來(lái)說(shuō),它保存了 message 字段的編號(hào)以及類型信息,我們可以做個(gè)實(shí)驗(yàn),把 name 這個(gè) tag 編碼后的二進(jìn)制打印出來(lái):
func?main()?{
?student?:=?student.Student{}
?student.Name?=?"t"
?marshal,?_?:=?proto.Marshal(&student)
?fmt.Println(fmt.Sprintf("%08b",?marshal))?//?00001010?00000001?01110100
}
打印出來(lái)的結(jié)果是這樣:

上圖中,由于 name 是 string 類型,所以第一個(gè) byte 是 tag,第二 byte 是 string 的長(zhǎng)度,第三個(gè) byte 是值,也就是我們上面設(shè)置的 “t”。我們下面先看看 tag:

tag 里面會(huì)包含兩部分信息:字段序號(hào),字段類型,計(jì)算方式就是上圖的公式。上圖中將 name 這個(gè)字段序列化成二進(jìn)制我們可以看到,第一個(gè) bit 是標(biāo)記位,表示是否字段結(jié)尾,這里是 0 表示 tag 已結(jié)尾,tag 占用 1byte;接下來(lái) 4 個(gè) bit 表示的是字段序號(hào),所以范圍 1 到 15 中的字段編號(hào)只需要 1 bit 進(jìn)行編碼,我們可以做個(gè)實(shí)驗(yàn)看看,將 tag 改成 16:

由上圖所示,每個(gè) byte 第一個(gè) bit 表示是否結(jié)束,0 表示結(jié)束,所以上面 tag 用兩個(gè) byte 表示,并且 protobuf 是小端編碼的,需要轉(zhuǎn)成大端方便閱讀,所以我們可以知道 tag 去掉每個(gè) byte 第一個(gè) bit 之后,后三位表示類型,是 3,其余位是編號(hào)表示 16。
所以從上面編碼規(guī)則我們也可以知道,字段盡可能精簡(jiǎn)一些,字段盡量不要超過(guò) 16 個(gè),這樣就可以用一個(gè) byte 表示了。
同時(shí)我們也可以知道,protobuf 序列化是不帶字段名的,所以如果客戶端的 proto 文件只修改了字段名,請(qǐng)求服務(wù)端是安全的,服務(wù)端繼續(xù)用根據(jù)序列編號(hào)還是解出來(lái)原來(lái)的字段。但是需要注意的是不要修改字段類型。
接下來(lái)我們看看類型,protobuf 共定義了 6 種類型,其中兩種是廢棄的:
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 |
上面的例子中,Name 是 string 類型所以上面 tag 類型解出來(lái)是 010 ,也就是 2。
Varints 編碼
對(duì)于 protobuf 來(lái)說(shuō)對(duì)數(shù)字類型做了壓縮的,普通情況下一個(gè) int32 類型需要 4 byte,而 protobuf 表示 127 以內(nèi)的數(shù)字只需要 2 byte。因?yàn)閷?duì)于一個(gè)普通 int32 類型數(shù)字,如果數(shù)字很小,那么實(shí)際上有效位很少,比如要表示 1 這個(gè)數(shù)字,二進(jìn)制可能是這樣:
00000000?00000000?00000000?00000001
前 3 個(gè)字節(jié)都是 0 沒(méi)有表示任何信息,protobuf 就是將這些 0 都去除了,用 1 byte 表示 1 這個(gè)數(shù)字,再用 1 byte 表示 tag 的編號(hào)和類型,所以占用了 2byte。
比如我們對(duì)上面 student 設(shè)置 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"))
}
上面打印出來(lái)的二進(jìn)制如下,因?yàn)?150 超過(guò) 127,所以需要用兩個(gè) byte 表示:

第一個(gè) byte 是 tag 這里就不再重復(fù)介紹了。后面兩個(gè) byte 是真實(shí)的值,每個(gè) byte 的最高位 bit 是標(biāo)記位,表示是否結(jié)束。然后我們轉(zhuǎn)換成大端表示,串聯(lián)起來(lái)就可以得到它的值是 150。
ZigZag 編碼
Varints 編碼之所以可縮短數(shù)字所占的存儲(chǔ)字節(jié)數(shù)是因?yàn)槿サ袅?0 ,但是對(duì)于負(fù)數(shù)來(lái)說(shuō)就不行了,因?yàn)樨?fù)數(shù)的符號(hào)位為 1,并且對(duì)于 32 位的有符號(hào)數(shù)都會(huì)轉(zhuǎn)換成 64 位無(wú)符號(hào)來(lái)處理,例如 -1,用 Varints 編碼之后的二進(jìn)制:

所以 Varints 編碼負(fù)數(shù)總共會(huì)恒定占用 11 byte,tag 一個(gè) byte,值占用 10 byte。
為此 Google Protocol Buffer 定義了 sint32 這種類型,采用 zigzag 編碼。將所有整數(shù)映射成無(wú)符號(hào)整數(shù),然后再采用 varint 編碼方式編碼。例如:
Signed Original | Encoded As |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
… | … |
0x7fffffff | 0xfffffffe |
-0x80000000 | 0xffffffff |
參照上面的表,也就是將 -1 編碼成 1,將 1 編碼成 2,全部都做映射,實(shí)際的 Zigzag 映射函數(shù)為:
(n?<<?1)?^?(n?>>?31)??//for?32?bit
(n?<<?1)?^?(n?>>?63)??//for?64?bit
對(duì)于使用來(lái)說(shuō),只是編碼方式變了,使用是不受影響,所以對(duì)于如果有很高比例負(fù)數(shù)的數(shù)據(jù),可以嘗試使用 sint 類型,節(jié)省一些空間。
embedded messages & repeated
比如現(xiàn)在定義這樣的 proto:
message Lecture {
int32 price =1 ;
}
message Student {
repeated int32 scores = 1;
Lecture lecture = 2;
}
給 scores 取值為?[1,2,3]
,編碼之后發(fā)現(xiàn)其實(shí)和上面講的 string 類型很像。第一個(gè) byte 是 tag;第二 byte 是 len,長(zhǎng)度為 3;后面三個(gè) byte 都是值,我們?cè)O(shè)定的 1,2,3。

再來(lái)看看 embedded messages 類型,讓 Lecture 的 price 設(shè)置為 150 好了,編碼之后是這樣:

其實(shí)結(jié)構(gòu)也很簡(jiǎn)單,左邊的是 Student 類型,右邊是 Lecture 類型。有點(diǎn)不同的是對(duì)于 embedded messages 會(huì)將大小計(jì)算出來(lái)。
?
最佳實(shí)踐
字段編號(hào)
需要注意的是范圍 1 到 15 中的字段編號(hào)需要一個(gè)字節(jié)進(jìn)行編碼,包括字段編號(hào)和字段類型;范圍 16 至 2047 中的字段編號(hào)需要兩個(gè)字節(jié)。所以你應(yīng)該保留數(shù)字 1 到 15 作為非常頻繁出現(xiàn)的消息元素。
因?yàn)槭褂昧?VarInts,所以單字節(jié)的最高位是零,而最低三位表示類型,所以只剩下 4 位可用了。也就是說(shuō),當(dāng)你的字段數(shù)量超過(guò)?16
?時(shí),就需要用兩個(gè)以上的字節(jié)表示了。
保留字段
一般的情況下,我們是不會(huì)輕易的刪除字段的,防止客戶端和服務(wù)端出現(xiàn)協(xié)議不一致的情況,如果您通過(guò)完全刪除某個(gè)字段或?qū)⑵渥⑨尩魜?lái)更新消息類型,那么未來(lái)的其他人不知道這個(gè) tag 或字段被刪除過(guò)了,我們可以使用 reserved 來(lái)標(biāo)記被刪除的字段,如:
message?Foo?{
??reserved?2,?15,?9?to?11;
??reserved?"foo",?"bar";
}
當(dāng)然除了 message ,reserved 也可以在枚舉類型中使用。
不要修改字段 tag 編號(hào)以及字段類型
protobuf 序列化是不帶字段名的,所以如果客戶端的 proto 文件只修改了字段名,請(qǐng)求服務(wù)端是安全的,服務(wù)端繼續(xù)用根據(jù)序列編號(hào)還是解出來(lái)原來(lái)的字段,但是需要注意的是不要修改字段類型,以及序列編號(hào),修改了之后就可能按照編號(hào)找錯(cuò)類型。
不要使用 required 關(guān)鍵字
required 意味著消息中必須包含這個(gè)字段,并且字段的值必須被設(shè)置。如果在序列化或者反序列化的過(guò)程中,該字段沒(méi)有被設(shè)置,那么 protobuf 庫(kù)就會(huì)拋出一個(gè)錯(cuò)誤。
如果你在初期定義了一個(gè) required 字段,但是在后來(lái)的版本中你想要?jiǎng)h除它,那么這就會(huì)造成問(wèn)題,因?yàn)榕f的代碼會(huì)期待該字段始終存在。為了確保兼容性,Google 在最新版本的 protobuf(protobuf 3)中已經(jīng)不再支持 required 修飾符。
盡量使用小整數(shù)
Varints 編碼表示 127 以內(nèi)的數(shù)字只需要 2 byte,1 byte 是 tag,1 byte 是值,壓縮效果很好。但是如果表示一個(gè)很大的數(shù)如 :1<<31 - 1
,除去 tag 外需要占用 5 byte,比普通的 int 32 多 1 byte,因?yàn)?protobuf 每個(gè) byte 最高位有一個(gè)標(biāo)識(shí)符占用 1 bit。
如果需要傳輸負(fù)數(shù),可以試試 sint32 或 sint64
因?yàn)樨?fù)數(shù)的符號(hào)位為 1,并且 Varints 編碼對(duì)于負(fù)數(shù)如果是 32 位的有符號(hào)數(shù)都會(huì)轉(zhuǎn)換成 64 位無(wú)符號(hào)來(lái)處理,所以 Varints 編碼負(fù)數(shù)總共會(huì)恒定占用 11 byte,tag 一個(gè) byte,值占用 10 byte。
而 sint32 和 sint64 將所有整數(shù)映射成無(wú)符號(hào)整數(shù),然后再采用 varint 編碼方式編碼,如果數(shù)字比較還是可以節(jié)省一定的空間的。
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/
?
作者:bear文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-645060.html
到了這里,關(guān)于數(shù)據(jù)序列化工具Protobuf編碼&避坑指南的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!