天天看點

深入Protobuf源碼-編碼實作

在前文有提到消息是一系列的基本類型以及其他消息類型的組合,因而基本類型是probobuf編碼實作的基礎,這些基本類型有:

.proto type

java type

c++ type

wire type

double

wiretype_fixed64(1)

float

wiretype_fixed32(5)

int64

long

wiretype_varint(0)

int32

int

uint64

unit64

uint32

unit32

sint64

sint32

fixed64

fixed32

sfixed64

sfixed32

bool

boolean

string

wiretype_length_delimited(2)

bytes

bytestring

在java種對不同類型的選擇,其他的類型差別很明顯,主要在與int32、uint32、sint32、fixed32中以及對應的64位版本的選擇,因為在java中這些類型都用int(long)來表達,但是protobuf内部使用zigzag編碼方式來處理多餘的符号問題,但是在編譯生成的代碼中并沒有驗證邏輯,比如uint的字段不能傳入負數之類的。而從編碼效率上,對fixed32類型,如果字段值大于2^28,它的編碼效率比int32更加有效;而在負數編碼上sint32的效率比int32要高;uint32則用于字段值永遠是正整數的情況。

在實作上,protobuf使用codedoutputstream實作序列化邏輯、codedinputstream實作反序列化邏輯,他們都包含write/read基本類型和message類型的方法,write方法中同時包含fieldnumber和value參數,在寫入時先寫入由fieldnumber和wiretype組成的tag值(添加這個wiretype類型資訊是為了在對無法識别的字段編碼時可以通過這個類型資訊判斷使用那種方式解析這個未知字段,是以這幾種類型值即可),這個tag值是一個可變長int類型,所謂的可變長類型就是一個位元組的最高位(msb,most significant bit)用1表示後一個位元組屬于目前字段,而最高位0表示目前字段編碼結束。在寫入tag值後,再寫入字段值value,對不同的字段類型采用不同的編碼方式:

1. 對int32/int64類型,如果值大于等于0,直接采用可變長編碼,否則,采用64位的可變長編碼,因而其編碼結果永遠是10個位元組,所有說它int32/int64類型在編碼負數效率很低(然而這裡我一直木有想明白對int32類型為什麼需要做64位的符号擴充,不擴充,5個位元組就可以了啊,而且對64位的負數也不需要用符号擴充,或者無法符号擴充,google上也沒有找到具體原因)。

2. 對uint32/uint64類型,也采用變長編碼,不對負數做驗證。

3. 對sint32/sint64類型,首先對該值做zigzag編碼,以保留,然後将編碼後的值采用變長編碼。所謂zigzag編碼即将負數轉換成正數,而所有正數都乘2,如0編碼成0,-1編碼成1,1編碼成2,-2編碼成3,以此類推,因而它對負數的編碼依然保持比較高的效率。

4. 對fixed32/sfixed32/fixed64/sfixed64類型,直接将該值以小端模式的固定長度編碼。

5. 對double類型,先将double轉換成long類型,然後以8個位元組固定長度小端模式寫入。

6. 對float類型,先将float類型轉換成int類型,然後以4個位元組固定長度小端模式寫入。

7. 對bool類型,寫0或1的一個位元組。

8. 對string類型,使用utf-8編碼擷取位元組數組,然後先用變長編碼寫入位元組數組長度,然後寫入所有的位元組數組。

tag

msgbytesize

msgbyte

9. 對bytes類型(bytestring),先用變長編碼寫入長度,然後寫入整個位元組數組。

10. 對枚舉類型(類型值wiretype_varint),用int32編碼方式寫入定義枚舉項時給定的值(因而在給枚舉類型項指派時不推薦使用負數,因為int32編碼方式對負數編碼效率太低)。

11. 對内嵌message類型(類型值wiretype_length_delimited),先寫入整個message序列化後位元組長度,然後寫入整個message。

注:zigzag編碼實作:(n << 1) ^ (n >> 31) / (n << 1) ^ (n >> 63);在codedoutputstream中還存在一些用于計算某個字段可能占用的位元組數的compute靜态方法,這裡不再詳述。

在protobuf的序列化中,所有的類型最終都會轉換成一個可變長int/long類型、固定長度的int/long類型、byte類型以及byte數組。對byte類型的寫隻是簡單的對内部buffer的指派:

public void writerawbyte(final byte value) throws ioexception {

  if (position == limit) {

    refreshbuffer();

  }

  buffer[position++] = value;

}

對32位可變長整形實作為:

public void writerawvarint32(int value) throws ioexception {

  while (true) {

    if ((value & ~0x7f) == 0) {

      writerawbyte(value);

      return;

    } else {

      writerawbyte((value & 0x7f) | 0x80);

      value >>>= 7;

    }

對于定長,protobuf采用小端模式,如對32位定長整形的實作:    

public void writerawlittleendian32(final int value) throws ioexcep-tion {

    writerawbyte((value      ) & 0xff);

    writerawbyte((value >>  8) & 0xff);

    writerawbyte((value >> 16) & 0xff);

    writerawbyte((value >> 24) & 0xff);

對byte數組,可以簡單了解為依次調用writerawbyte()方法,隻是codedoutputstream在實作時做了部分性能優化。這裡不詳細介紹。

對codedinputstream則是根據codedoutputstream的編碼方式進行解碼,因而也不詳述,其中關于zigzag的解碼:(n >>> 1) ^ -(n & 1)

對于repeated字段,一般有兩種編碼方式:

1.     每個項都先寫入tag,然後寫入具體資料。如對基本類型:

data

而對message類型:

length

2.     先寫入tag,後count,再寫入count個項,每個項包含length|data資料。即:

count

從編碼效率的角度來看,個人感覺第二中情況更加有效,然而不知道處于什麼原因考慮,protobuf采用了第一種方式來編碼,個人能想到的一個理由是第一種情況下,每個消息項都是相對獨立的,因而在傳輸過程中接收端每接收到一個消息項就可以進行解析,而不需要等待整個repeated字段的消息包。對于基本類型,protobuf也采用了第一種編碼方式,後來發現這種編碼方式效率太低,因而可以添加[packed = true]的描述将其轉換成第三種編碼方式(第二種方式的變種,對基本資料類型,比第二種方式更加有效):

3. 先寫入tag,後寫入字段的總位元組數,再寫入每個項資料。即:

databytesize

目前protobuf隻支援基本類型的packed修飾,因而如果将packed添加到非repeated字段或非基本類型的repeated字段,編譯器在編譯.proto檔案時會報錯。

在protobuf中,将所有未識别字段儲存在unknownfieldset中,并且在每個由protobuf編譯生成的message類以及generatedmessage.builder中儲存了unknownfieldset字段unknownfields;該字段可以從codedinputstream中初始化(調用unknownfieldset.builder的mergefieldfrom()方法)或從使用者自己通過builder設定;在序列化時,調用unknownfieldset的writeto()方法将自身内容序列化到codedoutputstream中。

unknownfieldset顧名思義是未知字段的集合,其内部資料結構是一個fieldnumber到field的map,而一個field用于表達一個未知字段,它可以是任何值,因而它包含了所有5中類型的list字段,這裡并沒有對一個field驗證,因而允許多個相同fieldnumber的未知字段,并且他們可以是任意類型值。unknownfieldset采用messagelite程式設計模式,因而它實作了messagelite接口,并且定義了一個builder類實作messagelite.builder接口用于手動或從codedinputstream中建構unknownfieldset。雖然field本身沒有實作messagelite接口,它依然實作了該接口的部分方法,如writeto()、getserializedsize()用于實作向codedoutputstream中序列化自身,并且定義了field.builder類用于建構field執行個體。

在一個message序列化時(writeto()方法實作),在寫完所有可識别的字段以及擴充字段,這個定義在message中的unknownfieldset也會被寫入codedoutputstream中;而在從codedinputstream中解析時,對任何未知字段也都會被寫入這個unknownfieldset中。

深入Protobuf源碼-編碼實作

在寫架構代碼時,經常由擴充性的需求,在java中,隻需要簡單的定義一個父類或接口即可解決,如果架構本身還負責建構執行個體本身,可以使用反射或暴露factory類也可以順利實作,然而對序列化來說,就很難提供這種動态plugin機制了。然而protobuf還是提出來一個相對可以接受的機制(文法有點怪異,但是至少可以用):在一個message中定義它支援的可擴充字段值的範圍,然後使用者可以使用extend關鍵字擴充該message定義(具體參考相關章節)。在實作中,所有這些支援字段擴充的message類型繼承自extendablemessage類(它本身繼承自generatedmessage類)并實作extendablemessageorbuilder接口,而它們的builder類則繼承自extendablebuilder類并且同時也實作了extendablemessageorbuilder接口。

extendablemessage和extendablebuilder類都包含fieldset<fielddescriptor>類型的字段用于儲存該message所有的擴充字段值。fieldset中儲存了fielddescriptor到其object值的map,然而在extendablemessage和extendablebuilder中則使用generatedextension來表識一個擴充字段,這是因為generatedextension除了包含對一個擴充字段的描述資訊fielddescriptor外,還存儲了該擴充字段的類型、預設值等資訊,在protobuf消息定義編譯器中會為每個擴充字段生成相應的generatedextension執行個體以供使用者使用:

public static final generatedextension<foo, integer> bar = generated-message.newfilescopedgeneratedextension( integer.class, null );

深入Protobuf源碼-編碼實作

bar.internalinit(descriptor.getextensions().get(0));

深入Protobuf源碼-編碼實作

base base = base.newbuilder().setextension(searchrequestprotos.bar, 11).build();

使用者使用該bar靜态字段用于作為key與它對應的值關聯,這種關聯關系寫入extensions字段中。進而在序列化時,對每個字段,按正常的值字段先寫tag在寫實際值内容将它序列化到codedoutputstream中(extensionwriter.writeuntil()方法);在反序列化中,我們需要告訴protobuf哪些字段是擴充字段,進而它在解析到無法識别的字段可以判斷這個字段是否是擴充字段,因而protobuf提供了extensionregistry類,它用于注冊所有識别的擴充字段,并且在protobuf編譯出來的代碼中也存在一個靜态方法将所有已定義的擴充字段注冊到使用者提供的extensionregistry執行個體中:    

public static void registerallextensions(extensionregistry registry) {

  registry.add(searchrequestprotos.bar);

深入Protobuf源碼-編碼實作