Protobuf 語言指南

KevinYan發表於2019-11-09

什麼是 Protobuf

Protobuf是Protocol Buffers的簡稱,它是Google公司開發的一種資料描述語言,用於描述一種輕便高效的結構化資料儲存格式,並於2008年對外開源。Protobuf可以用於結構化資料序列化,或者說序列化。它的設計非常適用於在網路通訊中的資料載體,很適合做資料儲存或 RPC 資料交換格式,它序列化出來的資料量少再加上以 K-V 的方式來儲存資料,對訊息的版本相容性非常強,可用於通訊協議、資料儲存等領域的語言無關、平臺無關、可擴充套件的序列化結構資料格式。開發者可以透過Protobuf附帶的工具生成程式碼並實現將結構化資料序列化的功能。

Protobuf中最基本的資料單元是message,是類似Go語言中結構體的存在。在message中可以巢狀message或其它的基礎資料型別的成員。

教程中將描述如何用protocol buffer語言構造你的protocol buffer資料,包括.proto檔案的語法以及如何透過.proto檔案生成資料訪問類。教程中使用的是proto3版本的protocol buffer語言。

定義Message

首先看一個簡單的例子,比如說你定義一個搜尋請求的message,每一個搜尋請求會包含一個搜尋的字串,返回第幾頁的結果,以及結果集的大小。在.proto檔案中定義如下:

syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}
  • .proto檔案的第一行指定了使用proto3語法。如果省略protocol buffer編譯器預設使用proto2語法。他必須是檔案中非空非註釋行的第一行。
  • SearchRequest定義中指定了三個欄位(name/value鍵值對),每個欄位都會有名稱和型別。

指定欄位型別

上面的例子中,所有的欄位都是標量型別的兩個整型(page_number和result_per_page)和一個字串型(query)。不過你還可以給欄位指定複合型別,包括列舉型別和其他message型別

指定欄位編號

在message定義中每個欄位都有一個唯一的編號,這些編號被用來在二進位制訊息體中識別你定義的這些欄位,一旦你的message型別被用到後就不應該在修改這些編號了。注意在將message編碼成二進位制訊息體時欄位編號1-15將會佔用1個位元組,16-2047將佔用兩個位元組。所以在一些頻繁使用用的message中,你應該總是先使用前面1-15欄位編號。

你可以指定的最小編號是1,最大是2E29 - 1(536,870,911)。其中19000到19999是給protocol buffers實現保留的欄位標號,定義message時不能使用。同樣的你也不能重複使用任何當前message定義裡已經使用過和預留的欄位編號。

定義欄位的規則

message的欄位必須符合以下規則:

  • singular:一個遵循singular規則的欄位,在一個結構良好的message訊息體(編碼後的message)可以有0或1個該欄位(但是不可以有多個)。這是proto3語法的預設欄位規則。(這個理解起來有些晦澀,舉例來說上面例子中三個欄位都是singular型別的欄位,在編碼後的訊息體中可以有0或者1個query欄位,但不會有多個。)
  • repeated:遵循repeated規則的欄位在訊息體重可以有任意多個該欄位值,這些值的順序在訊息體重可以保持(就是陣列型別的欄位)

新增更多訊息型別

在單個.proto檔案中可以定義多個message,這在定義多個相關message時非常有用。比如說,我們定義SearchRequest對應的響應message SearchResponse ,把它加到之前的.proto檔案中。

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

message SearchResponse {
 ...
}

新增註釋

.proto檔案中的註釋和C,C++的註釋風格相同,使用// 和 / ... /

/* SearchRequest represents a search query, with pagination options to
 * indicate which results to include in the response. */

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.
}

保留欄位

當你刪掉或者註釋掉message中的一個欄位時,未來其他開發者在更新message定義時就可以重用之前的欄位編號。如果他們意外載入了老版本的.proto檔案將會導致嚴重的問題,比如資料損壞、隱私洩露等。一種避免問題發生的方式是指定保留的欄位編號和欄位名稱。如果未來有人用了這些欄位標識那麼在編譯時protocol buffer的編譯器會報錯。

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}

proto會生成什麼程式碼

當使用protocol buffer編譯器編譯.proto檔案時,編譯器會根據你在.proto檔案中定義的message型別生成指定程式語言的程式碼。生成的程式碼包括訪問和設定欄位值、格式化message型別到輸出流,從輸入流解析出message等。

  • For C++, the compiler generates a .h and .cc file from each .proto, with a class for each message type described in your file.
  • For Java, the compiler generates a .java file with a class for each message type, as well as a special Builderclasses for creating message class instances.
  • Python is a little different – the Python compiler generates a module with a static descriptor of each message type in your .proto, which is then used with a metaclass to create the necessary Python data access class at runtime.
  • For Go, the compiler generates a .pb.go file with a type for each message type in your file.
  • For Ruby, the compiler generates a .rb file with a Ruby module containing your message types.
  • For Objective-C, the compiler generates a pbobjc.h and pbobjc.m file from each .proto, with a class for each message type described in your file.
  • For C#, the compiler generates a .cs file from each .proto, with a class for each message type described in your file.
  • For Dart, the compiler generates a .pb.dart file with a class for each message type in your file.

標量型別

.proto Type Notes C++ Type Java Type Python Type[2] Go Type Ruby Type C# Type PHP Type Dart Type
double double double float float64 Float double float double
float float float float float32 Float float float double
int32 使用可變長度編碼。編碼負數的效率低 - 如果您的欄位可能有負值,請改用sint32。 int32 int int int32 Fixnum or Bignum (as required) int integer int
int64 使用可變長度編碼。編碼負數的效率低 - 如果您的欄位可能有負值,請改用sint64。 int64 long int/long[3] int64 Bignum long integer/string[5] Int64
uint32 使用可變長度編碼 uint32 int int/long uint32 Fixnum or Bignum (as required) uint integer int
uint64 使用可變長度編碼. uint64 long int/long uint64 Bignum ulong integer/string[5] Int64
sint32 使用可變長度編碼。簽名的int值。這些比常規int32更有效地編碼負數。 int32 int int int32 Fixnum or Bignum (as required) int integer int
sint64 使用可變長度編碼。簽名的int值。這些比常規int64更有效地編碼負數。 int64 long int/long int64 Bignum long integer/string[5] Int64
fixed32 總是四個位元組。如果值通常大於228,則比uint32更有效。 uint32 int int/long uint32 Fixnum or Bignum (as required) uint integer int
fixed64 總是八個位元組。如果值通常大於256,則比uint64更有效 uint64 long int/long[3] uint64 Bignum ulong integer/string[5] Int64
sfixed32 總是四個位元組 int32 int int int32 Fixnum or Bignum (as required) int integer int
sfixed64 總是八個位元組 int64 long int/long int64 Bignum long integer/string[5] Int64
bool bool boolean bool bool TrueClass/FalseClass bool boolean bool
string 字串必須始終包含UTF-8編碼或7位ASCII文字,且不能超過232。 string String str/unicode string String (UTF-8) string string String
bytes 可以包含不超過232的任意位元組序列。 string ByteString str []byte String (ASCII-8BIT) ByteString string List

預設值

當時一個被編碼的message體中不存在某個message定義中的singular欄位時,在message體解析成的物件中,相應欄位會被設定為message定義中該欄位的預設值。預設值依型別而定:

  • 對於字串,預設值為空字串。
  • 對於位元組,預設值為空位元組。
  • 對於bools,預設值為false。
  • 對於數字型別,預設值為零。
  • 對於列舉,預設值是第一個定義的列舉值,該值必須為0。
  • 對於訊息欄位,未設定該欄位。它的確切值取決於語言。有關詳細資訊,請參閱程式碼生成指南

列舉型別

在定義訊息型別時,您可能希望其中一個欄位只有一個預定義的值列表中的值。例如,假設您要為每個SearchRequest新增corpus欄位,其中corpus可以是UNIVERSAL,WEB,IMAGES,LOCAL,NEWS,PRODUCTS或VIDEO。您可以非常簡單地透過向訊息定義新增列舉,併為每個可能的列舉值值新增常量來實現。

在下面的例子中,我們新增了一個名為Corpus的列舉型別,和一個Corpus型別的欄位:

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
  }
  Corpus corpus = 4;
}

如你所見,Corpus列舉的第一個常量對映到了0:所有列舉定義都需要包含一個常量對映到0並且作為定義的首行,這是因為:

  • 必須有0值,這樣我們就可以將0作為列舉的預設值。
  • proto2語法中首行的列舉值總是預設值,為了相容0值必須作為定義的首行。

使用其他Message型別

可以使用其他message型別作為欄位的型別,假設你想在每個SearchResponse訊息中攜帶型別為Result的訊息,

你可以在同一個.proto檔案中定義一個Result訊息型別,然後在SearchResponse中指定一個Result型別的欄位。

message SearchResponse {
  repeated Result results = 1;
}

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

匯入訊息定義

在上面的示例中,Result訊息型別在與SearchResponse相同的檔案中定義 - 如果要用作欄位型別的訊息型別已在另一個.proto檔案中定義,該怎麼辦?

您可以透過匯入來使用其他.proto檔案中的定義。要匯入另一個.proto的定義,請在檔案頂部新增一個import語句:

import "myproject/other_protos.proto";

預設情況下,您只能使用直接匯入的.proto檔案中的定義。但是,有時你可能需要將.proto檔案移動到新位置。現在,你可以在舊位置放置一個虛擬.proto檔案,在檔案中使用import public語法將所有匯入轉發到新位置,而不是直接移動.proto檔案並在一次更改中更新所有呼叫點。任何匯入包含import public語句的proto檔案的人都可以傳遞依賴匯入公共依賴項。例如

// new.proto
// All definitions are moved here
// old.proto
// This is the proto that all clients are importing.
import public "new.proto";
import "other.proto";
// client.proto
import "old.proto";
// You use definitions from old.proto and new.proto, but not other.proto

編譯器會在透過命令列引數-I或者--proto-path中指定的資料夾中搜尋.proto檔案,如果沒有提供編譯器會在喚其編譯器的目錄中進行搜尋。通常來說你應該將--proto-path的值設定為你專案的根目錄,並對所有匯入使用完全限定名稱。

使用proto2的訊息型別

可以匯入proto2版本的訊息型別到proto3的訊息型別中使用,當然也可以在proto2訊息型別中匯入proto3的訊息型別。但是proto2的列舉型別不能直接應用到proto3的語法中。

巢狀訊息型別

訊息型別可以被定義和使用在其他訊息型別中,下面的例子裡Result訊息被定義在SearchResponse訊息中

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

如果你想在外部使用定義在父訊息中的子訊息,使用Parent.Type引用他們

message SomeOtherMessage {
  SearchResponse.Result result = 1;
}

你可以巢狀任意多層訊息

message Outer {                  // Level 0
  message MiddleAA {  // Level 1
    message Inner {   // Level 2
      int64 ival = 1;
      bool  booly = 2;
    }
  }
  message MiddleBB {  // Level 1
    message Inner {   // Level 2
      int32 ival = 1;
      bool  booly = 2;
    }
  }
}

更新Message

如果一個現存的訊息型別不再滿足你當前的需求--比如說你希望在訊息中增加一個額外的欄位--但是仍想使用由舊版的訊息格式生成的程式碼,不用擔心!只要記住下面的規則,在更新訊息定義的同時又不破壞現有的程式碼就非常簡單。

  • 不要更改任何已存欄位的欄位編號。
  • 如果新增了新欄位,任何由舊版訊息格式生成的程式碼所序列化的訊息,仍能被依據新訊息格式生成的程式碼所解析。你應該記住這些元素的預設值這些新生成的程式碼就能夠正確地與由舊程式碼序列化建立的訊息互動了。類似的,新程式碼建立的訊息也能由舊版程式碼解析:舊版訊息(二進位制)在解析時簡單地忽略了新增的欄位,檢視下面的未知欄位章節瞭解更多。
  • 只要在更新後的訊息型別中不再重用欄位編號,就可以刪除該欄位。你也可以重新命名欄位,比如說新增OBSOLETE_字首或者將欄位編號設定為reserved,這些未來其他使用者就不會意外地重用該欄位編號了。

未知欄位

未知欄位是格式良好的協議緩衝區序列化資料,表示解析器無法識別的欄位。例如,當舊二進位制檔案解析具有新欄位的新二進位制檔案傳送的資料時,這些新欄位將成為舊二進位制檔案中的未知欄位。

最初,proto3訊息在解析期間總是丟棄未知欄位,但在3.5版本中,我們重新引入了未知欄位的保留以匹配proto2行為。在版本3.5及更高版本中,未知欄位在解析期間保留,幷包含在序列化輸出中。

對映型別

如果你想建立一個對映作為message定義的一部分,protocol buffers提供了一個簡易便利的語法

map<key_type, value_type> map_field = N;

key_type可以是任意整數或者字串(除了浮點數和bytes以外的所有標量型別)。注意enum不是一個有效的key_typevalue_type可以是除了對映以外的任意型別(意思是protocol buffers的訊息體中不允許有巢狀map)。

舉例來說,假如你想建立一個名為projects的對映,每一個Project訊息關聯一個字串鍵,你可以像如下來定義:

map<string, Project> projects = 3;
  • 對映裡的欄位不能是follow repeated規則的(意思是對映裡欄位的值不能是陣列)。
  • 對映裡的值是無序的,所以不能依賴對映裡元素的順序。
  • 生成.proto的文字格式時,對映按鍵排序。數字鍵按數字排序。
  • 從線路解析或合併時,如果有重複的對映鍵,則使用最後看到的鍵。從文字格式解析對映時,如果存在重複鍵,則解析可能會失敗。
  • 如果未給對映的欄位指定值,欄位被序列化時的行為依語言而定。在C++, Java和Python中欄位型別的預設值會被序列化作為欄位值,而其他語言則不會。

給Message加包名

你可以在.proto檔案中新增一個可選的package符來防止訊息型別之前的名稱衝突。

package foo.bar;
message Open { ... }

在定義message的欄位時像如下這樣使用package名稱

message Foo {
  ...
  foo.bar.Open open = 1;
  ...
}

package符對生成程式碼的影響視程式語言而定

定義服務

如果想訊息型別與RPC(遠端過程呼叫)系統一起使用,你可以在.proto檔案中定義一個RPC服務介面,然後protocol buffer編譯器將會根據你選擇的程式語言生成服務介面程式碼和stub,加入你要定義一個服務,它的一個方法接受SearchRequest訊息返回SearchResponse訊息,你可以在.proto檔案中像如下示例這樣定義它:

service SearchService {
  rpc Search (SearchRequest) returns (SearchResponse);
}

與protocol buffer 一起使用的最簡單的RPC系統是gRPC:一種由Google開發的語言和平臺中立的開源RPC系統。 gRPC特別適用於protocol buffer,並允許您使用特殊的protocol buffer編譯器外掛直接從.proto檔案生成相關的RPC程式碼。

如果你不想使用gRPC,可以使用自己實現的RPC系統,更多關於實現RPC系統的細節可以在Proto2 Language Guide中找到。

JSON編解碼

Proto3支援JSON中的規範編碼,使得在系統之間共享資料變得更加容易。在下表中逐個型別地列出了編碼規則。

如果JSON編碼資料中缺少某個值,或者其值為null,則在解析為protocol buffer時,它將被解釋為相應的預設值。如果欄位在protocol buffer中具有預設值,則預設情況下將在JSON編碼的資料中省略該欄位以節省空間。編寫編解碼實現可以覆蓋這個預設行為在JSON編碼的輸出中保留具有預設值的欄位的選項。

proto3 JSON JSON example Notes
message object {"fooBar": v, "g": null,…} 生成JSON物件。訊息欄位名稱會被轉換為小駝峰併成為JSON物件鍵。如果指定了json_name欄位選項,則將指定的值用作鍵。解析器接受小駝峰名稱(或由json_name選項指定的名稱)和原始proto欄位名稱。 null是所有欄位型別的可接受值,並被視為相應欄位型別的預設值。
enum string "FOO_BAR" 使用proto中指定的列舉值的名稱。解析器接受列舉名稱和整數值。
map<K,V> object {"k": v, …} 所有鍵都將被轉換為字串
repeated V array [v, …] null會被轉換為空列表[]
bool true, false true, false
string string "Hello World!"
bytes base64 string "YWJjMTIzIT8kKiYoKSctPUB+" JSON值將是使用帶填充的標準base64編碼編碼為字串的資料。接受帶有/不帶填充的標準或URL安全base64編碼。
int32, fixed32, uint32 number 1, -10, 0 JSON value will be a decimal number. Either numbers or strings are accepted.
int64, fixed64, uint64 string "1", "-10" JSON value will be a decimal string. Either numbers or strings are accepted.
float, double number 1.1, -10.0, 0, "NaN","Infinity" JSON value will be a number or one of the special string values "NaN", "Infinity", and "-Infinity". Either numbers or strings are accepted. Exponent notation is also accepted.
Any object {"@type": "url", "f": v, … } If the Any contains a value that has a special JSON mapping, it will be converted as follows: {"@type": xxx, "value": yyy}. Otherwise, the value will be converted into a JSON object, and the "@type" field will be inserted to indicate the actual data type.
Timestamp string "1972-01-01T10:00:20.021Z" Uses RFC 3339, where generated output will always be Z-normalized and uses 0, 3, 6 or 9 fractional digits. Offsets other than "Z" are also accepted.
Duration string "1.000340012s", "1s" Generated output always contains 0, 3, 6, or 9 fractional digits, depending on required precision, followed by the suffix "s". Accepted are any fractional digits (also none) as long as they fit into nano-seconds precision and the suffix "s" is required.
Struct object { … } Any JSON object. See struct.proto.
Wrapper types various types 2, "2", "foo", true,"true", null, 0, … Wrappers use the same representation in JSON as the wrapped primitive type, except that null is allowed and preserved during data conversion and transfer.
FieldMask string "f.fooBar,h" See field_mask.proto.
ListValue array [foo, bar, …]
Value value Any JSON value
NullValue null JSON null
Empty object {} An empty JSON object

生成程式碼

要生成Java,Python,C ++,Go,Ruby,Objective-C或C#程式碼,你需要使用.proto檔案中定義的訊息型別,你需要在.proto上執行protocol buffer編譯器protoc。如果尚未安裝編譯器,請下載該軟體包並按照README檔案中的說明進行操作。對於Go,還需要為編譯器安裝一個特殊的程式碼生成器外掛:你可以在GitHub上的golang/protobuf專案中找到這個外掛和安裝說明。

編譯器像下面這樣喚起:

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 --objc_out=DST_DIR --csharp_out=DST_DIR path/to/file.proto
  • IMPORT_PATH指定了在解析import命令時去哪裡搜尋.proto檔案,如果忽略將在當前工作目錄進行查詢,可以透過傳遞多次--proto-path引數來指定多個import目錄,他們將會按順序被編譯器搜尋。-I=IMPORT_PATH--proto_path的簡短形式。
  • 你可以提供一個或多個輸出命令:
  • 必須提供一個或多個.proto檔案作為輸入。可以一次指定多個.proto檔案。雖然檔案是相對於當前目錄命名的,但每個檔案必須存在於其中一個IMPORT_PATH中,以便編譯器可以確定其規範名稱。
本作品採用《CC 協議》,轉載必須註明作者和本文連結
公眾號:網管叨bi叨 | Golang、Laravel、Docker、K8s等學習經驗分享

相關文章