Skip to content

Protobuf

官網:Protocol Buffers | Google Developers

介紹

官方教程:Protocol Buffer Basics: Go | Protocol Buffers | Google Developers

Protocol Buffers是谷歌 2008 年開源的語言無關,協議無關,可擴展的結構化數據序列化機制,在解包封包的時候更加的快速,多用於 RPC 領域通信相關,可以定義數據的結構化方式,然後可以使用特殊生成的源代碼輕松地將結構化數據寫入各種數據流和從各種數據流中讀取結構化數據,並使用於各種語言,關於Protocol Buffers下文統稱為protobuf

protobuf算是比較流行,尤其是 go 這一塊,gRPC 就將其作為協議傳輸的序列化機制。

語法

首先從一個例子來看protobuf文件大體長什麼樣,總體來說它的語法非常簡單,十幾分鐘就能上手。下面是一個名為search.proto文件的例子,protobuf的文件後綴就是.proto

protobuf
syntax = "proto3";

message SearchRequest {
  string query = 1;
  string number = 2;
}

message SearchResult {
  string data = 1;
}

service SearchService {
  rpc Search(SearchRequest) returns(SearchResult);
}
  • 第一行syntax = "proto3"; 表示使用proto3的語法,默認使用proto3的語法。
  • message聲明的方式類似於結構體,是proto中的基本結構
  • SearchRequest中定義了三個字段,每個字段都會有名稱和類型
  • service中定義了一個服務,一個服務中包含一個或多個 rpc 接口
  • rpc 接口必須要有且只能有一個參數和返回值,它們的類型必須是message,不能是基本類型。

另外需要注意的是,proto文件中的每一行末尾必須要有分號結尾。

注釋

注釋風格跟 go 完全一致。

protobuf
syntax = "proto3";

/* 注釋
 * 注釋 */
message SearchRequest {
  string query = 1; //注釋
  string number = 2;
}

類型

類型修飾只能出現在message中,不能單獨出現。

基本類型

proto TypeGo Type
doublefloat64
floatfloat32
int32int32
int64int64
uint32uint32
uint64uint64
sint32int32
sint64int64
fixed32uint32
fixed64uint64
sfixed32int32
sfixed64int64
boolbool
stringstring
bytes[]byte

數組

在基本類型前面加上repeated修飾符表示這是一個數組類型,對應 go 中的切片。

protobuf
message Company {
  repeated string employee = 1;
}

map

在 protobuf 中定義 map 類型格式如下

map<key_type, value_type> map_field = N;

key_type必須是數字或者字符串,value_type沒有類型限制,看一個例子

protobuf
message Person {
  map<string, int64> cards = 1;
}

字段

事實上,proto 並不是傳統的鍵值類型,在聲明的proto文件中是不會出現具體的數據的,每一次字段的=後面跟的應該是當前message中的唯一編號,這些編號用於在二進制消息體中識別和定義這些字段。編號從 1 開始,1-15 的編號會佔用 1 個字節,16-2047 會佔用兩個字節,因此盡可能的將頻繁出現的字段賦予 1-15 的編號以節省空間,並且應該留出一些空間以留給後續可能會頻繁出現的字段。

一個message中的字段應當遵循以下規則

  • singular: 默認是該種類型的字段,在一個結構良好的message中,有且只能由 0 個或者 1 個該字段,即不能重復存在同一個字段。如下聲明便會報錯。

    protobuf
    syntax = "proto3";
    
    message SearchRequest {
      string query = 1;
      string number = 2;
      string number = 3;//字段重復
    }
  • optional: 與singular類似,只是可以顯示的檢查字段值是否被設置,可能會有以下兩種情況

    • set: 將會被序列化
    • unset: 不會被序列化
  • repeated: 此種類型的字段可以出現 0 次或多次,將會按照順序保留重復值(說白了其實就是數組,可以允許同一個類型的值多次重復出現,並且按照出現的順序保留,就是索引)

  • map: 鍵值對類型的字段,聲明方式如下

    protobuf
    map<string,int32> config = 3;

保留字段

reserve關鍵字可以聲明保留字段,保留字段編號聲明後,將無法再被用作其他字段的編號和名稱,編譯時也會發生錯誤。谷歌官方給出的回答是:,如果一個proto文件在新版本中刪除了一些編號,那麼在未來其他用戶可能會重用這些已被刪除的編號,但是倘若換回舊版本的編號的話就會造成字段對應的編號不一致從而產生錯誤,保留字段就可以在編譯期起到這麼一個提醒作用,提醒你不能使用這個保留使用的字段,否則編譯將會不通過。

protobuf
syntax = "proto3";

message SearchRequest {
  string query = 1;
  string number = 2;
  map<string, int32> config = 3;
  repeated string a = 4;
  reserved "a"; //聲明具體名稱的字段為保留字段
  reserved 1 to 2; //聲明一個編號序列為保留字段
  reserved 3,4; //聲明
}

如此一來,此文件將不會通過編譯。

棄用字段

如果一個字段被棄用,可以如下書寫。

protobuf
message Body {
  string name = 1 [deprecated = true];
}

枚舉

可以聲明枚舉常量並將其當作字段的類型來使用,需要注意的是,枚舉項的第一個元素必須是零,因為枚舉項的默認值就是第一個元素。

protobuf
syntax = "proto3";

enum Type {
  GET = 0;
  POST = 1;
  PUT = 2;
  DELETE = 3;
}

message SearchRequest {
  string query = 1;
  string number = 2;
  map<string, int32> config = 3;
  repeated string a = 4;
  Type type = 5;
}

當枚舉項內部存在相同值的枚舉項時,可以使用枚舉別名

protobuf
syntax = "proto3";

enum Type {
  option allow_alias = true; //需要開啟允許使用別名的配置項
  GET = 0;
  GET_ALIAS = 0; //GET枚舉項的別名
  POST = 1;
  PUT = 2;
  DELETE = 3;
}

message SearchRequest {
  string query = 1;
  string number = 2;
  map<string, int32> config = 3;
  repeated string a = 4;
  Type type = 5;
}

嵌套消息

protobuf
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裡面可以嵌套聲明message,就跟嵌套結構體一樣。

Package

您可以向protobuf文件添加一個可選的包修飾符,以防止協議消息類型之間的名稱沖突。

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

然後,您可以在定義消息類型的字段時使用包名:

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

Import

導入可以讓多個protobuf文件共享定義,它的語法如下,在導入的時候不能省略文件拓展名。

protobuf
import "a/b/c.proto";

在導入的時候都是使用的相對路徑,這個相對路徑不是指的導入文件與被導入文件的相對路徑,而是取決於protoc編譯器生成代碼時所指定的掃描路徑,假設有如下的文件結構

pb_learn
│  common.proto

├─monster
│      monster.proto

└─player
        health.proto
        player.proto

如果我們只需要生成player目錄部分的代碼,並且在掃描路徑時僅指定了player目錄,那麼health.protoplayer.proto之間的相互導入可以直接寫單文件名,比如player.proto導入health.proto

protobuf
import "health.proto";

倘若此時player.proto導入了common.protomonster目錄下的文件,那麼就會編譯失敗,所以下面這種寫法是完全錯誤的,因為編譯器沒法找到這些文件。

go
import "../common.proto"; // 錯誤寫法

TIP

順帶一提,...這些符號是不允許出現在導入路徑中的。

假設在編譯時指定了pb_learn為掃描路徑,那麼就可以通過相對路徑來導入其它目錄的文件,實際導入的路徑就是該文件的絕對地址相對於pb_learn的相對地址,看下面player.proto導入其它文件的例子。

protobuf
import "common.proto";
imrpot "monster/monster.proto";
import "player/health.proto";

即便是處於同一目錄下的health.proto此刻也必須要使用相對路徑。所以在一個項目中,我們一般會單獨創建一個文件夾來存放所有的protobuf文件,並在編譯時指定其作為掃描路徑,而該目錄下的所有導入行為也是基於它的相對路徑。

TIP

如果你使用的是 goland 編輯器,對於你自己創建的protobuf目錄,默認是沒法解析的,也就會出現爆紅的情況,想要 goland 識別的話就得手動設置掃描路徑,其原理跟上面講的完全一樣,設置方法如下,打開如下設置

File | Settings | Languages & Frameworks | Protocol Buffers

Import Paths中手動添加掃描路徑,這個掃描路徑應該跟你編譯時指定的路徑是一致的。

Any

Any 類型允許您將消息作為嵌入類型使用,而不需要它們的 proto 定義,我們可以直接導入谷歌定義的類型,它是自帶的,不需要手動編寫。

protobuf
import "google/protobuf/any.proto";

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

谷歌還預定義了其它非常多的類型,前往protobuf/ptypes at master · golang/protobuf (github.com)查看更多,主要有包括

  • 基本類型的封裝
  • 時間類型
  • Duration 類型

有關它們的protobuf定義應該在protoc編譯器的inlucde目錄下。

OneOf

這裡的官方文檔給出的解釋實在是太繁瑣了,說人話其實就是表示一個字段在傳輸時會有多種可能的類型,但最終只可能會有一個類型被使用,它的內部不允許出現repeated修飾的字段,這就好像 c 語言中的union一樣。

protobuf
message Stock {
    // Stock-specific data
}

message Currency {
    // Currency-specific data
}

message ChangeNotification {
  int32 id = 1;
  oneof instrument {
    Stock stock = 2;
    Currency currency = 3;
  }
}

Service

service關鍵字可以定義一個 RPC 服務,一個 RPC 服務包含若干個 rpc 接口,接口又分為一元接口和流式接口。

protobuf
message Body {
  string name = 1;
}

service ExampleService {
  rpc DoSomething(Body) returns(Body);
}

而流式接口又分為單向流式和雙向流式,通常用stream關鍵字來修飾,看下面的一個例子。

protobuf
message Body {
  string name = 1;
}

service ExampleService {
  // 客戶端流式
  rpc DoSomething(stream Body) returns(Body);
  // 服務端流式
  rpc DoSomething1(Body) returns(stream Body);
  // 雙向流式
  rpc DoSomething2(stream Body) returns(stream Body);
}

所謂流式就是就是在一個連接中長期的相互發送數據,而不再像一元接口那樣簡單的一問一答。

Empty

empty 實際上是一個空的message,對應 go 中的空結構體,它很少用於修飾字段,主要是用來表示某個 rpc 接口不需要參數或者沒有返回值。

protobuf
syntax = "proto3";

import "google/protobuf/empty.proto";

service EmptyService {
  rpc Do(google.protobuf.Empty) returns(google.protobuf.Empty);
}

Option

option 通常用於控制protobuf的一些行為。比如控制 go 語言源代碼生成的包,就可以如下聲明。

protobuf
option go_package = "github/jack/sample/pb_learn;pb_learn"

分號前面的是代碼生成後其它源文件的導入路徑,分號後面的就是對應生成文件的包名。

它可以做一些一些優化,有以下幾個可用的值,不可重復聲明。

  • SPEED,優化程度最高,生成的代碼體積最大,默認是這個。
  • CODE_SIZE,會減少代碼生成的體積,但是會依賴反射進行序列化
  • LIFE_RUNEIMTE,代碼體積最小,但是會缺少一些特性。

下面是一個使用案例

protobuf
option optimize_for = SPEED;

除此之外,option 還可以給messageenum添加一些元信息,利用反射可以獲取這些信息,這在進行參數校驗的時候尤其有用。

編譯

編譯也就是代碼生成,上面只是定義了protobuf文件,實際使用時需要將其轉化為某種特定的語言源代碼才能使用,我們通過protoc編譯器來完成此時,它支持多種語言。

安裝

編譯器下載的話到protocolbuffers/protobuf: Protocol Buffers - Google's data interchange format (github.com)去下載最新版的 Release,一般是一個壓縮文件

protoc-25.1-win64
│  readme.txt

├─bin
│      protoc.exe

└─include
    └─google
        └─protobuf
            │  any.proto
            │  api.proto
            │  descriptor.proto
            │  duration.proto
            │  empty.proto
            │  field_mask.proto
            │  source_context.proto
            │  struct.proto
            │  timestamp.proto
            │  type.proto
            │  wrappers.proto

            └─compiler
                    plugin.proto

下載好後將 bin 目錄添加到環境變量中,以便可以使用protoc命令,完成後看下版本,能正常輸出就說明安裝成功

bash
$ protoc --version
libprotoc 25.1

下載下來的編譯器默認不支持 go 語言,因為 go 語言代碼生成是單獨的一個可執行文件,其它語言全揉一塊了,所以再安裝 go 語言插件,用於將protocbuf定義翻譯成 go 語言源代碼。

bash
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

假如還需要生成 gRPC 服務代碼,再安裝如下插件

bash
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

安裝後查看其版本

bash
$ protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.3.0

$ protoc-gen-go --version
protoc-gen-go.exe v1.31.0

這些插件也是單獨的二進制文件,不過只能通過protoc來調用,不能單獨執行。

(this program should be run by protoc, not directly)

除此之外還有許多其它插件,比如生成openapi接口文檔的插件等等,感興趣可以自己去搜索。

生成

還是拿之前的例子來講,結構如下

pb_learn
│  common.proto

├─monster
│      monster.proto

└─player
        health.proto
        player.proto

對於生成代碼來說,總共要指定三個參數

  1. 掃描路徑,指示編譯器從哪裡尋找protobuf文件以及如何解析導入路徑
  2. 生成路徑,編譯好後的文件放在哪裡
  3. 目標文件,指定哪些目標文件要被編譯。

在開始之前要確保protobuf文件中的go_package設置正確,通過protoc -h來查看其支持的參數,最常用的是-I或者--proto_path,可以多次使用來指定多個掃描路徑,例如

bash
$ protoc --proto_path=./pb_learn --proto_path=./third_party

僅僅只是指定掃描路徑是不夠的,還需要指定生成路徑以及目標protobuf文件,這裡是生成go文件所以使用--go_out參數,由之前下載的protoc-gen-go插件支持。

bash
$ cd pb_learn

$ protoc --proto_path=. --go_out=. common.proto

$ ls
common.pb.go  common.proto  monster/  player/

--go_out的參數就是指定的生成路徑,.表示的就是當前路徑,common.proto就是指定要編譯的文件。如果要生成grpc代碼(前提是裝了 grpc 插件),可以加上--go-grpc_out參數(如果protobuf文件中沒有定義service,就不會生成對應文件)。

bash
$ protoc --proto_path=. --go_out=. --go-grpc_out=. common.proto

$ ls
common.pb.go  common.proto  common_grpc.pb.go  monster/  player/

common.pb.go是生成的protobuf類型定義,common_grpc.pb.go是生成的gRPC代碼,它基於前者,如果沒有生成對應語言的定義,也就沒法生成gRPC代碼。

如果想要將該目錄下的所有的protobuf文件都編譯,可以使用通配符*,比如``

bash
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.proto

如果想要包含所有的文件,可以使用**通配符,比如./**/*.proto

bash
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.proto

但是,這種方法僅適用於支持這種通配符的 shell,比如在 windows 下,cmd 或 powershell 都不支持這種寫法

powershell
D> protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./**/*.proto
Invalid file name pattern or missing input file "./**/*.proto"

幸運的是 gitbash 支持 linux 許多命令,也可以讓 windows 支持這種語法。為了避免每次都要寫重復的命令,可以將其放在makefile裡面

makefile
.PHONY: all

proto_gen:
  protoc --proto_path=. \
       --go_out=paths=source_relative:. \
       --go-grpc_out=paths=source_relative:. \
       ./**/*.proto ./*.proto

可以注意到多了一個paths=source_relative:.,這是在設置文件生成的路徑模式,總共有以下幾個可選項

  • paths=import,默認就是這個,文件會生成在import所指定的目錄下,它也可以是一個模塊路徑。比如現有一個文件protos/buzz.proto,指定paths=example.com/project/protos/fizz,那麼最終會生成example.com/project/protos/fizz/buzz.pb.go
  • module=$PREFIX,在生成時,會刪除路徑前綴。在上面的例子中,如果指定前綴example.com/project,那麼最終會生成protos/fizz/buzz.pb.go,這個模式主要是用於將其直接生成在模塊中(感覺好像沒什麼區別)。
  • paths=source_relative,生成的文件會在指定目錄中保持與protobuf文件相同的相對結構。

冒號:間隔後就是指定的生成路徑。

|  common.proto
|  common.pb.go

├─monster
│      monster.pb.go
│      monster.proto

└─player
        health.pb.go
        health.proto
        health_grpc.pb.go
        player.pb.go
        player.proto

反射

通過options可以對enummessagee進行拓展,先導入"google/protobuf/descriptor.proto"

protobuf
import "google/protobuf/descriptor.proto";

extend google.protobuf.EnumValueOptions {
  optional string string_name = 123456789;
}

enum Integer {
  INT64 = 0[
    (string_name) = "int_64"
  ];
}

這相當於給該枚舉值加了一個元信息。對於message也是同理,如下

protobuf
import "google/protobuf/descriptor.proto";

extend google.protobuf.MessageOptions {
  optional string my_option = 51234;
}

message MyMessage {
  option (my_option) = "Hello world!";
}

這就相當是有關於protobuf的反射,在生成代碼後可以通過Descriptor來進行訪問,如下

go
func main() {
  message := pb_learn.MyMessage{}
  message.ProtoReflect().Descriptor().Options().ProtoReflect().Range(func(descriptor protoreflect.FieldDescriptor, value protoreflect.Value) bool {
    fmt.Println(descriptor.FullName(), ":", value)
    return true
  })
}

輸出

my_option:"Hello world!"

這種方式可以類比一下 go 中給結構體加 tag,都是差不的感覺,根據這種方式還能實現參數校驗的功能,只需要在options中書寫規則,通過Descriptor來進行檢查。

Golang學習網由www.golangdev.cn整理維護