1. 程式人生 > >Protocol Buffers 3.0 技術手冊

Protocol Buffers 3.0 技術手冊

簡介

Protocol Buffers 是 google 的一種資料交換的格式,它獨立於語言,獨立於平臺。google 提供了多種語言的實現:java、c#、c++、go 和 python,每一種實現都包含了相應語言的編譯器以及庫檔案。由於它是一種二進位制的格式,比使用 xml 進行資料交換快許多。可以把它用於分散式應用之間的資料通訊或者異構環境下的資料交換。作為一種效率和相容性都很優秀的二進位制資料傳輸格式,可以用於諸如網路傳輸、配置檔案、資料儲存等諸多領域。

至於protobuf是什麼、使用場景、有什麼好處,本文不做說明,這裡將會為大家介紹怎麼用 protobuf 來定義我們的互動協議,包括 .proto

的語法以及如何根據proto檔案生成相應的程式碼。本文基於proto3,讀者也可以點選瞭解proto2

proto3語法

定義一個 Message

首先我們來定義一個 Search 請求,在這個請求裡面,我們需要給服務端傳送三個資訊:

  • query:查詢條件
  • page_number:你想要哪一頁資料
  • result_per_page:每一頁有多少條資料

於是我們可以這樣定義:

// 指定使用proto3,如果不指定的話,編譯器會使用proto2去編譯
syntax = "proto3"; //[proto2|proto3]

message SearchRequests {
    // 定義SearchRequests的成員變數,需要指定:變數型別、變數名、變數Tag
string query = 1; int32 page_number = 2; int32 result_per_page = 3; }

定義多個 message 型別

一個 proto 檔案可以定義多個 message ,比如我們可以在剛才那個 proto 檔案中把服務端返回的訊息結構也一起定義:

message SearchRequest {
    string query = 1;
    int32 page_number = 2;
    int32 result_per_page = 3;
}

message SearchResponse {
    repeated string
result = 1; }

message 可以巢狀定義,比如 message 可以定義在另一個 message 內部

message SearchResponse {
    message Result {
        string url = 1;
        string title = 2;
        repeated string snippets = 3;
    }
    repeated Result results = 1;
}

定義在 message 內部的 message 可以這樣使用:

message SomeOtherMessage {
    SearchResponse.Result result = 1;
}

定義變數型別

在剛才的例子之中,我們使用了2個標準值型別: string 和 int32,除了這些標準型別之外,變數的型別還可以是複雜型別,比如自定義的列舉和自定義的 message

這裡我們把標準型別列舉一下protobuf內建的標準型別以及跟各平臺對應的關係:

.proto 說明 C++ Java Python Go Ruby C# PHP
double double double float float64 Float double float
float float float float float32 Float float float
int32 使用變長編碼,對負數編碼效率低,如果你的變數可能是負數,可以使用sint32 int32 int int int32 Fixnum or Bignum (as required) int integer
int64 使用變長編碼,對負數編碼效率低,如果你的變數可能是負數,可以使用sint64 int64 long int/long int64 Bignum long integer/string
uint32 使用變長編碼 uint32 int int/long uint32 Fixnum or Bignum (as required) uint integer
uint64 使用變長編碼 uint64 long int/long uint64 Bignum ulong integer/string
sint32 使用變長編碼,帶符號的int型別,對負數編碼比int32高效 int32 int int int32 Fixnum or Bignum (as required) int integer
sint64 使用變長編碼,帶符號的int型別,對負數編碼比int64高效 int64 long int/long int64 Bignum long integer/string
fixed32 4位元組編碼, 如果變數經常大於228 的話,會比uint32高效 uint32 int int int32 Fixnum or Bignum (as required) uint integer
fixed64 8位元組編碼, 如果變數經常大於256 的話,會比uint64高效 uint64 long int/long uint64 Bignum ulong integer/string
sfixed32 4位元組編碼 int32 int int int32 Fixnum or Bignum (as required) int integer
sfixed64 8位元組編碼 int64 long int/long int64 Bignum long integer/string
bool bool boolean bool bool TrueClass/FalseClass bool boolean
string 必須包含utf-8編碼或者7-bit ASCII text string String str/unicode string String (UTF-8) string string
bytes 任意的位元組序列 string ByteString str []byte String (ASCII-8BIT) ByteString string

補充說明:

  • In Java, unsigned 32-bit and 64-bit integers are represented using their signed counterparts, with the top bit simply being stored in the sign bit.
  • In all cases, setting values to a field will perform type checking to make sure it is valid.
  • 64-bit or unsigned 32-bit integers are always represented as long when decoded, but can be an int if an int is given when setting the field. In all cases, the value must fit in the type represented when set. See 2.
  • Python strings are represented as unicode on decode but can be str if an ASCII string is given (this is subject to change).
  • Integer is used on 64-bit machines and string is used on 32-bit machines.

如果你想了解這些資料是怎麼序列化和反序列化的,可以點選 Protocol Buffer Encoding 瞭解更多關於protobuf編碼內容。

分配Tag

每一個變數在message內都需要自定義一個唯一的數字Tag,protobuf會根據Tag從資料中查詢變數對應的位置,具體原理跟protobuf的二進位制資料格式有關。Tag一旦指定,以後更新協議的時候也不能修改,否則無法對舊版本相容。

Tag的取值範圍最小是1,最大是229-1,但 19000~19999 是 protobuf 預留的,使用者不能使用。

雖然 Tag 的定義範圍比較大,但不同 Tag 也會對 protobuf 編碼帶來一些影響:

  • 1 ~ 15:單位元組編碼
  • 16 ~ 2047:雙位元組編碼

使用頻率高的變數最好設定為1 ~ 15,這樣可以減少編碼後的資料大小,但由於Tag一旦指定不能修改,所以為了以後擴充套件,也記得為未來保留一些 1 ~ 15 的 Tag

指定變數規則

在 proto3 中,可以給變數指定以下兩個規則:

  • singular:0或者1個,但不能多於1個
  • repeated:任意數量(包括0)

當構建 message 的時候,build 資料的時候,會檢測設定的資料跟規則是否匹配

在proto2中,規則為:

  • required:必須有一個
  • optional:0或者1個
  • repeated:任意數量(包括0)

註釋

//表示註釋開頭,如

message SearchRequest {
    string query = 1;
    int32 page_number = 2; // Which page number do we want
    int32 result_per_page = 3; // Number of results to return per page
}

保留變數不被使用

上面我們說到,一旦 Tag 指定後就不能變更,這就會帶來一個問題,假如在版本1的協議中,我們有個變數:

int32 number = 1;

在版本2中,我們決定廢棄對它的使用,那我們應該如何修改協議呢?註釋掉它?刪除掉它?如果把它刪除了,後來者很可能在定義新變數的時候,使新的變數 Tag = 1 ,這樣會導致協議不相容。那有沒有辦法規避這個問題呢?我們可以用 reserved 關鍵字,當一個變數不再使用的時候,我們可以把它的變數名或 Tag 用 reserved 標註,這樣,當這個 Tag 或者變數名字被重新使用的時候,編譯器會報錯

message Foo {
    // 注意,同一個 reserved 語句不能同時包含變數名和 Tag 
    reserved 2, 15, 9 to 11;
    reserved "foo", "bar";
}

預設值

當解析 message 時,如果被編碼的 message 裡沒有包含某些變數,那麼根據型別不同,他們會有不同的預設值:

  • string:預設是空的字串
  • byte:預設是空的bytes
  • bool:預設為false
  • numeric:預設為0
  • enums:定義在第一位的列舉值,也就是0
  • messages:根據生成的不同語言有不同的表現,參考generated code guide

注意,收到資料後反序列化後,對於標準值型別的資料,比如bool,如果它的值是 false,那麼我們無法判斷這個值是對方設定的,還是對方壓根就沒給這個變數設定值。

定義列舉 Enumerations

在 protobuf 中,我們也可以定義列舉,並且使用該列舉型別,比如:

message SearchRequest {
    string query = 1;
    int32 page_number = 2; // Which page number do we want
    int32 result_per_page = 3; // Number of results to return per page
    enum Corpus {
        UNIVERSAL = 0;
        WEB = 1;
        IMAGES = 2;
        LOCAL = 3;
        NEWS = 4;
        PRODUCTS = 5;
        VIDEO = 6;
    }
    Corpus corpus = 4;
}

列舉定義在一個訊息內部或訊息外部都是可以的,如果列舉是 定義在 message 內部,而其他 message 又想使用,那麼可以通過 MessageType.EnumType 的方式引用。定義列舉的時候,我們要保證第一個列舉值必須是0,列舉值不能重複,除非使用 option allow_alias = true 選項來開啟別名。如:

enum EnumAllowingAlias {
    option allow_alias = true;
    UNKNOWN = 0;
    STARTED = 1;
    RUNNING = 1;
}

列舉值的範圍是32-bit integer,但因為列舉值使用變長編碼,所以不推薦使用負數作為列舉值,因為這會帶來效率問題。

如何引用其他 proto 檔案

在proto語法中,有兩種引用其他 proto 檔案的方法: importimport public,這兩者有什麼區別呢?下面舉個例子說明:
這裡寫圖片描述

  • 在情景1中, my.proto 不能使用 second.proto 中定義的內容
  • 在情景2中, my.proto 可以使用 second.proto 中定義的內容
  • 情景1和情景2中,my.proto 都可以使用 first.proto
  • 情景1和情景2中,first.proto 都可以使用 second.proto
// my.proto
import "first.proto";
// first.proto
//import "second.proto";
import public "second.proto";
// second.proto
...

升級 proto 檔案正確的姿勢

升級更改 proto 需要遵循以下原則

  • 不要修改任何已存在的變數的 Tag
  • 如果你新增了變數,新生成的程式碼依然能解析舊的資料,但新增的變數將會變成預設值。相應的,新程式碼序列化的資料也能被舊的程式碼解析,但舊程式碼會自動忽略新增的變數。
  • 廢棄不用的變數用 reserved 標註
  • int32、 uint32、 int64、 uint64 和 bool 是相互相容的,這意味你可以更改這些變數的型別而不會影響相容性
  • sint32 和 sint64 是相容的,但跟其他型別不相容
  • string 和 bytes 可以相容,前提是他們都是UTF-8編碼的資料
  • fixed32 和 sfixed32 是相容的, fixed64 和 sfixed64是相容的

Any 的使用

Any可以讓你在 proto 檔案中使用未定義的型別,具體裡面儲存什麼資料,是在上層業務程式碼使用的時候決定的,使用 Any 必須匯入 import google/protobuf/any.proto

import "google/protobuf/any.proto";

message ErrorStatus {
    string message = 1;
    repeated google.protobuf.Any details = 2;
}

Oneof 的使用

Oneof 類似union,如果你的訊息中有很多可選欄位,而同一個時刻最多僅有其中的一個欄位被設定的話,你可以使用oneof來強化這個特性並且節約儲存空間,如

message LoginReply {
    oneof test_oneof {
        string name = 3;
        string age = 4;
    }
    required string status = 1;
    required string token = 2;
}

這樣,name 和 age 都是 LoginReply 的成員,但不能給他們同時設定值(設定一個oneof欄位會自動清理其他的oneof欄位)。

Maps 的使用

protobuf 支援定義 map 型別的成員,如:

map<key_type, value_type> map_field = N;
// 舉例:map<string, Project> projects = 3;
  • key_type:必須是string或者int
  • value_type:任意型別

使用 map 要注意:

  • Map 型別不能使 repeated
  • Map 是無序的
  • 以文字格式展示時,Map 以 key 來排序
  • 如果有相同的鍵會導致解析失敗

Packages 的使用

為了防止不同訊息之間的命名衝突,你可以對特定的.proto檔案提指定 package 名字。在定義訊息的成員的時候,可以指定包的名字:

package foo.bar;
message Open { ... }
message Foo {
    ...
    // 帶上包名
    foo.bar.Open open = 1;
    ...
}

Options

Options 分為 file-level options(只能出現在最頂層,不能在訊息、列舉、服務內部使用)、 message-level options(只能在訊息內部使用)、field-level options(只能在變數定義時使用)

  • java_package (file option):指定生成類的包名,如果沒有指定此選項,將由關鍵字package指定包名。此選項只在生成 java 程式碼時有效
  • java_multiple_files (file option):如果為 true, 定義在最外層的 message 、enum、service 將作為單獨的類存在
  • java_outer_classname (file option):指定最外層class的類名,如果不指定,將會以檔名作為類名
  • optimize_for (file option):可選有 [SPEED|CODE_SIZE|LITE_RUNTIME] ,分別是效率優先、空間優先,第三個lite是兼顧效率和程式碼大小,但是執行時需要依賴 libprotobuf-lite
  • cc_enable_arenas (file option):啟動arena allocation,c++程式碼使用
  • objc_class_prefix (file option):Objective-C使用
  • deprecated (field option):提示變數已廢棄、不建議使用
option java_package = "com.example.foo";
option java_multiple_files = true;
option java_outer_classname = "Ponycopter";
option optimize_for = CODE_SIZE;
int32 old_field = 6 [deprecated=true];

定義 Services

這個其實和gRPC相關,詳細可參考:gRPC, 這裡做一個簡單的介紹
要定義一個服務,你必須在你的 .proto 檔案中指定 service

service RouteGuide {
    ...
}

然後在我們的服務中定義 rpc 方法,指定它們的請求的和響應型別。gRPC 允許你定義4種類型的 service 方法

簡單RPC

客戶端使用 Stub 傳送請求到伺服器並等待響應返回,就像平常的函式呼叫一樣,這是一個阻塞型的呼叫

// Obtains the feature at a given position.
rpc GetFeature(Point) returns (Feature) {}

伺服器端流式 RPC

客戶端傳送請求到伺服器,拿到一個流去讀取返回的訊息序列。客戶端讀取返回的流,直到裡面沒有任何訊息。從例子中可以看出,通過在響應型別前插入 stream 關鍵字,可以指定一個伺服器端的流方法

// Obtains the Features available within the given Rectangle.  Results are
// streamed rather than returned at once (e.g. in a response message with a
// repeated field), as the rectangle may cover a large area and contain a
// huge number of features.
rpc ListFeatures(Rectangle) returns (stream Feature) {}

客戶端流式 RPC

客戶端寫入一個訊息序列並將其傳送到伺服器,同樣也是使用流。一旦客戶端完成寫入訊息,它等待伺服器完成讀取返回它的響應。通過在請求型別前指定 stream 關鍵字來指定一個客戶端的流方法

// Accepts a stream of Points on a route being traversed, returning a
// RouteSummary when traversal is completed.
rpc RecordRoute(stream Point) returns (RouteSummary) {}

雙向流式 RPC

雙方使用讀寫流去傳送一個訊息序列。兩個流獨立操作,因此客戶端和伺服器可以以任意喜歡的順序讀寫:比如, 伺服器可以在寫入響應前等待接收所有的客戶端訊息,或者可以交替的讀取和寫入訊息,或者其他讀寫的組合。每個流中的訊息順序被預留。你可以通過在請求和響應前加 stream 關鍵字去制定方法的型別

// Accepts a stream of RouteNotes sent while a route is being traversed,
// while receiving other RouteNotes (e.g. from other users).
rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}

程式碼生成

使用 protoc 工具可以把編寫好的 proto 檔案“編譯”為Java, Python, C++, Go, Ruby, JavaNano, Objective-C,或C#程式碼, protoc 可以從點選這裡進行下載。protoc 的使用方式如下:

protoc --proto_path=IMPORT_PATH --cpp_out=DST_DIR --java_out=DST_DIR --python_out=DST_DIR --go_out=DST_DIR --ruby_out=DST_DIR --javanano_out=DST_DIR --objc_out=DST_DIR --csharp_out=DST_DIR path/to/file.proto

引數說明:

  • IMPORT_PATH:指定 proto 檔案的路徑,如果沒有指定, protoc 會從當前目錄搜尋對應的 proto 檔案,如果有多個路徑,那麼可以指定多次--proto_path
  • 指定各語言程式碼的輸出路徑
    • –cpp_out:生成c++程式碼
    • java_out :生成java程式碼
    • python_out :生成python程式碼
    • go_out :生成go程式碼
    • ruby_out :生成ruby程式碼
    • javanano_out :適合執行在有資源限制的平臺(如Android)的java程式碼
    • objc_out :生成 Objective-C程式碼
    • csharp_out :生成C#程式碼
    • php_out :生成PHP程式碼