Protocol Buffers 入門(Android)

1. 關(guān)于 Protobuf

1.1 簡介

Protocol Buffer,簡稱 Protobuf,是 Google 開發(fā)的一種數(shù)據(jù)描述語言。它是一種輕便高效的結(jié)構(gòu)化數(shù)據(jù)存儲格式,適合做數(shù)據(jù)存儲或 RPC 數(shù)據(jù)交換格式,可用于數(shù)據(jù)傳輸量較大的即時通訊協(xié)議、數(shù)據(jù)存儲等場景。Protobuf 與語言無關(guān)、平臺無關(guān),目前提供了多種語言的 API。
官方文檔:https://developers.google.com/protocol-buffers
開源地址:https://github.com/protocolbuffers/protobuf

1.2 優(yōu)勢
  • 體積小速度快。像 XML 這種報文是基于文本格式的,存在大量的描述信息,雖然對于人來說可讀性更好,但增加了序列化時間、網(wǎng)絡(luò)傳輸時間等。導(dǎo)致系統(tǒng)的整體性能下降。而 PB 則將信息序列化為二進(jìn)制的格式,安全性提高的同時,序列化后的數(shù)據(jù)大小縮小了3倍,序列化速度比 Json 快了20-100倍,也必然會減小網(wǎng)絡(luò)傳輸時間。
  • 跨平臺跨語言。接收端和發(fā)送端只需要維護(hù)同一份 proto 文件即可。proto 編譯器會根據(jù)不同的語言,生成對應(yīng)的代碼文件。
  • 向后兼容性。不必破壞舊的數(shù)據(jù)格式,可以直接對數(shù)據(jù)結(jié)構(gòu)進(jìn)行更新。

2. 原理分析

通過本質(zhì)探究,了解 Protobuf 為何如此高效。

2.1 編碼背景
  • 信源編碼:信源編碼是一種以提高通信有效性為目的而對信源符號進(jìn)行的變換,或者說為了減少或消除信源利余度而進(jìn)行的信源符號變換。具體說,就是針對信源輸出符號序列的統(tǒng)計(jì)特性來尋找某種方法,把信源輸出符號序列變換為最短的碼字序列,使后者的各碼元所載荷的平均信息量最大,同時又能保證無失真地恢復(fù)原來的符號序列。信源編碼的作用之一是,即通常所說的數(shù)據(jù)壓縮;作用之二是將信源的模擬信號轉(zhuǎn)化成數(shù)字信號,以實(shí)現(xiàn)模擬信號的數(shù)字化傳輸。現(xiàn)代通信應(yīng)用中常見的信源編碼方式有:Huffman編碼、算術(shù)編碼、L-Z編碼,這三種都是無損編碼;另外還有一些采用壓縮方式的有損編碼。同時無損編碼也根據(jù)"是否把一個傳輸單位編碼為固定長度"區(qū)分為定長編碼和變長編碼。定長編碼就是一個符號變換后的碼字的比特長度是固定的,比如 ASCII、Unicode 都是定長編碼,碼字是8比特,16比特。變長編碼則是將信源符號映射為不同的碼字長度,比如 Huffman 編碼,PB 編碼。
  • 信道編碼:信道編碼是為了對抗信道中的噪音和衰減,通過增加冗余來提高抗干擾能力以及糾錯能力。信道編碼的本質(zhì)是降低誤碼率、增加通信的可靠性。數(shù)字信號在傳輸中往往由于各種原因,使得在傳送的數(shù)據(jù)流中產(chǎn)生誤碼,所以通過信道編碼這一環(huán)節(jié)來避免碼流傳輸中誤碼的發(fā)生。常用的處理技術(shù)有奇偶校驗(yàn)碼、糾錯碼、信道交織編碼等。
  • 簡單來說,信源編碼就是將信源產(chǎn)生的消息變換為數(shù)字序列的過程,主要目的是降低數(shù)據(jù)率,提高信息量效率,一般用來對視頻、音頻、數(shù)據(jù)進(jìn)行處理。而信道編碼的主要目的是提高系統(tǒng)的抗干擾能力,比如糾錯碼啊,卷積碼這類,可以檢測出信息是否有被傳錯。
  • 從通信角度來看,Protobuf 是一種變長的無損的信源編碼。
2.2 整數(shù)的編碼優(yōu)化

varint 編碼:一般情況下,一個 int 值看作4字節(jié),也就是所謂的定長編碼。PB 考慮到現(xiàn)實(shí)情況中,數(shù)值較大的數(shù)比數(shù)值較小的數(shù)更少地被使用這一事實(shí),采用了變長編碼。如果一個數(shù)能夠用1個字節(jié)來表示,那就用一個字節(jié)來表示。如數(shù)值1就會被編碼為0000 0001,而不是把它編碼為0000 0000 0000 0000 0000 0000 0000 0001。但是也由此產(chǎn)生一個問題,每個整數(shù)的編碼長度可能不一樣,如何區(qū)分邊界呢?PB 將每個字節(jié)拿出1比特最高位的那個比特 MSB(Most Significant Bit)來作為邊界的標(biāo)記(編碼是否為最后一個字節(jié)),1表示還沒有到最后一個字節(jié),0表示到了最后一個字節(jié)。

規(guī)則如下 ↓

  • 0xxx xxxx表示某個整數(shù)編碼后的結(jié)果是單個字節(jié),因?yàn)镸SB=0;
  • 1xxx xxxx 0xxx xxxx表示某個整數(shù)編碼后的結(jié)果是2個字節(jié),因?yàn)榍耙粋€字節(jié)的MSB=1(編碼結(jié)果未結(jié)束),后一個字節(jié)的MSB=0;
  • 同理,三個字節(jié)、四個字節(jié)都用這種方法來表示邊界。

代碼如下 ↓

final void bufferUInt32NoTag(int value) {
    if (HAS_UNSAFE_ARRAY_OPERATIONS) {
        final long originalPos = position;
        while (true) {
            if ((value & ~0x7F) == 0) {
                //最后一次取出最高位補(bǔ)0
                UnsafeUtil.putByte(buffer, position++, (byte) value);
                break;
            } else {
                UnsafeUtil.putByte(buffer, position++, (byte) ((value & 0x7F) | 0x80));
                //取出后面7位,最高位補(bǔ)1
                value >>>= 7;
            }
        }
        int delta = (int) (position - originalPos);
        totalBytesWritten += delta;
    } else {
        while (true) {
            if ((value & ~0x7F) == 0) {
                buffer[position++] = (byte) value;
                totalBytesWritten++;
                return;
            } else {
                buffer[position++] = (byte) ((value & 0x7F) | 0x80);
                totalBytesWritten++;
                value >>>= 7;
            }
        }
    }
}

示例如下 ↓

  • 0000 0001表示整數(shù)1;
  • 1010 1100 0000 0010表示兩個字節(jié)的結(jié)果。將兩字節(jié)的MSB去掉為:0101100 0000010。由于 PB 對于多個字節(jié)的情況采用低字節(jié)優(yōu)先,即后面的字節(jié)要放在高位,于是拼在一起的結(jié)果為:00000100101100,表示300這個整數(shù)值。(其實(shí)就是將數(shù)字的二進(jìn)制補(bǔ)碼的每7位分為一組, 低7位先輸出,編碼在前面,在輸出下一組,依次類推)

可以看到以上的變長編碼方式,在數(shù)據(jù)壓縮上能節(jié)省很多空間,不過它也存在以下幾個小缺點(diǎn) ↓

  • 造成了比特的1/8的浪費(fèi),一個很大的數(shù)將可能使用5個字節(jié)來表示。
  • 負(fù)數(shù)需要10個字節(jié)顯示(因?yàn)樨?fù)數(shù)最高位是1,會被當(dāng)作很大的整數(shù)處理)
2.3 對象的編碼優(yōu)化
2.3.1 Protobuf 對 key-value 中 key 的優(yōu)化:使用序號 key 代替變量名

對于一個對象,里面包含多個變量,如何編碼呢?假設(shè)一個類的定義如下 ↓

Class Student {
    String name;
    String sex;
    int age;
}

如果使用 XML,那么傳輸?shù)母袷饺缦?↓

<?xml version="1.0" encoding="UTF-8" ?>
        <name>Bob</name>
        <sex>male</sex>
        <age>18</age>

如果使用 Json,那么傳輸?shù)母袷饺缦?↓

{
    "name": "Bob",
    "sex": "male",
    "age": "18"
}

而 Protobuf 認(rèn)為 "name"、"sex"、"age" 這些變量名不應(yīng)該包含在傳輸消息中,因?yàn)榫幗獯a、傳輸這些信息也需要資源。Protobuf 為了節(jié)省空間,在通信雙方都保持一份文檔,記錄了變量名的編號,比如上述三個變量名字分別編號為1、2、3。于是在序列化的時候,只需要傳輸下面的信息 ↓

1:"Bob", 2:"male", 3:"18"

由于對方也保留了一份編號文檔,于是就可以反序列化了。這些編號本身也可以用上面對整數(shù)的編碼優(yōu)化方式進(jìn)行編碼。??

2.3.2 Protobuf 對 key-value 中 value 的優(yōu)化

如果 value 為整數(shù),那么直接使用前面提到的對整數(shù)的編碼優(yōu)化即可。即大多數(shù)整數(shù)只占一兩個字節(jié)。
如果 value 為字符串,這時候每個字節(jié)都拿出1個 bit 來區(qū)分邊界就太浪費(fèi)空間了,而且字符串本身就是一個一個字節(jié)的,被打亂后也會影響解碼效率。因此,Protobuf 將 value 長度信息的指示可以放在 key 和 value 之間(長度本身也是一個整數(shù),也能編碼優(yōu)化)。在解碼 value 時,解析長度就可以知道 value 值到哪里結(jié)束。不過也因此產(chǎn)生一個問題,比如整數(shù)這種情況,value 中已經(jīng)自帶了結(jié)束標(biāo)識符,那就不需要 value 的長度指示信息了。因此 Protobuf 引入了 Type 類型,即提前告訴接收端 value 的類型。Protobuf 將這個 Type 信息放在了 key 中的最后 3 個 bit 中。根據(jù)這個 Type,即可讓接受端注意或者忽略 value 的長度字段。value 的類型在 Protobuf 中稱為 wire_type。主要有以下幾種 ↓

wire type = 0  
// 0 表示這個Value是一個變長整數(shù),比如int32, int64, uint32, uint64, sint32, sint64, bool, enum
wire type = 1
// 1 表示這個Value是一個64位的定長數(shù),比如fixed64, sfixed64, double
wire type = 2
// 2 表示string, bytes等,這些Value的長度需要置于Key后面
wire type = 3
// 3 表示groups中的Start Group,就是有一組,3表示接下來的Value是第一組
wire type = 4
// 4 表示groups中的End Group
wire type = 5
// 5 表示32位固定長度的fixed32, sfixed32, float等
2.4 示例
  • 例子1:08 ac 02 這三個字節(jié),分析如下 ↓
  1. 08 二進(jìn)制為 0000 1000,最高位 0 表示這是最后一個字節(jié),去除最高位為 0001 000。
  2. 最后 3 個 bit 為 Type 類型,000 表示 wire type = 0,前面的 0001 表示這是編號為1的變量。
  3. 后面的 ac 02,寫成二進(jìn)制為 10101100 00000010,去掉最高位分隔符為 0101100 0000010,因?yàn)榈妥止?jié)優(yōu)先,于是串起來為 0000010 0101100 = 300。
  4. 最終,08 ac 02 這三個字節(jié)解碼為編號為 1 的變量值為整數(shù) 300。
  • 例子2:12 07 74 65 73 74 69 6e 67 這九個字節(jié),分析如下 ↓
  1. 12 的二進(jìn)制為 0001 0010,最高位 0 表示這是最后一個字節(jié),去除最高位為 0010 010。
  2. 最后 3 個 bit 010 表示 wire type = 2,前四位 0010 表示這是編號為 2 的變量。
  3. 因?yàn)閣ire type = 2,表示 value 是 String、bytes 等變長流。接下來要解碼 value 的長度。
  4. 07 的二進(jìn)制為 0000 0111,最高位為 0,表示這是最后一個字節(jié),去除最高位后是 000 0111,表示Value的長度為 7,也就是后面的 7 個字節(jié):74 65 73 74 69 6e 67。
  5. 這 7 個字節(jié)如果是 String,那么根據(jù) ASCII 碼可解碼為:"testing"。
  6. 最終,12 07 74 65 73 74 69 6e 67 這幾個字節(jié)解碼為編號為 2 的變量值為字符串"testing"。
2.5 總結(jié)
2.5.1 體積壓縮優(yōu)勢

由 2.4 的第二個例子,08 ac 02 12 07 74 65 73 74 69 6e 67 這九個字節(jié)等價于 Json 中的 ↓

{"testInt":"300", "testString":"testing"}

可看出,Json 使用了40個左右的字節(jié),而 Protocol 只使用了12個字節(jié),這也就解釋了為什么 Protobuf 將信息序列化為二進(jìn)制后,體積縮小了3倍,也因此減少了數(shù)據(jù)網(wǎng)絡(luò)傳輸?shù)臅r間。

2.5.2 序列化速度優(yōu)勢

以 XML 的解包過程為例,XML 首先需要將得到的字符串轉(zhuǎn)換為 XML 文檔對象的結(jié)構(gòu)模型,再從結(jié)構(gòu)模型中讀取指定節(jié)點(diǎn)的字符串,最后再將這個字符串指定為某個對象的變量值。這個過程非常復(fù)雜,其中轉(zhuǎn)換文檔對象結(jié)構(gòu)模型的過程,通常需要完成詞法文法分析等大量消耗 CPU 的復(fù)雜計(jì)算。而 Protobuf 只需要簡單地將一個二進(jìn)制序列,按照指定的格式讀取賦值到某個對象的變量值即可。因此它的的序列化速度非常快。

3. 在 Android 中的簡單使用

分析完 Protobuf 的原理之后,便是開始學(xué)習(xí)如何使用。前面說到它可用于多種語言,且與平臺無關(guān)。不過我只稍微學(xué)習(xí)了如何在 Android studio 中使用 Protobuf。

3.1 Gradle 配置

在根目錄的 build.gradle 中添加如下代碼 ↓

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.2.1'
        classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.6' // 添加這行
    }
}

在 module 的 build.gradle 中首先添加如下代碼 ↓

apply plugin: 'com.google.protobuf' // 添加插件

接著添加 protobuf 塊(與android同級)↓

protobuf {
    // 配置protoc編譯器
    protoc {
        artifact = 'com.google.protobuf:protoc:3.0.0-alpha-3'
    }
    plugins {
        javalite {
            artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
        }
    }
    // 這里配置生成目錄,編譯后會在build的目錄下生成對應(yīng)的java文件
    generateProtoTasks {
        all().each { task ->
            task.plugins {
                javalite {}
            }
        }
    }
}

再添加依賴 ↓

implementation 'com.google.protobuf:protobuf-lite:3.0.0'

此時可以編譯項(xiàng)目,會生成 proto java class,這個類就是我們后面所要使用到的。

3.2 定義 proto 文件

一般是在 java/res 同級目錄下建立 proto 文件夾,然后再創(chuàng)建 .proto 文件。



我們在 .proto 文件里定義數(shù)據(jù)結(jié)構(gòu)。這里有份官網(wǎng)的示例代碼 ↓

package tutorial;

option java_package = "com.example.tutorial";
option java_outer_classname = "AddressBookProtos";

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

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    required string number = 1;
    optional PhoneType type = 2 [default = HOME];
  }

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

關(guān)鍵字說明 ↓ (更多內(nèi)容可以參考官網(wǎng) Protocol Buffer Language Guide

syntax 聲明版本。例如上面代碼的syntax="proto3",如果沒有聲明,則默認(rèn)是proto2
package 聲明包名
import 導(dǎo)入包
java_package 指定生成的類應(yīng)該放在什么Java包名下。如果你沒有顯式地指定這個值,則它簡單地匹配由package 聲明給出的Java包名,但這些名字通常都不是合適的Java包名 (由于它們通常不以一個域名打頭)
java_outer_classname 定義應(yīng)該包含這個文件中所有類的類名。如果你沒有顯式地給定java_outer_classname ,則將通過把文件名轉(zhuǎn)換為首字母大寫來生成。例如上面例子編譯生成的文件名和類名是AddressBookProtos
message 類似于java中的class關(guān)鍵字
repeated 用于修飾屬性,表示對應(yīng)的屬性是個array
optional 可選字段,可以不傳入數(shù)據(jù),或者設(shè)置默認(rèn)值
required 必填字段,如果創(chuàng)建數(shù)據(jù)對象時不傳入?yún)?shù),編碼時就會拋出exception。使用required時要注意,如果你升級協(xié)議時把這個字段改為optional,接收方?jīng)]有升級,你發(fā)送的數(shù)據(jù)對方將無法解釋。因此不建議使用它,一般只使用optional和repeated

這里我跟著教程,定義了一個簡單的數(shù)據(jù)結(jié)構(gòu) ↓

syntax = "proto3";
package tutorial;

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

編譯后,在以下的目錄中會生成對應(yīng)的 java 文件 ↓



Dataformat.java 即是 dataformat.proto 生成的對應(yīng) java 文件,里面代碼行數(shù)有點(diǎn)多。

3.3 獲取數(shù)據(jù)

通過網(wǎng)絡(luò)獲取數(shù)據(jù)流,然后解析成 proto 文件定義的格式 ↓

Observable.just("http://elyeproject.x10host.com/experiment/protobuf")
        .map(new Function<String, Dataformat.Person>() {
            @Override
            public Dataformat.Person apply(String url) throws Exception {
                OkHttpClient okHttpClient = new OkHttpClient();
                Request request = new Request.Builder().url(url).build();
                Call call = okHttpClient.newCall(request);
                Response response = call.execute();
                if (response.isSuccessful()) {
                    ResponseBody responseBody = response.body();
                    if (responseBody != null) {
                        return Dataformat.Person.parseFrom(responseBody.byteStream());
                    }
                }

                return null;
            }
        }).subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(new Consumer<Dataformat.Person>() {
            @Override
            public void accept(Dataformat.Person person) throws Exception {
                Log.i(TAG, person.getName());
            }
        }, new Consumer<Throwable>() {
            @Override
            public void accept(Throwable throwable) throws Exception {
                Log.i(TAG, throwable.getMessage());
            }
        });

該網(wǎng)站返回的數(shù)據(jù)如下 ↓


Android 獲取的數(shù)據(jù)如下 ↓

PS:doc-android-client 項(xiàng)目的 bitable module 主要實(shí)現(xiàn) native UI,它所引入的 bitable_bridge 包則負(fù)責(zé)業(yè)務(wù)邏輯。bitable_bridge 是個 React Native 工程,邏輯是用 JS 寫的,數(shù)據(jù)格式則為 Protobuf 。

4. 數(shù)據(jù)交互格式比較

4.1 XML、Json、Protobuf
Json 一般的 web 項(xiàng)目中,最流行的主要還是 Json。因?yàn)闉g覽器對于 Json 數(shù)據(jù)支持非常友好,有很多內(nèi)建的函數(shù)支持。 Json 使用了鍵值對的方式,不僅壓縮了一定的數(shù)據(jù)空間,同時也具有可讀性。
XML 在 webservice 中應(yīng)用最為廣泛,但是相比于 Json,它的數(shù)據(jù)更加冗余,因?yàn)樾枰蓪Φ拈]合標(biāo)簽。
Protobuf 后起之秀,適合高性能,對響應(yīng)速度有要求的數(shù)據(jù)傳輸場景。因?yàn)槭嵌M(jìn)制數(shù)據(jù)格式,需要編碼和解碼。數(shù)據(jù)本身不具有可讀性。因此只能反序列化之后得到真正可讀的數(shù)據(jù)。

相對于其他兩種語言,Protobuf 具有的優(yōu)勢如下 ↓

  1. 序列化后體積相比 Json 和 XML 很小,適合網(wǎng)絡(luò)傳輸;
  2. 支持跨平臺多語言;
  3. 消息格式升級和兼容性還不錯;
  4. 序列化和反序列化速度很快,快于 Json 的處理速度。

PS:雖然 Protobuf 并非像 Json 那樣直接明文顯示,不過我們只需定義對象結(jié)構(gòu),然后由 Protbuf 庫去把對象自動轉(zhuǎn)換成二進(jìn)制,用的時候再自動反解碼過來。傳輸過程于我們而言是透明的。我們只負(fù)責(zé)傳輸?shù)膶ο缶涂梢粤耍杂闷饋砗芊奖恪?/p>

結(jié)論:在一個需要大量的數(shù)據(jù)傳輸?shù)膱鼍爸校绻麛?shù)據(jù)量很大,那么選擇 Protobuf 可以明顯地減少數(shù)據(jù)量,減少網(wǎng)絡(luò) IO,從而減少傳輸所消耗的時間。

4.2 Protobuf 與 Json 速度比較
測試平臺 Android studio 3.2
所引用的庫 google.protobuf(proto3)、google.gson.Gson
目的 比較 Protobuf 與 Json 的序列化/反序列化速度
方法 控制變量法

過程簡述 ↓

  1. 為 Protobuf 和 Json 創(chuàng)建一樣的數(shù)據(jù)結(jié)構(gòu)(.proto 文件 和 .class 文件),然后存進(jìn)以下的數(shù)據(jù),當(dāng)然這些數(shù)據(jù)可以多次賦值、多次測試。
String name = "王小明";
int id = 15331016;
String email = "chen@bytedance.com";
String phone = "12345678910";
  1. Protobuf 操作
Dataformat.Person.Builder builder = Dataformat.Person.newBuilder();
// 存進(jìn)數(shù)據(jù)
builder.setName(name);
builder.setId(id);
builder.setEmail(email);
builder.setPhone(phone);
// 序列化
Dataformat.Person person_write = builder.build();
byte[] result = person_write.toByteArray();
// 反序列化
Dataformat.Person person_read = Dataformat.Person.parseFrom(result);

可以看到,protocol 序列化后得到的編碼結(jié)果為 49 個字節(jié)。



  1. Json 操作
Gson gson = new Gson();
// 存進(jìn)數(shù)據(jù)
Person person1 = new Person(name, id, email, phone);
// 序列化
String person1_write = gson.toJson(person1);
// 反序列化
Person person1_read = gson.fromJson(person1_write, Person.class);

序列化后的大小為 82 個字節(jié)。


  1. Log輸出每個節(jié)點(diǎn)的時間,為了效果明顯,每個(反)序列化操作重復(fù)進(jìn)行1000000次

    結(jié)果分析 ↓ (單位:ms)

    結(jié)論 ↓
    Protobuf 的體積比 Json 小,(反)序列化速度比 Json 快,在數(shù)據(jù)傳輸上更具優(yōu)勢。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容