google protocol buffer——protobuf的使用特性及編碼原理


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

 

在上一篇文章中,我們展示了protobuf在java中的基本使用方式。而本文將繼續深入探究protobuf的編碼原理。

主要分為兩個部分

第一部分是結合上一篇文章留下的幾個伏筆展示protobuf的使用特性

第二部分是分析protobuf的編碼原理,解釋特性背后的原因

 

第一部分,Protobuf使用特性

1.不同類型對象的轉換

我們先定義如下一個.proto文件 

syntax = "proto3";

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

message Person {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

message Article {
  string title = 1;
  int32 wordsCount = 2;
  string author = 3;
}

其中我們定義了2個模型,一個Person,一個Article,雖然他們的字段名字不相同,但是類型和編號都是一致的

接着我們生成.java文件,最終文件結構如下圖

此時我們嘗試做如下的一個轉換

/**
 * 測試不同模型間的轉換
 * @throws Exception
 */
@Test
public void parseDifferentModelsTest() throws Exception {
    //創建一個Person對象
    DifferentModels.Person person = DifferentModels.Person.newBuilder()
            .setName("person name")
            .setId(1)
            .setEmail("tera@google.com")
            .build();
    //對person編碼
    byte[] personBytes = person.toByteArray();
    //將編碼后的數據直接merge成Article對象
    DifferentModels.Article article = DifferentModels.Article.parseFrom(personBytes);
    System.out.println("article's title:" + article.getTitle());
    System.out.println("article's wordsCount:" + article.getWordsCount());
    System.out.println("article's author:" + article.getAuthor());
}

輸出結果如下

article's title:person name
article's wordsCount:1
article's author:tera@google.com

可以看到,雖然jsonBytes是由person對象編碼得到的,但是可以用於article對象的解碼,不但不會報錯,所有的數據內容都是完整保留的

這種兼容性的前提是模型中所定義的字段類型和序號都是一一對應相同的

在平時的編碼中,我們經常會遇到從數據庫中讀取數據模型,然后將其轉換成業務模型,而很多時候,這2種模型的內容其實是完全一致的,此時我們也許就可以使用protobuf的這種特性,就可以省去很多低效的賦值代碼

 

2.protobuf序號的重要性

在上一篇文章中,我們看到在定義.proto文件時,字段后面會跟着一個"= X",這里並不是指這個字段的值,而是表示這個字段的“序號”,和正確地編碼與解碼息息相關,在我看來是protocol buffer的靈魂

我們定義如下的.proto文件,這里注意,Model1和Model2的name和id的序號有不同

syntax = "proto3";

option java_package = "cn.tera.protobuf.model"; option java_outer_classname = "TagImportance"; message Model1 { string name = 1; int32 id = 2; string email = 3; } message Model2 { string name = 2; int32 id = 1; string email = 3; }

定義如下的測試方法 

/**
 * 序號的重要性測試
 *
 * @throws Exception
 */
@Test
public void tagImportanceTest() throws Exception {
    TagImportance.Model1 model1 = TagImportance.Model1.newBuilder()
            .setEmail("model1@google.com")
            .setId(1)
            .setName("model1")
            .build();
    TagImportance.Model2 model2 = TagImportance.Model2.parseFrom(model1.toByteArray());
    System.out.println("model2 email:" + model2.getEmail());
    System.out.println("model2 id:" + model2.getId());
    System.out.println("model2 name:" + model2.getName());
    System.out.println("-------model2 數據---------");
    System.out.println(model2);
}

輸出結果如下

model2 email:model1@google.com
model2 id:0
model2 name:
-------model2 數據---------
email: "model1@google.com"
1: "model1"
2: 1

可以看到,雖然Model1和Model2定義的字段類型和名字都是相同的,然而name和id的序號顛倒了一下,導致最終model2在解析byte數組時,無法正確將數據解析到對應的字段上,所以輸出的id為0,而name字段為null

不過即使字段無法一一對應,但在輸出model2.toString()時,我們依然可以看到數據是被解析到了,只不過無法對應到具體字段,只能用1,2來表示其字段名 

 

3.protobuf序號對編碼結果大小的影響

protobuf的序號不僅影響編碼、解碼的正確性,一定程度上還會影響編碼結果的字節數

我們在上面的.proto文件中增加一個Model3,其中Model3中定義的字段沒有變化,但是序號更改為16,17,18

syntax = "proto3";

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

message Model1 {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

message Model2 {
  string name = 2;
  int32 id = 1;
  string email = 3;
}

message Model3 {
  string name = 16;
  int32 id = 17;
  string email = 18;
}

測試方法

/**
 * 序號對編碼大小的影響
 *
 * @throws Exception
 */
@Test
public void tagSizeInfluenceTest() throws Exception {
    TagImportance.Model1 model1 = TagImportance.Model1.newBuilder()
            .setEmail("model1@google.com")
            .setId(1)
            .setName("model1")
            .build();
    System.out.println("model1 編碼大小:" + model1.toByteArray().length);

    TagImportance.Model3 model3 = TagImportance.Model3.newBuilder()
            .setEmail("model1@google.com")
            .setId(1)
            .setName("model1")
            .build();
    System.out.println("model3 編碼大小:" + model3.toByteArray().length);
}

輸出結果如下

model1 編碼大小:29
model3 編碼大小:32

可以看到,在數據量完全相同的情況下,編號偏大的對象編碼的結果也會偏大

 

4.模型字段數據類型兼容性

在上一篇文章中我在getName()方法中提到了靈活性,接下去就展示一下該特性

我們定義如下的.proto文件

syntax = "proto3";

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

message OldPerson {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

message NewPerson {
  Name name = 1;
  int32 id = 2;
  string email = 3;
}

message Name {
  string first = 1;
  string last = 2;
  int32 usedYears = 3;
}

其中定義了2個Person對象

在OldPerson中,name是一個純String

在NewPerson中,name字段則被定義為了一個對象

此時我們做如下的操作

/**
 * 模型字段不同類型的兼容性
 *
 * @throws Exception
 */
@Test
public void typeCompatibleTest() throws Exception {
    ModelTypeCompatible.NewPerson newPerson = ModelTypeCompatible.NewPerson.newBuilder()
            .setName(ModelTypeCompatible.Name.newBuilder()
                    .setFirst("tera")
                    .setLast("cn")
                    .setUsedYears(10)
            ).setId(5)
            .setEmail("tera@google.com")
            .build();
    ModelTypeCompatible.OldPerson oldPerson = ModelTypeCompatible.OldPerson.parseFrom(newPerson.toByteArray());
    System.out.println(oldPerson.getName());
}

輸出結果如下

teracn

可以看到,雖然NewPerson的name字段是一個對象,但是卻可以被成功地轉換成OldPerson的String類型的name字段,雖然其中的usedYears字段被舍棄了

這種兼容性的前提是從對象類型向String類型轉換,而反向是不可以的

 

5.protobuf與json之間的轉換和對比

json是現在應用最為廣泛的數據結構之一,因此當我們決定使用protobuf時,不可避免的問題就是它和json的兼容性

因此接下去我們看下protobuf和json之間是如何轉換的

我們先構造一個簡單的java類

public class PersonJson {
    public String name;
    public int id;
    public String email;
}

重復利用前一篇文章中生成的protobuf模型BasicUsage.Person,以及前文就引入的json相關的maven,我們測試如下方法

/**
 * json和protobuf的互相轉換
 */
@Test
void jsonToProtobuf() throws Exception {
    //構造簡單的模型
    PersonJson model = new PersonJson();
    model.email = "personJson@google.com";
    model.id = 1;
    model.name = "personJson";
    String json = JSON.toJSONString(model);
    System.out.println("原始json");
    System.out.println("------------------------");
    System.out.println(json);
    System.out.println();

    //parser
    JsonFormat.Parser parser = JsonFormat.parser();
    //需要build才能轉換
    BasicUsage.Person.Builder personBuilder = BasicUsage.Person.newBuilder();
    //將json字符串轉換成protobuf模型,並打印
    parser.merge(json, personBuilder);
    BasicUsage.Person person = personBuilder.build();
    //需要注意的是,protobuf的toString方法並不會自動轉換成json,而是以更簡單的方式呈現,所以一般沒法直接用
    System.out.println("protobuf內容");
    System.out.println("------------------------");
    System.out.println(person.toString());

    //修改protobuf模型中的字段,並再轉換會json字符串
    person = person.toBuilder().setName("protobuf").setId(2).build();
    String buftoJson = JsonFormat.printer().print(person);
    System.out.println("protobuf修改過數據后的json");
    System.out.println("------------------------");
    System.out.println(buftoJson);
}

輸出結果如下

原始json
------------------------
{"email":"personJson@google.com","id":1,"name":"personJson"}

protobuf內容
------------------------
name: "personJson"
id: 1
email: "personJson@google.com"

protobuf修改過數據后的json
------------------------
{
  "name": "protobuf",
  "id": 2,
  "email": "personJson@google.com"
}

可以看到json和protobuf是可以做到完全兼容的互相轉換

此時我們就可以比較一下,相容的數據內容經過json和protobuf分別編碼后的數據字節大小,我們就使用上面的數據內容,做如下的測試

/**
 * json和protobuf的編碼數據大小
 */
@Test
void codeSizeJsonVsProtobuf() throws Exception {
    //構造簡單的模型
    PersonJson model = new PersonJson();
    model.email = "personJson@google.com";
    model.id = 1;
    model.name = "personJson";
    String json = JSON.toJSONString(model);
    System.out.println("原始json");
    System.out.println("------------------------");
    System.out.println(json);
    System.out.println("json編碼后的字節數:" + json.getBytes("utf-8").length + "\n");

    //parser
    JsonFormat.Parser parser = JsonFormat.parser();
    //需要build才能轉換
    BasicUsage.Person.Builder personBuilder = BasicUsage.Person.newBuilder();
    //將json字符串轉換成protobuf模型,並打印
    parser.merge(json, personBuilder);
    BasicUsage.Person person = personBuilder.build();
    //需要注意的是,protobuf的toString方法並不會自動轉換成json,而是以更簡單的方式呈現,所以一般沒法直接用
    System.out.println("protobuf內容");
    System.out.println("------------------------");
    System.out.println(person.toString());
    System.out.println("protobuf編碼后的字節數:" + person.toByteArray().length);
}

輸出內容如下

原始json
------------------------
{"email":"personJson@google.com","id":1,"name":"personJson"}
json編碼后的字節數:60

protobuf內容
------------------------
name: "personJson"
id: 1
email: "personJson@google.com"

protobuf編碼后的字節數:37

可以看到,相同的數據內容,protobuf編碼的結果是json編碼結果的60%左右(當然這個數值是會隨着數據內容的不同浮動)

 

這里先總結一下之前的特性

1.protobuf的解碼不需要類型相同,也不需要字段名相同

2.protobuf的解碼依賴於序號的正確性

3.protobuf中的序號大小會影響最終編碼大小

4.protobuf的對象類型可以向String類型兼容

5.protobuf可以和json完全兼容,且編碼大小要比json小

 

第二部分,Protobuf編碼原理

首先,我們需要了解一種最基本的編碼方式varints(原文檔的單詞,沒有找到特別准確的翻譯,所以就就保留英文),這是一種用1個或多個字節對Integer進行編碼的方法

當一個Integer采用這種方式編碼后,除了最后一個字節,每一個字節的最高位都是1,而最后一個字節的最高位則是0,從而在解碼的時候可以通過判斷最高位的值來確定是否已經解碼到了最后一個字節。

每一個字節除了最高位的其他7個bit則用來存放數字本身的編碼

例如300,編碼后得到2個字節,紅色表示最高位bit,藍色表示數字本身編碼

1010 1100  0000 0010

其中第一個字節最高位bit為1,表示后面還有字節需要一並進行解碼。第二個字節最高位bit為0,則表示已經到達最后一個字節了

解碼時

1.去掉2個字節的最高位

010 1100  000 0010

2.反轉2個字節的順序

000 0010  010 1100

3.連接2個字節,構成了300的二進制形式

100101100

 

接着我們來看一個實際的例子,編碼一個Person對象,只給里面的id字段賦值

/**
 * varint數字編碼
 */
@Test
void varintTest() {
    BasicUsage.Person person = BasicUsage.Person.newBuilder()
            .setId(91809)
            .build();
    Utility.printByte(person.toByteArray());
}

輸出的編碼結果如下

16    -95    -51    5    
00010000 10100001 11001101 00000101 

其中黃色部分即是91809的varints編碼,我們來驗證一下

紅色表示最高位,藍色表示數字本身編碼,在讀取該部分字節的時候是一個一個讀取的

讀取到第一個字節時,發現最高位是1,因此會繼續讀取第二個字節,第二個字節最高位也是1,因此繼續讀取第三個字節,而第三個字節最高位為0,從而結束讀取,就處理這3個字節

10100001 11001101 00000101 

1.去掉3個字節的最高位

0100001 1001101 0000101 

2.反轉3個字節的順序

0000101 1001101 0100001

3.連接3個字節,構成了91809的二進制形式

10110011010100001

 接着我們看person編碼結果的第一個字節

16    -95    -51    5    
00010000 10100001 11001101 00000101 

這個字節表示的是數據的序號類型,編碼方式也是varient,因此我將其分為3個部分

00010000

紅色0為最高位bit,表示是否解析到了本次varient的最后一個字節

中間藍色的4個bit 0010表示序號,十進制2,即id的序號

最后3個黃色底的0為該字段的類型,000表示int32類型

此時一個最簡單的protobuf的編碼就解析完成了

 

到這里我們先總結一下protobuf編碼的性質,將特別抽象的的內容轉換成一個我們可以直覺理解的東西

先看原始數據,如果用json表示出來就是如下形式

{
    "id": 91890
}

而protobuf編碼后的數據格式如下

00010000 10100001 11001101 00000101

其中第一個字節表示序號和字段類型,即序號為2,類型為int的字段

后三個字節表示數據的值,值為91890

這時候就會有這樣一個問題,那id這個字段名去哪兒了?

答案就是,id的字段名被protobuf舍棄了!

所以,protobuf最終的編碼結果是拋棄了所有的字段名,僅僅保留了字段的序號、類型和數據的值。

因此在第一篇文章的開頭,就提到protobuf並非是一種可以完全自解釋的編碼格式,意思就是如此。

也正因為如此,所以我也認為這個序號正是protobuf編碼的靈魂所在

 

有了這個概念之后,我們就可以解釋之前5個示例了

示例1:protobuf的解碼不需要類型相同,也不需要字段名相同

因為protobuf編碼后的結果根本就不包含類的信息,也不包含字段名的信息,因此解碼的時候自然也就不依賴於類和字段名

 

示例2:protobuf的解碼依賴於序號的正確性

因為編碼后的結果的序號和類型是在同一個字節中,是一一對應的關系,如果編碼的對應關系和解碼的對應關系不同,則自然編碼和解碼的過程會出問題

 

示例3:protobuf中的序號大小會影響最終編碼大小

我們前面看到序號和字段類型的字節結構如下,表示序號的部分是中間的4個bit,0010

00010000

而4個bit所能表示的最大數是1111,也就是15,因此當序號大於15的時候,一個字節就不夠表達了,就需要額外一個字節,例如序號為17,類型為int的字段,它的序號字節就會如下

10001000 00000001

其中黃色底的000表示類型Int,去除后,剩下的bit通過標准的varient解碼后,得到的結果就是17

因此,如果序號超過15,那么就會多需要一個字節來表示序號。回過頭看示例3,model3編碼結果正好比model1編碼結果多3個字節,正是3個字段的序號導致的

 

示例4:protobuf的對象類型可以向String類型兼容

上面提到了int的類型在字節中的bit表示是000,那么接下去我么可以看下其他類型對應的bit表示

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

這里可以看到,0就是表示int32,表達方式是varient

而2則可以表示string、embedded messages等,而這里的embedded messages對應的就是子對象

既然類型的表示是相同的,那么在解碼的時候自然就是可以從embeedded messages向string兼容

然而由於messages的結構是要比string復雜的,因此反向是無法兼容的

其實這個更廣域和普世來說,總是復雜信息可以向簡單信息轉換,而反向一般是不可行的

 

示例5:protobuf可以和json完全兼容,且編碼大小要比json小

兼容性是由java類庫實現的,這個不在編碼原理的范疇內,這里主要看下編碼大小比json小的原因

例如示例中的json

{"email":"personJson@google.com","id":1,"name":"personJson"}

json的編碼后,為了保證格式的正確和自解釋的功能,其中還包含了很多格式字符,包括{  "  ,  }等,還包括了email、id、name字段名本身

而protobuf編碼后,則僅僅保留了序號、類型,以及字段的值,沒有任何其他額外的符號,因此就比json節省了很多字節數 

 

那么protobuf的編碼原理基礎就先了解到這里,下一篇文章將繼續解釋其他protobuf類型的編碼原理

 

最后總結下本文內容,通過5個示例展示了protobuf在使用上的一些特性,並通過基本的編碼原理解釋了特性的本質原因

特性有以下5點

1.protobuf的解碼不需要類型相同,也不需要字段名相同

2.protobuf的解碼依賴於序號的正確性

3.protobuf中的序號大小會影響最終編碼大小

4.protobuf的對象類型可以向String類型兼容

5.protobuf可以和json完全兼容,且編碼大小要比json小

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM