圖解Protobuf編碼

圖解Protobuf編碼

Protobuf是Google發佈的消息序列化工具。Protobuf定義了消息描述語法(proto語法)和消息編碼格式,並且提供了主流語言的代碼生成器(protoc)。本文僅討論Protobuf消息編碼格式,並且假定讀者已經熟悉Protobuf消息描述語法(proto2或者proto3)。


基本編碼規則

Protobuf消息由字段(field)構成,每個字段有其規則(rule)、數據類型(type)、字段名(name)、tag,以及選項(option)。比如下面這段代碼描述了由10個字段構成的Test消息:

test.proto

序列化時,消息字段會按照tag順序,以key+val的格式,編碼成二進制數據。以下面這段Java代碼爲例:

byte[] data = Test.newBuilder()
  .setA(3).setB(2).setC(1)
  .build().toByteArray();

序列化之後,可以把data裏的數據想象成下面這樣:

這裏寫圖片描述

proto2語法定義了3種字段規則:required、optional、repeated。proto3語法去掉了required規則,只剩下optional(默認)和repeated兩種。由上圖可知,如果沒有給optional和repeated字段賦值,那麼字段是不會出現在序列化後的數據中的。詳細的編碼規則,請繼續閱讀。

數據劃分

Protobuf消息序列化之後,會產生二進制數據。這些數據(精確到bit)按照含義不同,可以劃分爲6個部分:MSB flag、tag、編碼後數據類型(wire type)、長度(length)、字段值(value)、以及填充(padding)。後文會圖解這些部分的具體含義,這裏先約定好圖中消息各部分使用的顏色:

colors

Key+Value

前面說過,消息的每一個字段,都會以key+val的形式,序列化爲二進制數據。val比較好猜測,那麼key具體是什麼呢?答案是這樣:key = tag << 3 | wire_type。也就是說,key的前3個比特是wire type,剩下的比特是tag值。Protobuf支持豐富的數據類型,但是編碼之後,只剩下Varint(0)、64-bit(1)、Length-delimited(2)和32-bit(5)這4種(還有兩種已經廢棄了,本文不討論)類型,用3個比特來表示,足夠了。以前面定義的Test消息爲例:

byte[] data = Test.newBuilder()
  .setA(3).setB(2).setC(1)
  .build().toByteArray();

序列化之後的數據有6個字節,是下面這個樣子:

abc

Varint

用3個bit來表示wire type是夠了,但是tag是用剩下的5個bit來表示嗎?tag難道不能超過32(2^5)嗎?由上圖已經知道,答案是否!爲了用儘可能少的字節編碼消息,Protobuf在多處都使用了Varint這種格式。比如數據類型裏的int32、int64,以及tag值和後面將要解釋的length值,都使用Varint類型存儲。那麼Varint到底有什麼神奇之處呢?也沒有,其實就是用每個字節的前7個bit來表示數據,而最高位的bit(MSB,Most Significant Bit)則用作記號(flag)。文字不太好描述,看一個例子:

byte[] data2 = Test.newBuilder()
  .setJ(1) // tag=16
  .build().toByteArray();

由於tag是按Varint編碼的,所以要扣掉一個bit(MSB)。再減去wire type佔用的3個比特,那麼第一個字節裏,留給tag值的,實際只剩下4個比特,只能表示0到15。由於Test消息j字段的tag值是16,所以需要兩個字節才能表示j字段的key。data2如下圖所示(重要的bit進行了旋轉,以示提醒):

tag16

64-bit和32-bit

前面說了,爲了節省字節數,tag、length,以及int32、int64等數據類型都是用Varint編碼的。那麼這種編碼方式有什麼壞處嗎?主要有2處。第一,不利於表示大數。對於比較小的數來說,以0到127爲例,用Varint很划算。以浪費1bit和少量額外的計算爲代價,只要1個字節就可以表示。但是對於比較大的數,就不划算了。以int32爲例,大於2^(4*7) - 1的數,需要用5個字節來表示。看一個例子:

byte[] data3 = Test.newBuilder()
  .setA(268435456) // 2^28
  .build()
  .toByteArray();

序列化之後的數據如下圖所示:

268435456

也就是說,如果某個消息的某個int字段大部分時候都會取比較大的數,那麼這個字段使用Varint這種變長類型來編碼就沒什麼好處。對於這種情況,Protobuf定義了64-bit和32-bit兩種定長編碼類型。使用64-bit編碼的數據類型包括fixed64、sfixed64和double;使用32-bit編碼的數據類型包括fixed32、sfixed32和float。以Test消息e字段(fixed32)爲例:

byte[] data4 = Test.newBuilder()
  .setE(268435456) // 2^28
  .build()
  .toByteArray();

序列化之後的數據如下圖所示:

fixed32

ZigZag

Varint編碼格式的第二缺點是不適合表示負數,以int32和-1爲例:

byte[] data5 = Test.newBuilder()
  .setA(-1)
  .build()
  .toByteArray();

Protobuf想讓int32和int64在編碼格式上兼容,所以-1需要佔用10個字節,如下圖所示:

n1

爲了克服這個缺陷,Protobuf提供了sint32和sint64兩種數據類型。如果某個消息的某個字段出現負數值的可能性比較大,那麼應該使用sint32或sint64。這兩種數據類型在編碼時,會先使用ZigZig編碼將負數映射成正數,然後再使用Varint編碼。ZigZag編碼規則如下圖所示:

zigzag

以Test消息的d字段(sint32)爲例:

byte[] data6 = Test.newBuilder()
  .setD(-2) // sint32
  .build()
  .toByteArray();

序列化之後的數據如下圖所示:

這裏寫圖片描述

Length-delimited

如前所述,64-bit和32-bit是定長編碼格式,長度固定。Varint是變長編碼格式,長度由字節的MSB決定。Length-delimited編碼格式則會將數據的length也編碼進最終數據,使用Length-delimited編碼格式的數據類型包括string、bytes和自定義消息。以string爲例:

byte[] data7 = Test.newBuilder()
  .setF("hello") // string
  .build()
  .toByteArray();

序列化之後的數據如下圖所示:

hello

下面是自定義消息的例子:

byte[] data8 = Test.newBuilder()
  .setI(Test.newBuilder().setA(1))
  .build()
  .toByteArray();

序列化之後的數據如下圖所示:

i

repeated

前面討論的字段都是optional類型,最多隻有一個val,但是repeated字段卻可以有多個val。那麼repeated字段是如何序列化的呢?以Test消息的g字段爲例:

byte[] data9 = Test.newBuilder()
  .addG(1).addG(2).addG(3)
  .build()
  .toByteArray();

序列化之後的數據如下圖所示:

repeated

可見,repeated字段就是簡單的把每個字段值依次序列化而已。

packed

如果repeated字段包含的val比較多,那麼每個val都帶上key是不是比較浪費呢?是的,所以Protobuf提供了packed選項,以Test消息的h字段爲例:

byte[] data10 = Test.newBuilder()
  .addH(1).addH(2).addH(3) // packed
  .build()
  .toByteArray();

序列化之後的數據如下圖所示:

packed

可見,如果repeated字段設置了packed選項,則會使用Length-delimited格式來編碼字段值。


結束。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章