跳至主要内容

[Note] Protocol Buffer 筆記

Language Guide (proto3) @ Google Developers

資訊

如果單純只使用 protocol buffer 的 go 套件,並沒有實作 go-grpc 的功能,若需要在 grpc 中使用 protocol buffer 需進一步參考 go-grpc

為什麼使用 Protocol Buffer 而非 JSON

JSON 的好處

  • 資料可以用任何形式,例如陣列、nested object
  • 在 Web 中被廣泛使用,且作為資料交換的方式
  • 多數語言都能解讀

Protocol Buffer 的好處

  • gRPC 使用 protocol buffer
  • 更快:payload size 比 JSON 小非常多,可以節省非常多耗用的流量
  • 效能更好:Parse JSON 是屬於 CPU intensive 的任務;protocol buffer 則是 binary format 的資料,更接近底層機器用來表徵資料的方式,因此所需消耗的 CPU 資源較少
  • 能夠清楚定義型別(data types) ,並且有資料交換的 schema
  • 可以寫註解在內
  • 自動產生可以使用的程式碼(例如,gRPC)

Protocol Declaration

syntax = "proto3";
package sandbox.protobuf_sandbox.proto.addressbook;
option go_package = "sandbox/protobuf-sandbox/proto";

import "google/protobuf/timestamp.proto";
  • package 是用來避免在不同專案之間的名稱衝突
  • go_package 是選填的(option),用來載入 proto 編譯後的檔案

定義 Message Type

message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
  • field:在 message 中每一行都是一個 field,並包含有名稱(name)和型別(type)
  • field type:每個欄位的第一個值是用來定義該欄位的型別
  • field number:每個欄位的最後有一個數字,這是數字將是唯一值(unique number),用來在 message binary format 中辨認欄位用

field number

可以看到每一個欄位都含有一個獨特的數字(unique number),這個數字是用來在 message binary format 中辨認欄位用的,一旦這個 message type 正在使用中就不應該再改變它,因此撰寫時並不一定要照著數字的順序,只要確保它是唯一個。由於數字 115 會使用 1 個 byte,數字 162047 會使用 2 個 byte,因此一般來說,會把最常用到的欄位使用 115 來編碼,但也要記得預留 115 的空間,因為未來可能會新增其他常用到的欄位

數字可以從 1 一直到 536,870,911(2^29 -1),但你不能使用 19000~19999 這個區間,因是這個區間是給 Protocol Buffer 實作用的

被保留的欄位(reserved fields)

在建立好 proto 檔後,未來更新 message 時若是直接移除(註解)掉某個欄位時,後續維護的開發者在不知情的情況下,可能會用到曾經被使用過的 field number,由於 gRPC 是透過 field number 來辨別欄位,因此重複的 field number 將會導致嚴重的問題。

為了避免這個問題,是把曾經使用過的 field numbers 註記保留下來,未來 protocol buffer 在 compiled 時如果有使用到這些 reserved fields 時就會提出警告。

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

型別

Protocol Buffers: Language Guide @ Google Developers

Scalar Value Types(純量型別)

Scalar Value Types @ Google Developer Docs

Default Values(預設值)

當 encoded message 的欄位沒有賦值時,再被解析後將被賦予預設值,這個預設值會根據型別的不同而不同。字串的預設值會是「空字串」、數值的預設值會是 0、Boolean 的預設值會是 false。

Enumerations

  • 在所有 enum 的定義中,第一個元素一定要是能夠 map 到 0 的常數
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;
}

使用其他的 message type

在一個 message type 中可以使用另一個定義好的 message type。舉例來說,在 SearchResponse 這個 message type 中可以使用 Result 這個 message type:

// repeated <型別> <欄位名稱> = <tag-number>
message SearchResponse {
repeated Result results = 1;
}

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

匯入定義(importing definitions)

除了把 message type 定義在同一支檔案之外,你也可以定義在不同的 proto 檔中,再透過 import 將 message type 匯入使用:

import "myproject/other_protos.proto";

Nested Type

另外,你也可以在一個 message type 中定義另一個 message type:

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

Any

import "google/protobuf/any.proto";

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

Style Guide

Style Guide @ Google Developer Docs

  • 檔案名稱:以小寫 snack case 命名,例如 lower_snake_case.proto
  • Package 名稱:包含專案資料夾在內,以小寫命名,例如檔案在 my/package/ 的話,便把該 package 命名為 my.package
  • Services name:使用大寫駝峰,例如 service FooService { }
  • Message names:使用大寫駝峰,例如 message SongServerRequest{ }
  • 欄位名稱(field):使用小寫 snake case 命名,例如,song_name,如果欄位名稱的最後帶有數字,則直接把數字跟在英文後,使用 song_name1(不要使用 song_name_1
  • Enums 型別:
    • 欄位名稱使用大寫駝峰
    • 欄位的常數值使用使用「大寫加上底線」,最後加上「分號」而非逗號
    • 對於 zero value 的 enum 使用 _UNSPECIFIED 當作後綴
enum Foo {
FOO_UNSPECIFIED = 0;
FOO_FIRST_VALUE = 1;
FOO_SECOND_VALUE = 2;
}

使用 ProtoBuffer 檔

這裡定義好的 proto 檔如下,接著就會利用這個 proto 檔來做一些操作:

protobuffer examples @ github

// https://github.com/protocolbuffers/protobuf/blob/master/examples/addressbook.proto
syntax = "proto3";
package sandbox.protobuf_sandbox;
option go_package = "sandbox/protobuf-sandbox";

import "google/protobuf/timestamp.proto";

message Person {
string name = 1;
int32 id = 2;
string email = 3;

enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}

message PhoneNumber {
string number = 1;
PhoneType type = 2;
}

repeated PhoneNumber phones = 4;
google.protobuf.Timestamp last_updated = 5;
}

message AddressBook {
repeated Person people = 1;
}
  • AddressBook message 包含了Person message。
  • message 也可以在某一個 message 內被定義,例如,在 Person message 中定義並使用了 PhoneNumber message。

編譯 Protocol Buffers(Compiling)

目前的資料夾結構如下:

.
└── sandbox
└── protobuf-sandbox
   ├── go.mod
├── go.sum
└── proto
└── addressbook.proto

1. 安裝 Compiler

首先需要在電腦上安裝 protocol buffer 的 compilers,如果是 Mac 的話,可以透過 home-brew 安裝:

# 安裝 compiler,安裝完後就會有 protoc CLI 工具
$ brew install protobuf
$ protoc --version # Ensure compiler version is 3+

2. 在專案中安裝 Go protocol buffers 的套件

# 安奘此 compiler 後,即可將 protocol buffer 編譯成 Golang 中可以使用的檔案(純用 Protocol Buffer 而非 gRPC)
$ go get -u google.golang.org/protobuf/cmd/protoc-gen-go

3. 執行 compiler

# $ protoc -I=$SRC_DIR --go_out=$DST_DIR $SRC_DIR/foobar.proto
$ protoc -I proto/ --go_out=proto/ --go_opt=paths=source_relative proto/addressbook.proto
  • 由於我們要產出 Go 的程式碼,因此使用 --go_out 這個 option,不同程式語言會使用不同的 options
  • 使用 --go_opt=paths=source_relative 將可以根據 source file 的相對路徑產出編譯後的 Go 檔

接著就會在 proto 資料夾中多一隻 addressbook.pb.go 的檔案。

編譯後的 Go 檔內容

根據下放的 Sample Code,在 addressbook.pb.go 的檔案中,可以看到有定義好的:

  • type Person struct {...}
  • type AddressBook struct{...}
  • type PhoneNumber struct{...}
  • 以及各個 Type 對應的 getter 和 setter

使用編譯後的 proto 檔案

enum

enum 欄位對應到 go 中會變成常數,例如 PhoneType 這個 enum 因為是定義在 Person message 中,因此在 go 中會以 Person 作為前綴,可以使用 pb.Person_MOBILE, pb.Person_HOME 等:

// enum 在 Go 中會變成常數
// pb.Person_MOBILE, pb.Person_HOME, pb.Person_WORK
switch ptype { // ptype 的內容由使用者定義
case "mobile":
pn.Type = pb.Person_MOBILE
case "home":
pn.Type = pb.Person_HOME
case "work":
pn.Type = pb.Person_WORK
default:
fmt.Printf("Unknown phone type %q. Using default.\n", ptype)
}

repeated

repeated 欄位對應到 go 中的 slice,可以直接使用 append 方法把對應的 struct 加到該 slice 中:

// repeated proto field 對應到 Go 中的 slice field
pn := &pb.Person_PhoneNumber{
Number: phone,
}
p.Phones = append(p.Phones, pn)

Message type

proto 中定義的 message type 在 Go 中會直接對應成一個 Go 的 struct type,例如 message Person 對應到的就是 type Person struct,使用時像是:

import pb "sandbox/protobuf-sandbox/proto"
p := &pb.Person{}

Message 中 nested 另一個 message

如果一個 message type 是 nested 在另一個 message type 中時,例如 PhoneNumber message 是 nested 在 Person message 中,假設想要在程式中建立 PhoneNumber 這個 message 的 struct,就可以把父層的 message 名稱作為前綴,像是:

// PhoneNumber 這個 message type 是 nested 在 Person message
// 在 Go struct 中會使用父層的 message type
pn := &pb.Person_PhoneNumber{
Number: phone,
}

Marshal and Unmarshal Data

使用 proto 套件提供的 MarshalUnmarshal 的資料可以對資料進行序列化和反序列化的動作:

// proto.Unmarshal(b []byte, m Message) error
import (
"fmt"
"google.golang.org/protobuf/proto"
"io/ioutil"
pb "sandbox/protobuf-sandbox/proto"
)

func main() {
// 讀檔
in, _ := ioutil.ReadFile("addressbook.data")

// 建立 struct
addressBook := pb.AddressBook{}

// 透過 proto.Unmarshal 把讀出的內容與 struct 做 mapping
if err := proto.Unmarshal(in, &addressBook); err != nil {
fmt.Println("can not parse the file")
}

fmt.Println(&addressBook)
// people:{name:"Aaron" id:1 email:"aaronchen@jubo.health" phones:{number:"12345678"}}
}