Protocol Buffer技術詳解(資料編碼)

工程師WWW發表於2013-12-10

 之前已經發了三篇有關Protocol Buffer的技術部落格,其中第一篇介紹了Protocol Buffer的語言規範,而後兩篇則分別基於C++和Java給出了一些相對比較實用而又簡單的示例。由於近期工作壓力很大,因此對於是否繼續寫本篇部落格也確實讓我糾結了幾天。但每每想到善終如始則無敗事這句話時,最終的決定還是既然開始了,就要儘自己最大的努力去做,而不要留有絲毫的遺憾。
      該篇Blog的內容將完全取自於Google的官方文件,只是為一些相對難以理解的技術點加入了適當的註解。但因技術能力有限,如解釋有誤,歡迎指正。
      這是一篇讓你對Protocol Buffer知其然亦知其所以然的文件,即便你在並不瞭解這其中的技術細節和處理機制的情況下,仍然能夠在你的應用程式中正常的使用Protocol Buffer,然而我相信,通過對這些細節和機制的深入瞭解,不僅可以讓你更好的使用和駕馭Protocol Buffer,而且還能深深地感受到Google工程師的智慧和高超的程式設計技藝,因此在我看來,深入的研習對我們程式設計能力的提高和思路的拓寬都是大有裨益的。不積跬步無以致千里。
    
      一、簡單訊息編碼佈局:
      讓我們先看一下下面的訊息定義示例:
      message Test1 {
          required int32 a = 1;
      }

      假設我們在應用程式中將欄位a的值設定為150(十進位制),此後再將該物件序列化到Binary檔案中,你可以看到檔案的資料為:
      08 96 01
      這3個位元組的含義又是什麼呢?它們又是按照什麼樣的編碼規則生成的呢?讓我們拭目以待。
    
      二、Base 128 Varints:
      在理解Protocol Buffer的編碼規則之前,你首先需要了解varints。varints是一種使用一個或多個位元組表示整型資料的方法。其中數值本身越小,其所佔用的位元組數越少。
      在varint中,除了最後一個位元組之外的每個位元組中都包含一個msb(most significant bit)設定(使用最高位),這意味著其後的位元組是否和當前位元組一起來表示同一個整型數值。而位元組中的其餘七位將用於儲存資料本身。由此我們可以簡單的解釋一下Base 128,通常而言,整數數值都是由位元組表示,其中每個位元組為8位,即Base 256。然而在Protocol Buffer的編碼中,最高位成為了msb,只有後面的7位儲存實際的資料,因此我們稱其為Base 128(2的7次方)。
      比如數字1,它本身只佔用一個位元組即可表示,所以它的msb沒有被設定,如:
      0000 0001
      再比如十進位制數字300,它的編碼後表示形式為:
      1010 1100 0000 0010
      對於Protocol Buffer而言又是如何將上面的位元組佈局還原成300呢?這裡我們需要做的第一步是drop掉每個位元組的msb。從上例中可以看出第一個位元組(1010 1100)的msb(最高位)被設定為1,這說明後面的位元組將連同該位元組表示同一個數值,而第二個位元組(0000 0010)的msb為0,因此該位元組將為表示該數值的最後一個位元組了,後面如果還有其他的位元組資料,將表示其他的資料。
      1010 1100 0000 0010
      -> 010 1100 000 0010

      上例中的第二行已經將第一行中每一個位元組的msb去除。由於Protocol Buffer是按照Little Endian的方式進行資料佈局的,因此我們這裡需要將兩個位元組的位置進行翻轉。
      010 1100 000 0010
      -> 000 0010 010 1100           //翻轉第一行的兩個位元組
      -> 100101100                         //將翻轉後的兩個位元組直接連線並去除高位0
      -> 256 + 32 + 8 + 4 = 300    //將上一行的二進位制資料換算成十進位制,其值為300
    
      三、訊息結構:
      Protocol Buffer中的訊息都是由一系列的鍵值對構成的。每個訊息的二進位制版本都是使用標籤號作為key,而每一個欄位的名字和型別均是在解碼的過程中根據目標型別(反序列化後的物件型別)進行配對的。在進行訊息編碼時,key/value被連線成位元組流。在解碼時,解析器可以直接跳過不識別的欄位,這樣就可以保證新老版本訊息定義在新老程式之間的相容性,從而有效的避免了使用older訊息格式的older程式在解析newer程式發來的newer訊息時,一旦遇到未知(新新增的)欄位時而引發的解析和物件初始化的錯誤。最後,我們介紹一下欄位標號和欄位型別是如何進行編碼的。下面先列出Protocol Buffer可以支援的欄位型別。

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages, packed repeated fields
3 Start group groups (deprecated)
4 End group groups (deprecated)
5 32-bit fixed32, sfixed32, float

      由於在編碼後每一個欄位的key都是varint型別,key的值是由欄位標號和欄位型別合成編碼所得,其公式如下:
      field_number << 3 | field_type
      由此看出,key的最後3個bits用於儲存欄位的型別資訊。那麼在使用該編碼時,Protocol Buffer所支援的欄位型別將不會超過8種。這裡我們可以進一步計算出Protocol Buffer在一個訊息中可以支援的欄位數量為2的29次方減一。現在我們再來回顧一下之前給出的Test1訊息被序列化後的第一個位元組08的由來。
      0000 1000
      -> 000 1000                  
//drop掉msb(最高位)
      最低的3位表示欄位型別,即0為varint。我們再將結果右移3位( >> 3),此時得到的結果為1,即欄位a在訊息Test1中的標籤號。通過這樣的結果,Protocol Buffer的解碼器可以獲悉當前欄位的標籤號是1,其後所跟隨資料的型別為varint。現在我們可以繼續利用上面講到的知識分析出後兩個位元組(96 01)的由來。
      96 01 = 1001 0110 0000 0001
          -> 001 0110 000 0001   
//drop兩個位元組的msb
          -> 000 0001 001 0110   //翻轉高低位元組
          -> 10010110                   //去掉最高位中沒用的0
          -> 128 + 16 + 4 + 2 = 150
    
      四、更多的值型別:
      1. 有符號整型
      如前所述,型別0表示varint,其中包含int32/int64/uint32/uint64/sint32/sint64/bool/enum。在實際使用中,如果當前欄位可以表示為負數,那麼對於int32/int64和sint32/sint64而言,它們在進行編碼時將存在著較大的差別。如果使用int32/int64表示一個負數,該欄位的值無論是-1還是-2147483648,其編碼後長度將始終為10個位元組,就如同對待一個很大的無符號整型一樣。反之,如果使用的是sint32/sint64,Protocol Buffer將會採用ZigZag編碼方式,其編碼後的結果將會更加高效。
      這裡簡單講述一下ZigZag編碼,該編碼會將有符號整型對映為無符號整型,以便絕對值較小的負數仍然可以有較小的varint編碼值,如-1。下面是ZigZag對照表:

Signed Original Encoded As
0 0
-1 1
1 2
-2 3
2147483647 4294967294
-2147483648 4294967295

      其公式為:
      (n << 1) ^ (n >> 31)    //sint32
      (n << 1> ^ (n >> 63)   //sint64
      需要補充說明的是,Protocol Buffer在實現上述位移操作時均採用的算術位移,因此對於(n >> 31)和(n >> 63)而言,如果n為負值位移後的結果就是-1,否則就是0。
      注:簡單解釋一下C語言中的算術位移和邏輯位移。他們的左移操作都是相同的,即低位補0,高位直接移除。不同的是右移操作,邏輯位移比較簡單,高位全部補0。而算術位移則需要視當前值的符號位而定,補進的位和符號位相同,即正數全補0,負數全補1。換句話說,算術位移右移時要保證符號位的一致性。在C語言中,如果使用 int變數位移時就是算術位移,uint變數位移時是邏輯位移。
      2. Non-varint數值型
      double/fixed64始終都佔用8個位元組,float/fixed32始終佔用4個位元組。
      3. Strings
      其型別值為2,key資訊之後是位元組陣列的長度資訊,最後在緊隨指定長度的實際資料值資訊。如:
      message Test2 {
          required string b = 2;
      }

      現在我們設定b的值為"testing"。其編碼後資料如下:
      12 07 74 65 73 74 69 6E 67
      第一個位元組0x12表示key,通過解碼可以得到欄位型別2和欄位標號2。第二個位元組07表示testing的長度。後面7個紅色高亮的位元組則表示testing。
    
      五、嵌入訊息:
      這裡是一個包含嵌入訊息的訊息定義。
      message Test3 {
          required Test1 c = 3;
      }

      此時我們先將Test1的a欄位值設定為150,其編碼結果如下:
      1A 03 08 96 01
      從上面的結果可以看出08 96 01和之前直接編碼Test1時是完全一致的,只是在前面增加了key(欄位型別 + 標號)和長度資訊。新增資訊的解碼方式和含義與前面的Strings完全相同,這裡不再重複解釋了。
    
      六、Packed Repeated Fields:
      Protocol Buffer從2.1.0版本開始引入了[pack = true]的欄位級別選項。如果設定該選項,那麼元素數量為0的repeated欄位將不會被編碼,否則陣列中的所有元素會被編碼成一個單一的key/value形式。畢竟陣列中的每一個元素都具有相同的欄位型別和標號。該編碼形式,對包含較小值的整型元素而言,優化後的編碼結果可以節省更多的空間。如:
      message Test4 {
          repeated int32 d = 4 [pack=true];
      }

      這裡我們假設d欄位包含3個元素,值分別為3,270,86942。編碼結果如下:
      22             //key (欄位標號4,型別為2)
      06             //資料中所有元素所佔用的位元組數量
      03             //第一個元素(varint 3)
      8E 02        //第二個元素(varint 270)
      9E A7 05  //第三個元素(varint 86942)
    
      七、欄位順序: 
      在.proto檔案中定義訊息的欄位標號時,可以是不連續的,但是如果將其定義為連續遞增的數值,將獲得更好的編碼和解碼效能。
    
      結束語:
      本篇部落格是Protocol Buffer技術詳解系列的最後一篇部落格,同時該系列部落格又將是開源學習之旅系列主題中的第一個系列,希望今後能夠藉此平臺與大家進行更多的技術交流,共同提高。如有意見或問題,歡迎留言。

相關文章