Android Protobuf應用及原理

餓了麼物流技術團隊發表於2018-03-26
2018-03-24 | 鍾曉鋒 | Android

前言

之前一直忙於移動端日誌SDK Trojan的開源工作,已十分穩定地執行在餓了麼團隊App中,整合了日誌加密和解密功能。哎呀,允許我賣個狗皮膏藥,不用不知道,用了就知道,從此愛不釋手,Trojan其實是一個很好用的膏藥,甚至是一劑不可或缺的良藥,能幫助我們跟蹤線上使用者,解決疑難雜症。

閒話少說,進入今天的正題,Protobuf,可能大家對此很陌生,還未接觸過,不過不要緊,看完這篇部落格,相信你一定有所感觸。起初為了節約流量,在我們千里眼後端介面率先使用Protobuf替代Json,支援Java、C++、Python等語言,就嚐到甜頭了,簡單好用還節省記憶體流量,基於這個特性,英雄豈無使用者之地。後面,我們推廣到Sqlite、SharedPerference等領域,利用Protobuf進行改造,替換原有的Json或者XML儲存方式!

Protobuf

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

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.

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

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種對不同型別的選擇,其他的型別區別很明顯,主要在與int32、uint32、sint32、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

相關文章