[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 套件提供的 Marshal
和 Unmarshal
的資料可以對資料進行序列化和反序列化的動作:
// 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"}}
}