一種更緊湊的數據格式:Protobuf 入門

Protocol buffers,簡稱 Protobuf,是由 Google 設計的結構化序列化資料技術。對網路應用來講,常常需要在不同的伺服端、客戶端之間交換資料,這些資料格式有 XML、JSON 等等之類。XML 特點是完整,便於記載更多的 meta data,但格式複雜,需要更強的效能來解析,傳輸時也需要更多頻寬;而 JSON 是 JavaScript 用於表示物件的語法,相對 XML 來得簡潔,隨著 JavaScript 普及,也變成現在常用的輕量級資料交換格式。

儘管 XML 跟 JSON 在當前網路應用中已經相當常見,但用不同語言開發的伺服端程式,都需要分別實現自己語言的 XML 或 JSON 解析,同時,這些格式定義也是團隊協作的痛點。假設 A 團隊開發某支應用,因為開發時沒有定義文件,只用 Email 跟 B 團隊說明,如果說明得不夠清楚,就會導致 B 團隊在交換資料時格式錯誤,需要好幾個來回,才能釐清彼此應該實現的格式。

Protobuf 對這些問題有它的看法,本文會使用 Protobuf 來序列化資料,搭配 Golang 寫個簡單的讀檔寫檔程式,體會一下 Protobuf 的設計特點。

# Prepare Environment

Prorobuf 使用前,需要依據格式定義文件 proto file 來編譯訊息,編譯用工具是 protoc,可以到官網下載,或使用

choco install protoc

來安裝,安裝後輸入

PS C:\Users\ken> protoc --version
libprotoc 3.12.3

來確認版本。版本號很重要,跟 Google 其他的工具一樣,不同版本間可能有相容問題,使用時盡量依照語意化版本的方式選用對應版本。

Protobuf 用於結構化訊息的方法,是先將格式定義好,再用定義好的格式來產生序列化訊息,可以參考官方說明

You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages.

因為包含 Code Generator,Protobuf 可以相容多種語言,常見的 C++、C#、Java 等等都在支援中。

參照 Golang 的專案目錄,建個 pb 來放置 proto file

project
├── cmd
├── pkg
├── scripts
|   └── build_win.bat
├── pb
|   └── person.proto
|── go.mod
└── README.md

檔案內容是

syntax = "proto3";

package person;

message Person {
string name = 1;
string address = 2;
string gender = 3;
int32 age = 4;
}

syntax 是版本,用於跟 proto2 區別;package 是這份 proto file 所屬的套件名稱;message 用於定義訊息結構。假設訊息結構是 Person,其中包含姓名、地址、性別、年齡等欄位,用於表示 Person 的資料。各欄位等號後數字是用於區別欄位,如果開發中需要新增欄位,可以直接在 field 中使用新數字,即可無痛向後相容。

# Use Protobuf Command Line Tool

材料準備好後,來試著用 protoc 序列化訊息。前面已經定義好的格式了,這邊將需要傳輸的內容寫在 sample.txt

name: "Ken Chen"
address: "New Taipei City"
gender: "Male"
age: 18

引號後面是 proto 欄位的值,格式類似 JSON,可以參考官方說明。

然後用 protoc 來序列化訊息

protoc --encode person.Person ./pb/person.proto > encode.txt < sample.txt
type encode.txt

Ken ChenNew Taipei CityMale

最底下那串是序列化後的訊息,可以看到 string 還留著,但 field 跟 number 都被序列化成 byte 訊息了。

這些序列化完成的訊息,可以再用 protoc 反序列化回來,變成人眼可讀

protoc --decode person.Person ./pb/person.proto < encode.txt
name: "Ken Chen"
address: "New Taipei City"
gender: "Male"
age: 18

我們將傳輸內容序列化、寫進檔案、讀出、反序列化,這就是個簡單的傳輸過程。

# Prepare Golang Environment

驗證 Protobuf 的使用流程後,接著要將 Protobuf 的機制放到自行開發的應用程式中。

先下載 Golang 的 Protobuf 套件

go get google.golang.org/protobuf/cmd/protoc-gen-go

修改 proto file,讓它帶有 Golang Package 的資訊

syntax = "proto3";

option go_package = "example/pkg/pb/person";

package person;

message Person {
string name = 1;
string address = 2;
string gender = 3;
int32 age = 4;
}

使用 protoc 中的 Code Generator 來產生 go 的程式碼

protoc --go_out=./ --go_opt=paths=source_relative ./pb/*.proto

go_out 是產出文件的路徑;我們將 pb 的所有 proto file 都產生一份對應的 go 程式碼

可以看看產出文件 person.pb.go 的開頭

// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.25.0
// protoc v3.12.3
// source: pb/person.proto

package person

import (
proto "github.com/golang/protobuf/proto"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)

如第一行寫的,這是產出文件,看看就好,不要修改它。如果有一些客製化應用,希望在既有的 Protobuf struct 加上自己的 method,建議可以用 go package 的機制,在同目錄底下放 patch.go 文件來新增。

把產出的文件放到專案路徑 pkg,變成

project
├── cmd
├── pkg
|   └── person
|      └── person.pb.go
├── scripts
|   └── build_win.bat
├── pb
|   └── person.proto
|── go.mod
└── README.md

# Read/Write Data in Golang

可以來寫點應用了,在 cmd 下新增 go 程式碼

project
├── cmd
|   └── proto
|      └── main.go
├── pkg
|   └── person
|      └── person.pb.go
├── scripts
|   └── build_win.bat
├── pb
|   └── person.proto
|── go.mod
└── README.md

內容是

package main

import (
"example/pkg/person"
"fmt"
"io/ioutil"

"github.com/golang/protobuf/proto"
)

func main() {
data, _ := ioutil.ReadFile("encode.txt")
msg := person.Person{}
err := proto.Unmarshal(data, &msg)
if err != nil {
fmt.Println(err)
}
fmt.Println(msg.Name, msg.Gender, msg.Address, msg.Age)
}

由於前面已經用 protoc 產生序列化後的檔案 encode.txt,這裡可以讀取該檔來驗證程式。example/pkg/person 是引入剛剛產出的 go 套件。使用 ioutil 讀檔,再用 proto.Umarshal 反序列化成 struct,最後印出。

編譯並執行,看看結果

.\bin\proto.exe
Ken Chen Male New Taipei City 18

可以看到欄位值跟 sample.txt 相同。

接著來寫檔,修改 main.go

func main() {
// write file
msgWrite := person.Person{}
msgWrite.Age = 22
msgWrite.Gender = "Female"
msgWrite.Name = "Cythia"
msgWrite.Address = "Boston ,US"
dataWrite, err := proto.Marshal(&msgWrite)
if err != nil {
fmt.Println(err)
}
err = ioutil.WriteFile("encode.txt", dataWrite, os.ModePerm)
if err != nil {
fmt.Println(err)
}

// read file
data, _ := ioutil.ReadFile("encode.txt")
msg := person.Person{}
err = proto.Unmarshal(data, &msg)
if err != nil {
fmt.Println(err)
}
fmt.Println(msg.Name)
fmt.Println(msg.Gender)
fmt.Println(msg.Address)
fmt.Println(msg.Age)
}

寫檔的順序跟讀檔相反,先建立 Protobuf struct,填入各欄位,用 proto.Marshal 序列化,再用 ioutil.WriteFile 將序列化後的資訊寫進檔案中。寫入檔案同樣是 encode.txt,底下再用讀檔將內容讀出。

編譯並執行,觀察結果

.\bin\proto.exe
Cythia
Female
Boston ,US
22

檔案內容由 Ken 改為 Cythia,寫入成功。

# Compare with JSON

既然都有現成的資料了,可以再跟 JSON 比較,看看兩者序列化後的大小差多少。

由於 Protobuf 相容 JSON 的格式,這件事變得很容易,可以看 person.pb.go 的內容

type Person struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields

Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"`
Gender string `protobuf:"bytes,3,opt,name=gender,proto3" json:"gender,omitempty"`
Age int32 `protobuf:"varint,4,opt,name=age,proto3" json:"age,omitempty"`
}

在 Person 這個 struct 中,Name、Address、Gender、Age 等欄位有同時打上 protobuf 跟 json 兩種 tag,因此要轉換 struct 為 json,只需要直接使用 Golang 標準庫中的 json 庫即可。

修改 main.go

// write json
dataJSON, err := json.Marshal(&msgWrite)
if err != nil {
fmt.Println(err)
}
err = ioutil.WriteFile("encode_json.txt", dataJSON, os.ModePerm)
if err != nil {
fmt.Println(err)
}

執行後可以看到多出 encode_json.txt 這個檔案,內容是

{"name":"Cythia","address":"Boston ,US","gender":"Female","age":22}

來比較 Protobuf 跟 JSON 序列化後的差異,用

dir *.txt /s

得到

2020/06/26  下午 02:42                30 encode.txt
2020/06/26 下午 02:42 67 encode_json.txt

Protobuf 只要 30 bytes,而 JSON 需要 67 bytes,相差一倍多,難怪 Google 宣稱它又小又快。

# 小結

Protobuf 對網路應用來講,最主要的優點就是體積更小,傳輸更快,在高密度的資料交換場景,例如微服務組成的大型應用中,Protobuf 能有效提高傳輸速度。讓人訝異的是,根據這篇文章,Protobuf 在解析使用的資源居然還低於 JSON,真要說不方便的地方,大約是人眼不可讀這點。

除了傳輸與效能上的特點,Protobuf 將資料格式文件化,無意中也防止兩個不同的應用程序無法交換,對多人協作來講有其優勢。麻煩的地方可能是 proto file 需要額外版控,而 Git 目前的子版本版控技術用起來不是很方便,無論 submodule 或 subtree 都有限制,以 subtree 來講,會需要開發者先到 proto file 的 Repo 中修改檔案,加入並提交後,再到專案開發資料夾用 subtree 拉下來。可以預期在開發初期,proto file 會頻繁變更,衍生出許多隱形的開發成本。

Google 怎麼處理這問題呢?他們不用處理,因為 Google 的版控 Policy 是 Monorepo,所有的程式碼都放在一起,不需要做子版本版控。這好像是某種康威定律的佐證。

# Reference

標籤

其他文章

評論