Android Protobuf應用及原理

2018-03-24 | 鍾曉鋒 | Android

前言

以前一直忙於移動端日誌SDK Trojan的開源工做,已十分穩定地運行在餓了麼團隊App中,集成了日誌加密和解密功能。哎呀,容許我賣個狗皮膏藥,不用不知道,用了就知道,今後愛不釋手,Trojan實際上是一個很好用的膏藥,甚至是一劑不可或缺的良藥,能幫助咱們跟蹤在線用戶,解決疑難雜症。html

閒話少說,進入今天的正題,Protobuf,可能你們對此很陌生,還未接觸過,不過沒關係,看完這篇博客,相信你必定有所感觸。起初爲了節約流量,在咱們千里眼後端接口率先使用Protobuf替代Json,支持Java、C++、Python等語言,就嚐到甜頭了,簡單好用還節省內存流量,基於這個特性,英雄豈無用戶之地。後面,咱們推廣到Sqlite、SharedPerference等領域,利用Protobuf進行改造,替換原有的Json或者XML存儲方式!java

Protobuf

說了這麼久,Protobuf究竟是什麼呢,借花獻佛,引用Protobuf官網的解釋:android

Protocol buffers are a flexible, efficient, automated mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages. You can even update your data structure without breaking deployed programs that are compiled against the "old" format.c++

本人英語水平有限,就在此簡單翻譯一下,大意是:編程

Protobuf是一種靈活高效可序列化的數據協議,相於XML,具備更快、更簡單、更輕量級等特性。支持多種語言,只需定義好數據結構,利用Protobuf框架生成源代碼,就可很輕鬆地實現數據結構的序列化和反序列化。一旦需求有變,能夠更新數據結構,而不會影響已部署程序。後端

從上面咱們能夠總結出,Protobuf具備如下優勢:數組

  1. 代碼生成機制
syntax = "proto3";
package me.ele.demo.protobuf;
option java_outer_classname = "LoginInfo";
message Login {
    string account = 1;
    string password = 2;
}
複製代碼

這是一個用戶登陸信息的數據結構,經過Protobuf提供的Gradle Plugin就能夠在me.ele.demo.protobuf目錄下編譯自動生成LoginInfo類,並有序列化和反序列化等Api。性能優化

  1. 高效性

用千里眼項目中跑出來的數據進行對比,更具說服力。服務器

序列化時間效率對比:數據結構

數據格式 1000條數據 5000條數據
Protobuf 195ms 647ms
Json 515ms 2293ms

序列化空間效率對比:

數據格式 5000條數據
Protobuf 22MB
Json 29MB

從上面的數據能夠看出來,Protobuf序列化時,和Json對比,無論在時間和空間上都是更加高效。因爲篇幅的緣由就不展現反序列化的數據對比了。

  1. 支持向後兼容和向前兼容

當客戶端和服務器同事使用一塊協議的時候, 當客戶端在協議中增長一個字節,並不會影響客戶端的使用

  1. 支持多種編程語言

在Google官方發佈的源代碼中包含了c++、java、Python三種語言

至於缺點,Protobuf採用了二進制格式進行編碼,這直接致使了可讀性差;缺少自描述,Protobuf是二進制格式的協議內容,要是不配合proto結構體根本看不出來什麼來。

接入

在項目的根gradle配置以下

dependencies {
        classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.0'
}
複製代碼

在gradle中配置以下:

apply plugin: 'com.google.protobuf'

android {
    sourceSets {
        main {
            // 定義proto文件目錄
            proto {
                srcDir 'src/main/proto'
                include '**/*.proto'
            }
        }
    }
}

dependencies {
    // 定義protobuf依賴,使用精簡版
    compile "com.google.protobuf:protobuf-lite:3.0.0"
    compile ('com.squareup.retrofit2:converter-protobuf:2.2.0') {
        exclude group: 'com.google.protobuf', module: 'protobuf-java'
    }
}

protobuf {
    protoc {
        artifact = 'com.google.protobuf:protoc:3.0.0'
    }
    plugins {
        javalite {
            artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
        }
    }
    generateProtoTasks {
        all().each { task ->
            task.plugins {
                javalite {}
            }
        }
    }
}
複製代碼

apply plugin: 'com.google.protobuf'是Protobuf的Gradle插件,幫助咱們在編譯時經過語義分析自動生成源碼,提供數據結構的初始化、序列化以及反序列等接口。

compile "com.google.protobuf:protobuf-lite:3.0.0"是Protobuf支持庫的精簡版本,在原有的基礎上,用public替換set、get方法,減小Protobuf生成代碼的方法數目。

定義數據結構

仍是以上面的例子來展開:

syntax = "proto3";
package me.ele.demo.protobuf;
option java_outer_classname = "LoginInfo";
message Login {
    string account = 1;
    string password = 2;
}
複製代碼

在這裏定義了一個LoginInfo,咱們只是簡單的定義了accountpassword兩個字段。這裏注意,在上例中, syntax = "proto3";聲明proto協議版本,proto2和proto3在定義數據結構時有些差異,option java_outer_classname = "LoginInfo";定義了Protobuf自動生成類的類名,package me.ele.demo.protobuf;定義了Protobuf自動生成類的包名。

經過Android Studio clean,Protobuf插件會幫助咱們自動生成LoginInfo類,類結構以下:

LoginInfo類結構

Protobuf幫咱們自動生成LoginOrBuilder接口,主要聲明各個字段的set和get方法;而且生成Login類,核心邏輯這個類中,經過writeTo(CodedOutputStream)接口序列化到CodedOutputStream,經過ParseFrom(InputStream)接口從InputStream中反序列化。類圖以下:

Login類圖

原理分析

上文提到,Protobuf無論在時間和空間上更高效,是怎麼作到的呢?

消息通過Protobuf序列化後會成爲一個二進制數據流,經過Key-Value組成方式寫入到二進制數據流,如圖所示:

二進制數據流

Key 定義以下:

(field_number << 3) | wire_type
複製代碼

以上面的例子來講,如字段account定義:

string account = 1;
複製代碼

在序列化時,並不會把字段account寫進二進制流中,而是把field_number=1經過上述Key的定義計算後寫進二進制流中,這就是Protobuf可讀性差的緣由,也是其高效的主要緣由。

數據類型

Protobuf數據類型

在Java種對不一樣類型的選擇,其餘的類型區別很明顯,主要在與int3二、uint3二、sint3二、fixed32中以及對應的64位版本的選擇,由於在Java中這些類型都用int(long)來表達,可是protobuf內部使用ZigZag編碼方式來處理多餘的符號問題,可是在編譯生成的代碼中並無驗證邏輯,好比uint的字段不能傳入負數之類的。而從編碼效率上,對fixed32類型,若是字段值大於2^28,它的編碼效率比int32更加有效;而在負數編碼上sint32的效率比int32要高;uint32則用於字段值永遠是正整數的狀況。

編碼原理

在實現上,Protobuf使用CodedOutputStream實現序列化、CodedInputStream實現反序列化,他們包含write/read基本類型和Message類型的方法,write方法中同時包含fieldNumbervalue參數,在寫入時先寫入由fieldNumberWireType組成的tag值(添加這個WireType類型信息是爲了在對沒法識別的字段編碼時能夠經過這個類型信息判斷使用那種方式解析這個未知字段,因此這幾種類型值便可),這個tag值是一個可變長int類型,所謂的可變長類型就是一個字節的最高位(msb,most significant bit)用1表示後一個字節屬於當前字段,而最高位0表示當前字段編碼結束。在寫入tag值後,再寫入字段值value,對不一樣的字段類型採用不一樣的編碼方式:

  1. 對int32/int64類型,若是值大於等於0,直接採用可變長編碼,不然,採用64位的可變長編碼,於是其編碼結果永遠是10個字節,全部說int32/int64類型在編碼負數效率很低。

  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編碼獲取字節數組,而後先用變長編碼寫入字節數組長度,而後寫入全部的字節數組。

  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 IOException {
    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字段編碼

對於repeated字段,通常有兩種編碼方式:

  1. 每一個項都先寫入tag,而後寫入具體數據。

  2. 先寫入tag,後count,再寫入count個項,每一個項包含length|data數據。

從編碼效率的角度來看,我的感受第二中狀況更加有效,然而不知道處於什麼緣由考慮,Protobuf採用了第一種方式來編碼,我的能想到的一個理由是第一種狀況下,每一個消息項都是相對獨立的,於是在傳輸過程當中接收端每接收到一個消息項就能夠進行解析,而不須要等待整個repeated字段的消息包。對於基本類型,Protobuf也採用了第一種編碼方式,後來發現這種編碼方式效率過低,於是能夠添加[packed = true]的描述將其轉換成第三種編碼方式(第二種方式的變種,對基本數據類型,比第二種方式更加有效)

  1. 先寫入tag,後寫入字段的總字節數,再寫入每一個項數據。

目前Protobuf只支持基本類型的packed修飾,於是若是將packed添加到非repeated字段或非基本類型的repeated字段,編譯器在編譯proto文件時會報錯。

結束

以上是Protobuf的詳細介紹,基於源碼的分析這裏並未展開,請你們多多指教!最後,很是感謝你們對本篇博客的關注!

參考文獻

https://developers.google.com/protocol-buffers/docs/overview http://www.blogjava.net/DLevin/archive/2015/04/01/424011.html

相關文章
相關標籤/搜索