google protocol buffer——protobuf的編碼原理二

tera發表於2020-08-30

這一系列文章主要是對protocol buffer這種編碼格式的使用方式、特點、使用技巧進行說明,並在原生protobuf的基礎上進行擴充套件和優化,使得它能更好地為我們服務。

 

在上一篇文章中,我們主要通過一些示例瞭解了protobuf的使用特性,以及和這些特性相關的基礎編碼原理。

編碼原理只開了個頭,所以本文將繼續展示protobuf剩餘的編碼原理

 

在之前的文章中,我們只是定義了一些非常簡單的模型,其中只包含了string、int和一個Name物件,所以我們首先先定義一個更復雜的模型

.proto檔案如下

syntax = "proto3";

option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "ProtobufStudent";

message Student{
  int32 age = 1;
  int64 hairCount = 2;
  bool isMale = 3;
  string name = 4;
  double height = 5;
  float weight = 6;
  Parent father = 7;
  Parent mother = 8;
  repeated string friends = 9;
  repeated Hobby hobbies = 10;
  Color hairColor = 11;
  bytes scores = 12;
  uint32 uage = 13;
  sint32 sage = 14;
}

message Parent {
  string name = 1;
  int32 age = 2;
}

message Hobby {
  string name = 1;
  int32 cost = 2;
}

enum Color {
  BLACK = 0;
  RED = 1;
  YELLOW = 2;
}

 

相比之前定義的模型,這裡新增了int64,bool,double,float,repeated,enum,uint,sint型別

repeated型別對應的是java中的list

protobuf將這些具體的型別分為了幾個大類,如下面這個表格所示

TypeMeaningUsed 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
5 32-bit fixed32, sfixed32, float

接著我們就通過例項來看下每種資料結構的編碼方式

1.Varint

這種型別的資料,在序號位元組中的型別部分表示為000,即表格中的Type欄位0

首先我們看最簡單的4種型別,protobuf型別為int32、int64、bool、enum,模型中對應這種型別的欄位是age、hairCount、isMale、hairColor,因此我們分別給這4個欄位賦值

age測試程式碼

/**
 * protobuf基礎編碼,varint型別
 */
@Test
void protobufBaseEncodeTest() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setAge(15)
    Utility.printByte(student.toByteArray());
}

輸出結果

8    15    
00001000 00001111

這裡複習一下上一篇文章中關於protobuf的編碼基礎

第一個位元組表示欄位的序號和型別

黃色底000,表示該資料型別是varint

藍色0001,表示序號為1

紅色0,表示序號解析到了最後一個位元組

第二個位元組表示數字的值15

通過varint解碼後,即是15

hairCount測試程式碼

@Test
void protobufBaseEncodeTest() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setHairCount(239281373231123L)
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

16    -109    -16    -126    -54    -128    -76    54    
00010000 10010011 11110000 10000010 11001010 10000000 10110100 00110110 

第一個位元組表示欄位的序號和型別

黃色底000,表示該資料型別是varint

藍色0010,表示序號為2

紅色0,表示序號解析到了最後一個位元組

後面7個位元組,通過varint解碼後,即是239281373231123L

isMale測試程式碼

@Test
void protobufBaseEncodeTest() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setIsMale(true)
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

24    1    
00011000 00000001 

序號位元組結構和之前一樣

這裡因為賦值的是true,所以值是1,如果賦值是false的話,那麼該欄位就不會被編碼了(因為bool型別預設就是false)

hairColor測試程式碼

@Test
void protobufBaseEncodeTest() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setHairColor(ProtobufStudent.Color.RED)
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

88    1    
01011000 00000001 

序號位元組結構和之前一樣,這裡因為賦值的是Color.RED,我們檢視列舉值表即為1,如果賦值的是Color.BLACK,則該欄位將不會被編碼(因為int型別預設值就是0)

 

上面4個例子是可以通過正數就可以表達的型別,接著我們看對於有符號的正數,protobuf是如何表達的

protobuf型別為int32、uint32、sint32,對應模型中的age、uage、sage(這裡注意,雖然在.proto檔案中我們分了3個型別進行定義,但最終對映到java的型別都是int)

負數age測試程式碼

/**
 * protobuf基礎編碼,有符號的整數
 */
@Test
void negativeIntTest() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setAge(-7)
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

8    -1    -1    -1    -1    -1    -1    -1    -1    -1    1    
00001000 11111001 11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111 00000001 

可以看到資料體佔用了10個位元組,通過varint解碼後就可以得到-7

因為一般負數的二進位制結果都是採用正數補碼的形式儲存,所以protobuf使用了一個長度固定為10個位元組的空間對負數進行編碼,即使是-7也需要10個位元組進行儲存,其實是十分不合理的,因此我們看下uint和sint的表現

uage測試程式碼

/**
 * protobuf基礎編碼,有符號的整數
 */
@Test
void negativeIntTest() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setUage(-7)
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

104    -1    -1    -1    -1    15    
01101000 11111001 11111111 11111111 11111111 00001111 

如果定義為uint32的話,那麼固定的資料儲存空間則會縮減為5個位元組

sage測試程式碼

/**
 * protobuf基礎編碼,有符號的整數
 */
@Test
void negativeIntTest() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setSage(-7)
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

112    13    
01110000 00001101 

粗看一下還不錯,至少是用一個位元組就表示了,但是仔細觀察就會發現,我們傳入的數字明明是-7,但是編碼結果卻是13

原因在於如果我們定義的是sint32,那麼protobuf會採用一種叫做ZigZag的編碼方式,即一種資料的對映,表格如下

Signed OriginalEncoded As
0 0
-1 1
1 2
-2 3
2 4
... ...

即設需要編碼的整數為n:如果n>=0,則對映為2n;如果n<0,則對映為-2n-1

用程式碼來表示的話:

對於int32型別,對映規則為

(n << 1) ^ (n >> 31)

對於int64型別,對映規則為

(n << 1) ^ (n >> 63)

對映之後,再將對映的值通過varint的方式編碼成位元組

回過頭看-7,對應的對映正是13,因此編碼結果中也就是13

當然採取這種ZigZag進行對映後,對於負數編碼所需的空間會減少,但對於正數的編碼結果則會多出1個bit(看一下對映規則,如果n>=0,則對映為2n)

因此綜上測試結果,如果我們能夠預知在使用的過程中會遇到負數,那麼從編碼結果位元組數的角度來說,採用sint定義.proto的欄位將會是一個更優的選擇

 

這裡總結一下Varint的編碼方式,首先由一個序號位元組標識欄位的序號和型別,資料體無論是int、long、bool、enum,因為其最終總能用一個數字表示,因此他們都能統一地通過varint進行編碼,所以這種資料型別的分類就叫Varint

對於正數來說,直接通過varint編碼即可

而對於負數來說,int會採用固定的10個位元組對補碼進行varint編碼,uint會採用固定的5個位元組對補碼進行varint編碼,而sint則是採用ZigZag的對映將負數對映成一個正數後再進行varint編碼

順帶一提,在進行varint解碼的時候,我們會發現它是需要將位元組順序反轉之後才能解析出我們需要的數字(這裡詳解見protobuf的使用特性及編碼原理),而這種反轉存放的形式正是小端儲存,即little-endian,這部分有興趣的同學可以自己再去了解一下

 

 

2.64-bit和32-bit

這裡我將64bit和32bit放到一起,因為他們之間的編碼區別僅在於最終位元組數量的不同,而編碼原理是一樣的

這兩種型別的資料,在序號位元組中的型別部分表示為001和101,即表格中的Type欄位0和5

模型中對應這種型別的欄位是height、weight因此我們分別給這2個欄位賦值

height測試程式碼

/**
 * protobuf基礎編碼,double和float型別
 */
@Test
void protobufBaseEncodeTestDoubleAndFloat() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setHeight(99.6)
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

41    102    102    102    102    102    -26    88    64    
00101001 01100110 01100110 01100110 01100110 01100110 11100110 01011000 01000000 

第一個位元組表示欄位的序號和型別

黃色底001,即十進位制的1,表示該資料型別64-bit

藍色0101,表示序號為5

紅色0,表示序號解析到了最後一個位元組

後續的8個位元組正是99.6的二進位制表達形式,protobuf採用的標準是IEEE754。不過因為該標準的內容比較複雜,可以單獨成文,所以就不放在這裡展開了,本文還是專注於protobuf本身。

這裡提供兩個網址

維基百科:https://zh.wikipedia.org/wiki/IEEE_754

線上轉換:http://www.binaryconvert.com/result_double.html

我們進入線上轉換站點,輸入99.6,得到結果

 

和varint型別資料的儲存方式一樣,這裡採用的也是小端儲存(little-endian),因此將圖中的位元組反轉之後,即可得到我們前面程式碼輸出的內容

weight測試程式碼

/**
 * protobuf基礎編碼,double和float型別
 */
@Test
void protobufBaseEncodeTestDoubleAndFloat() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setWeight(99.6F)
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

53    51    51    -57    66    
00110101 00110011 00110011 11000111 01000010 

序號位元組結構和double一樣,只不過表示型別的3個bit是101,即十進位制的5,表示bit-32型別

後面4個位元組,也是float型別的IEEE754標準編碼,並且採用小端儲存的方式,我們可以再去轉換驗證一下,如下圖

 

 這裡總結一下bit64和bit32的編碼方式:和varint一樣,通過一個序號位元組來標識序號和型別,而資料體是long和double型別,採用的都是IEEE754標準的編碼方式,並且位元組是小端儲存

 

3.Length-delimited

這種型別的資料,在序號位元組中的型別部分表示為010,即表格中的Type欄位2

從名字上來看,就是“長度限定”的意思,也就是說這種型別的資料都是需要指定長度的

包括string, 位元組陣列, 子物件,list,對應我們模型中的name、scores、father、friends

name測試程式碼

/**
 * protobuf基礎編碼,LengthDelimited型別
 */
@Test
void protobufBaseEncodeTestLengthDelimited() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setName("tera")
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

34    4    116    101    114    97    
00100010 00000100 01110100 01100101 01110010 01100001 

第一個位元組表示欄位的序號和型別

黃色底010,即十進位制的2,表示該資料型別Length-delimited

藍色0100,表示序號為4

紅色0,表示序號解析到了最後一個位元組

之前提到這種型別的名稱叫“長度限定”,因此和varint以及64bit型別的資料相比,這裡會額外多出一個位元組,代表後續資料的位元組長度,用粉色底表示

這裡我們看到長度為00000100,即十進位制的4,也就是說後面4個位元組是實際的資料

而"tera"4個英文字母對應的utf-8位元組正是01110100 01100101 01110010 01100001

scores測試程式碼

/**
 * protobuf基礎編碼,LengthDelimited型別
 */
@Test
void protobufBaseEncodeTestLengthDelimited() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setScores(ByteString.copyFrom(new byte[200]))
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

98    -56    1    0    0   ...   0     
01100010 11001000 00000001 00000000 00000000 ... 00000000

因為陣列的長度我設定為200,所以就用...表示中間省略的輸出

序號位元組結構和之前一樣

第二和第三個位元組為長度位元組,編碼方式為varint,解碼後得到11001000,即十進位制的200,表示後面200個位元組都是資料

沒有給byte陣列賦值,所以資料的值預設都是0

friends測試程式碼

/**
 * protobuf基礎編碼,LengthDelimited型別
 */
@Test
void protobufBaseEncodeTestLengthDelimited() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .addFriends("a")
            .addFriends("b")
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

74    1    97    74    1    98    
01001010 00000001 01100001 01001010 00000001 01100010 

因為這是一個list,因此其中包含了多個資料段

第一和第四個位元組分別表示2個資料的序號和型別,特別注意的是,因為這2個資料是屬於同一個list的,所以序號都是1001,即十進位制的9

第二和第五個位元組表示資料的長度,因為我這裡只是新增了a和b,所以資料長度都是1

第三和第六個位元組表示資料的值,也就是a和b對應的utf-8編碼

father測試程式碼

/**
 * protobuf基礎編碼,lengthDelimited型別
 */
@Test
void protobufBaseEncodeTestLengthDelimited() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setFather(ProtobufStudent.Parent.newBuilder()
                    .setName("MrTera"))
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

58    8    10    6    77    114    84    101    114    97    
00111010 00001000 00001010 00000110 01001101 01110010 01010100 01100101 01110010 01100001  

第一個位元組為序號和型別,表示序號7,Length-Delimited型別的資料

第二個位元組為十進位制的8,即表示後面8個位元組就是資料體

這裡特別注意,因為father欄位本身就是一個Parent物件,所以這個資料體本身就是一個完整的protobuf的資料結構

此時我們就可以將後8個位元組看成一個獨立的protobuf結構

第三個位元組為father中的欄位序號和型別,表示序號1,Length-Delimited型別的資料,注意,這裡的序號1代表的是father欄位的Parent類中序號為1

第四個位元組為father中的資料長度,表示後面6個位元組為資料體

最後6個位元組即為字串"MrTera"的utf-8編碼

 

這裡總結一下Length-Delimited型別的資料,同樣通過一個序號位元組來標識序號和型別,額外有位元組標識資料體的長度。

對於string和byte陣列的資料體,以utf-8的形式進行編碼

而對於子物件來說,資料體就是一個獨立完整的protobuf結構

對於list來說,則根據其內容的不同,分別採用直接utf-8或者完整protobuf結構編碼

這裡額外展示一個示例,用list儲存物件,我將bit相應的含義都用顏色標識好,具體的含義留給讀者自行分析,從而可以更好地理解protobuf的編碼原理

/**
 * protobuf基礎編碼,lengthDelimited型別
 */
@Test
void protobufBaseEncodeTestLengthDelimited() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .addHobbies(ProtobufStudent.Hobby.newBuilder().setName("a"))
            .addHobbies(ProtobufStudent.Hobby.newBuilder().setName("b"))
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

82    3    10    1    97    82    3    10    1    98    
01010010 00000011 00001010 00000001 01100001 01010010 00000011 00001010 00000001 01100010 

 

最後讓我們把前面所講到的編碼原理結合到一起,看一下一個相對完整的編碼結果

測試程式碼

/**
 * 一個相對完整的模型
 */
@Test
void entireModelTest() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setAge(12)
            .setName("tera")
            .setIsMale(true)
            .setFather(ProtobufStudent.Parent.newBuilder()
                    .setName("MrTera"))
            .addFriends("peter")
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

8    12    24    1    34    4    116    101    114    97    58    8    10    6    77    114    84    101    114    97    74    5    112    101    116    101    114    
00001000 00001100 00011000 00000001 00100010 00000100 01110100 01100101 01110010 01100001 00111010 00001000 00001010 
00000110 01001101 01110010 01010100 01100101 01110010 01100001 01001010 00000101 01110000 01100101 01110100 01100101 01110010

整個結果就是之前每一組單獨示例的結果拼合到一起,這裡我大致標識下每個位元組的意義,序號位元組就不細分了,而是全部採用黃色背景,其他顏色和之前的都一致。有興趣的同學可以再仔細分析一下

 

此時我們考慮這樣一個問題,對於所屬大類相同,但是實際型別不同的資料,在解碼的時候該如何區分?

例如,我們編碼如下一組資料

/**
 * 資料型別的分辨
 */
@Test
void differDatatype() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setAge(14)
            .setSage(-7)
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

8    14    112    14    
00001000 00001110 01110000 00001110 

可以看到age和sage的在編碼後的值都是00001110,而且資料型別都是000(varint),唯一有區別的僅僅是序號

那麼當我們僅僅拿到這4個位元組的時候,是完全無法將編碼後的資料還原成原始資料的!!

任何大型別相同,而實際型別不同的資料都有可能發生這種情況,例如字串和位元組陣列

/**
 * 資料型別的分辨
 */
@Test
void differDatatype() {
    ProtobufStudent.Student student = ProtobufStudent.Student.newBuilder()
            .setName("aaa")
            .setScores(ByteString.copyFrom(new byte[]{97, 97, 97}))
            .build();
    Utility.printByte(student.toByteArray());
}

輸出結果

34    3    97    97    97    98    3    97    97    97    
00100010 00000011 01100001 01100001 01100001 01100010 00000011 01100001 01100001 01100001

可以看到資料型別、長度、值都是相同的

這裡再次強調,protobuf是一個不可以自解釋的編碼方式,除了欄位的名字被拋棄之外,資料解析的歧義性也是其不可自解釋的原因之一

所以protobuf的正確解析必須依賴於我們在編譯.proto檔案時,自動生成的那個巨大的java檔案(這部分的詳細解析可以看protobuf的基本使用和模型分析

也正是因為這個protobuf的不可自解釋,在我們傳遞protobuf編碼的時候,資料傳送方和資料接收方都必須有相同的.proto檔案,其中的欄位序號必須一一對應,一旦有偏差,那麼最終的解析一定是有問題的,再次說明序號是protobuf編碼的靈魂所在

 

 

最後總結一下protobuf的編碼原理

1.最重要的序號位元組,每一個資料段都必須有該位元組;標識欄位的序號和型別;型別共有3種大型別;

2.varint,可以轉化為int型別的資料,包括各種int32、int64等整型相關型別以及bool、enum,編碼方式為varint,位元組順序為小端儲存

3.double和float,浮點型別資料,編碼方式為IEEE754標準,位元組順序為小端儲存

4.Length-Delimited型別,需要指定資料體長度的型別,包括string、list、byte陣列、子物件等,除了序號位元組和資料內容位元組,還需要額外的位元組標識資料的長度

5.對於大型別相同,而小型別不同的資料,在解碼的時候是通過編譯時生成的java程式碼對其進行區分,僅依靠位元組資料本身是無法區分的

 

相關文章