Protocol Buffers(protobuf)是一种语言无关,平台无关,可扩展的用于序列化结构化数据的方式——类似XML,但比XML更灵活,更高效。虽然平常工作中经常用到protobuf,但很多时候只是停留在基本语法的使用上,很多高级特性和语法还掌握不全,在阅读一些开源proto库的时候,总会看到一些平常没有使用过的语法,影响理解。
本文基于Go语言,总结了所有的proto3
常用和不常用的语法和示例,助你全面掌握protobuf语法,加深理解,扫清源码阅读障碍。
Quick Start
使用protobuf语法编写xxx.proto
文件,然后将其编译成可供特定语言识别和使用的代码文件,供程序调用,这是protobuf的基本工作原理。
以Go语言为例,使用官方提供的编译器会将xxx.proto
文件编译成xxx.pb.go
文件——一个普通的go代码文件。
要使用protobuf,首先我们需要下载protobuf编译器——protoc,但Go语言并没有被编译器直接支持,而是通过插件的方式被编译器引用,所以同时我们还需要下载Go语言的编译插件:
- 下载合适环境的编译器(
protoc-$VERSION-$PLATFORM.zip
):github.com/protocolbuf… - 下载安装Go语言编译插件:
go install google.golang.org/protobuf/cmd/protoc-gen-go
安装完毕后,我们准备如下文件$SRC_DIR/quick_start.proto
:
syntax = "proto3";
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
复制代码
执行编译器命令:protoc --go_out=$DST_DIR $SRC_DIR/quick_start.proto
。
该命令将编译$SRC_DIR/quick_start.proto
文件,并且将其基于Go语言的编译输出结果保存到文件$DST_DIR/quick_start.qb.go
中:
....
type SearchRequest struct {
Query string `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"`
PageNumber int32 `protobuf:"varint,2,opt,name=page_number,json=pageNumber,proto3" json:"page_number,omitempty"`
ResultPerPage int32 `protobuf:"varint,3,opt,name=result_per_page,json=resultPerPage,proto3" json:"result_per_page,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
....
复制代码
在程序中引入生成文件quick_start.qb.go
所在的包,就可以用protobuf的方式对结构体进行序列化和反序列化。
序列化:
req := &pb.SearchRequest{} //此处pb是 quick_start.qb.go 所在包的别名
// ...
// 序列化结构体,写入文件
out, err := proto.Marshal(req)
if err != nil {
log.Fatalln("Failed to encode search request :", err)
}
if err := ioutil.WriteFile(fname, out, 0644); err != nil {
log.Fatalln("Failed to write search request:", err)
}
复制代码
反序列化:
// 从文件读取消息,并将其反序列化成结构体
in, err := ioutil.ReadFile(fname)
if err != nil {
log.Fatalln("Error reading file:", err)
}
book := &pb.SearchRequest{}
if err := proto.Unmarshal(in, book); err != nil {
log.Fatalln("Failed to parse search request:", err)
}
复制代码
A Bit of Everything
quick start示例中展示的是最基础的用法,下面我们通过一个包含所有proto3
语法的示例,逐一讲解protobuf的各项语法和功能。
示例代码在这里可以找到:a_bit_of_everything.proto
在代码根目录下执行protoc --go_out=plugins=grpc:. a_bit_of_everything.proto
生成xxx.pb.go
文件。
package
syntax = "proto3";
option go_package = "examplepb"; // 编译后的golang包名
package example.everything; // proto包名
...
复制代码
在示例文件的起始位置会看到go_package
和package
两个关于包的声明,但这两个package
表达的意义并不相同,package example.everything;
表明的是当前.proto
文件所在的包名,跟Go语言类似,在相同的包名下,不能定义相同名称的message
,enum
或是service
。
option go_package = "examplepb"
则定义了一个文件级别的option
,用于指定编译后的golang包名。
import
...
import "google/protobuf/any.proto";
import "google/protobuf/descriptor.proto";
//import "other.proto";
...
复制代码
import
用于引入其他的proto文件,当在当前文件中要使用其他proto文件的定义时,需要将其import
进来,然后可以通过类似packageName.MessageName
的方式来引用需要的内容,跟Go语言的import
十分类似。执行编译protoc
的时候,需要加上-I
参数来指定import
文件的路径,例如:
protoc -I $GOPATH/src --go_out=. a_bit_of_everything.proto
示例中引入的any.proto和descriptor.proto已经内置到protoc中,故编译本示例不需要加-I参数
标量类型 (Scalar Value Types)
proto类型 | Go类型 | 备注 |
---|---|---|
double | float64 | |
float | float | |
int32 | int32 | 编码负数值相对低效 |
int64 | int64 | 编码负数值相对低效 |
uint32 | uint32 | |
uint64 | uint64 | |
sint32 | int32 | 当值为负数时候,编码比int32更高效 |
sint64 | int64 | 当值为负数时候,编码比int64更高效 |
fixed32 | uint32 | 当值总是大于2^28时,编码比uint32更高效 |
fixed64 | uint64 | 当值总是大于2^56时,编码比uint32更高效 |
sfixed32 | int32 | |
sfixed64 | int64 | |
bool | bool | |
string | string | 只能是utf-8编码或者7-bit ASCII文本,且长度不得大于2^32 |
bytes | []byte | 不大于2^32的任意长度字节序列 |
message消息
// 普通的message
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
复制代码
message
可以包含多个字段声明,每个字段声明需要包含字段类型,字段名称和一个唯一序号。字段类型可以是标量,枚举或是其他message
类型。唯一序号用于标识该字段在消息二进制编码中位置。
还可以用
repeated
来修饰字段类型,详见下文repeated
说明。
枚举类型
...
// 枚举 enum
enum Status {
STATUS_UNSPECIFIED = 0;
STATUS_OK = 1;
STATUS_FAIL= 2;
STATUS_UNKNOWN = -1; // 不推荐有负数
}
...
复制代码
通过enum
关键字定义枚举类型,在protobuf中,枚举是一个int32类型。第一个枚举值必须从0开始,如果不希望在代码中使用0值,可以将第一个值用XXX_UNSPECIFIED
作为占位符。由于enum类型实际上是用protobuf的int32类型的编码方式编码,故不推荐在枚举类型中使用负数。
XXX_UNSPECIFIED
只是一种代码规范。并不影响代码行为。
保留字段 (Reserved Fields) & 保留枚举值(Reserved Values)
// 保留字段
message ReservedMessage {
reserved 2, 15, 9 to 11;
reserved "foo", "bar";
// string abc = 2; // 编译报错
// string foo = 3; // 编译报错
}
// 保留枚举
enum ReservedEnum {
reserved 2, 15, 9 to 11, 40 to max;
reserved "FOO", "BAR";
// FOO = 0; // 编译报错
F = 0;
}
复制代码
如果我们将某message
中的字段删除了,后面更新可能会重新使用这些字段。当新旧两种proto定义都在线上运行时,编解码可能会发生错误。例如有新旧两个版本的Foo
:
// old version
message Foo {
string a = 1;
}
复制代码
// new version
message Foo {
int32 a = 1;
}
复制代码
如果使用新版本的proto来解析旧版本的消息,就会发生错误,因为新版本proto会尝试将a
解析成int32,但实际上旧版本proto是按照string类型来对a
进行编码的。protobuf通过提供reserved
关键字来避免新旧版本冲突的问题:
// new version
message Foo {
reserved 1; // 标记第一个字段是保留的
int32 a = 2; // 序号从2开始,就不会与旧版本的string类型a冲突了
}
复制代码
嵌套
// nested 嵌套message
message SearchResponse {
message Result {
string url = 1 ;
string title = 2;
}
enum Status {
UNSPECIFIED = 0;
OK = 1;
FAIL= 2;
}
Result results = 1;
Status status = 2;
}
复制代码
message
允许多层嵌套,message
和enum
都可以嵌套。被嵌套的message
和enum
不仅可以在当前message
中使用,也可以被其他message
引用:
message OtherResponse {
SearchResponse.Result result = 1;
SearchResponse.Status status = 2;
}
复制代码
复合类型
除标量类型外,protobuf还提供了一些非标量类型,在本文中我把它们统称为复合类型。
复合类型并不是官方划分的类别。是本文为了便于理解而归纳总结的一个概念。
repeated
// repeated
message RepeatedMessage {
repeated SearchRequest requests = 1;
repeated Status status = 2;
repeated int32 number = 3;
}
复制代码
repeated
可以作用在message
中的变量类型上。只有标量类型,枚举类型和message类型可以被repeated
修饰。repeated
表示当前修饰变量可以被重复任意次(包括0次),其实就是表示当前修饰类型的一个变长数组,也就是Go语言中的slice
:
// repeated
type RepeatedMessage struct {
Requests []*SearchRequest `protobuf:"bytes,1,rep,name=requests,proto3" json:"requests,omitempty"`
Status []Status `protobuf:"varint,2,rep,packed,name=status,proto3,enum=example.everything.Status" json:"status,omitempty"`
Number []int32 `protobuf:"varint,3,rep,packed,name=number,proto3" json:"number,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
复制代码
map
message MapMessage{
map<string, string> message = 1;
map<string, SearchRequest> request = 2;
}
复制代码
除了slice
,当然还有map
。其中key的类型可以是除去double
,float
,bytes
以外的标量类型,value的类型可以是任意标量类型,枚举类型和message类型。protobuf的map
编译成Go语言后也是用map
来表示:
...
// map
type MapMessage struct {
Message map[string]string `protobuf:"bytes,1,rep,name=message,proto3" json:"message,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
Request map[string]*SearchRequest `protobuf:"bytes,2,rep,name=request,proto3" json:"request,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
...
复制代码
any
...
import "google/protobuf/any.proto";
...
message AnyMessage {
string message = 1;
google.protobuf.Any details = 2;
}
...
复制代码
any
类型可以包含一个不需要指定类型的任意的序列化消息。要使用any
类型,需要import google/protobuf/any.proto
。any
类型字段的encode/decode交由各语言的运行时各自实现,例如在Go语言中可以这样读写any
类型的字段:
...
import "github.com/golang/protobuf/ptypes"
...
func getSetAny() {
fmt.Println("getSetAny")
req := &examplepb.SearchRequest{
Query: "query",
}
// 将SearchRequest打包成Any类型
a, err := ptypes.MarshalAny(req)
if err != nil {
log.Println(err)
return
}
// 赋值
anyMsg := &examplepb.AnyMessage{
Message: "any message",
Details: a,
}
req = &examplepb.SearchRequest{}
// 从Any类型中还原proto消息
err = ptypes.UnmarshalAny(anyMsg.Details, req)
if err != nil {
log.Println(err)
}
fmt.Println(" any:", req)
}
复制代码
one of
// one of
message OneOfMessage {
oneof test_oneof {
string m1 = 1;
int32 m2 =2;
}
}
复制代码
如果某消息包含多个字段,但这些字段同一时间最多只允许一个被设置时,可以通过oneof
来保证这样的行为。对oneof
中任意一个字段设值,都会将其他字段清空。例如对上述的例子,test_oneof
字段要么是string类型的m1,要么是int32类型的m2。在Go语言中读写oneof
的示例如下:
func getSetOneof() {
fmt.Println("getSetOneof")
oneof := &examplepb.OneOfMessage{
// 同一时间只能设值一个值
TestOneof: &examplepb.OneOfMessage_M1{
M1: "this is m1",
},
}
fmt.Println(" m1:", oneof.GetM1()) // this is m1
fmt.Println(" m2:", oneof.GetM2()) // 0
}
复制代码
options & extensions
相信大部的gopher在平常使用protobuf的过程中都很少关注options
,80%的开发工作也不需要直接用到options
。但options是一个很有用的功能,其大大提高了protobuf的扩展性,我们有必要了解它。options
其实是protobuf内置的一些message
类型,其分为以下几个级别:
- 文件级别(file-level options)
- 消息级别(message-level options)
- 字段级别(field-level options)
- service级别(service options)
- method级别(method options)
protobuf提供一些内置的options
可供选择,也提供了通过extend
关键字来扩展这些options
,达到增加自定义options
的目的。
在
proto2
语法中,extend
可以作用于任何message
,但在proto3
语法中,extend
仅能作用于这些定义option
的message
——仅用于自定义option
。
options
不会改变声明的整体含义(例如声明的是int32就是int32,不会因为一个option改变了其声明类型),但可能会影响在特定情况下处理它的方式。例如我们可以使用内置的deprecated option
将某字段标记为deprecated
:
message Msg {
string foo = 1;
string bar = 2 [deprecated = true]; //标记为deprecated。
}
复制代码
当我们需要编写自定义protoc插件时,可以通过自定义options
为编译插件提供额外信息。举个例子,假设我要开发一个proto的校验插件,其生成xxx.Validate()
方法来校验消息的合法性,我可以通过自定义options
来提供生成代码的必要信息:
message Msg {
// required是自定义options,表示foo字段必须非空
string foo = 1; [required = true];
}
复制代码
内置options
的定义可以在github.com/protocolbuf…找到,每种级别的options
都对应一个message
,分别是:
- FileOptions —— 文件级别
- MessageOptions —— 消息级别
- FieldOptions —— 字段级别
- ServiceOptions —— service级别
- MethodOptions —— method级别
以下将通过示例来逐一介绍这些级别的options
,以及如何扩展这些options
。
文件级别
...
option go_package = "examplepb"; // 编译后的golang包名
...
message extObj {
string foo_string= 1;
int64 bar_int=2;
}
// file options
extend google.protobuf.FileOptions {
string file_opt_string = 1001;
extObj file_opt_obj = 1002;
}
option (example.everything.file_opt_string) = "file_options";
option (example.everything.file_opt_obj) = {
foo_string: "foo"
bar_int:1
};
复制代码
go_package
毫无疑问是protobuf内置提供的,用于指定编译后的golang包名。除了使用内置的外,可以通过extend
字段来扩展内置的FileOptions
,例如在上述例子中,我们新增了两个新的option——string类型的file_opt_string
和extObj类型的file_opt_obj
。并通过option
关键字设置了两个文件级别的options。在Go语言中,我们可以这样读取这些options:
func getFileOptions() {
fmt.Println("file options:")
msg := &examplepb.MessageOption{}
md, _ := descriptor.MessageDescriptorProto(msg)
stringOpt, _ := proto.GetExtension(md.Options, examplepb.E_FileOptString)
objOpt, _ := proto.GetExtension(md.Options, examplepb.E_FileOptObj)
fmt.Println(" obj.foo_string:", objOpt.(*examplepb.ExtObj).FooString)
fmt.Println(" obj.bar_int", objOpt.(*examplepb.ExtObj).BarInt)
fmt.Println(" string:", *stringOpt.(*string))
}
复制代码
打印结果:
file options:
obj.foo_string: foo
obj.bar_int 1
string: file_options
复制代码
消息级别
// message options
extend google.protobuf.MessageOptions {
string msg_opt_string = 1001;
extObj msg_opt_obj = 1002;
}
message MessageOption {
option (example.everything.msg_opt_string) = "Hello world!";
option (example.everything.msg_opt_obj) = {
foo_string: "foo"
bar_int:1
};
string foo = 1;
}
复制代码
与文件级别大同小异,不再赘述。Go语言读取示例:
func getMessageOptions() {
fmt.Println("message options:")
msg := &examplepb.MessageOption{}
_, md := descriptor.MessageDescriptorProto(msg)
objOpt, _ := proto.GetExtension(md.Options, examplepb.E_MsgOptObj)
stringOpt, _ := proto.GetExtension(md.Options, examplepb.E_MsgOptString)
fmt.Println(" obj.foo_string:", objOpt.(*examplepb.ExtObj).FooString)
fmt.Println(" obj.bar_int", objOpt.(*examplepb.ExtObj).BarInt)
fmt.Println(" string:", *stringOpt.(*string))
}
复制代码
字段级别
// field options
extend google.protobuf.FieldOptions {
string field_opt_string = 1001;
extObj field_opt_obj = 1002;
}
message FieldOption {
// 自定义的option
string foo= 1 [(example.everything.field_opt_string) = "abc",(example.everything.field_opt_obj) = {
foo_string: "foo"
bar_int:1
}];
// protobuf内置的option
string bar = 2 [deprecated = true];
}
复制代码
字段级别的option定义方式不使用option
关键字,格式为:用[]包裹的用逗号分隔的k=v形式的数组。在Go语言中,我们可以这样读取这些option:
func getFieldOptions() {
fmt.Println("field options:")
msg := &examplepb.FieldOption{}
_, md := descriptor.MessageDescriptorProto(msg)
stringOpt, _ := proto.GetExtension(md.Field[0].Options, examplepb.E_FieldOptString)
objOpt, _ := proto.GetExtension(md.Field[0].Options, examplepb.E_FieldOptObj)
fmt.Println(" obj.foo_string:", objOpt.(*examplepb.ExtObj).FooString)
fmt.Println(" obj.bar_int", objOpt.(*examplepb.ExtObj).BarInt)
fmt.Println(" string:", *stringOpt.(*string))
}
复制代码
应用项目参考:github.com/mwitkow/go-… go-proto-validators是一个用于生成可以校验proto消息合法性的proto编译插件,其使用字段级别的option来定义校验规则。
service和method级别
// service & method options
extend google.protobuf.ServiceOptions {
string srv_opt_string = 1001;
extObj srv_opt_obj = 1002;
}
extend google.protobuf.MethodOptions {
string method_opt_string = 1001;
extObj method_opt_obj = 1002;
}
service ServiceOption {
option (example.everything.srv_opt_string) = "foo";
rpc Search (SearchRequest) returns (SearchResponse) {
option (example.everything.method_opt_string) = "foo";
option (example.everything.method_opt_obj) = {
foo_string: "foo"
bar_int: 1
};
};
}
复制代码
service和method级别的option也是通过option
关键字来定义,与文件级别和消息级别option类似,不再赘述。Go语言读取示例:
func getServiceOptions() {
fmt.Println("service options:")
msg := &examplepb.MessageOption{}
md, _ := descriptor.MessageDescriptorProto(msg)
srv := md.Service[1] // ServiceOption
stringOpt, _ := proto.GetExtension(srv.Options, examplepb.E_SrvOptString)
fmt.Println(" string:", *stringOpt.(*string))
}
func getMethodOptions() {
fmt.Println("method options:")
msg := &examplepb.MessageOption{}
md, _ := descriptor.MessageDescriptorProto(msg)
srv := md.Service[1] // ServiceOption
objOpt, _ := proto.GetExtension(srv.Method[0].Options, examplepb.E_MethodOptObj)
stringOpt, _ := proto.GetExtension(srv.Method[0].Options, examplepb.E_MethodOptString)
fmt.Println(" obj.foo_string:", objOpt.(*examplepb.ExtObj).FooString)
fmt.Println(" obj.bar_int", objOpt.(*examplepb.ExtObj).BarInt)
fmt.Println(" string:", *stringOpt.(*string))
}
复制代码
应用项目参考:github.com/grpc-ecosys…
grpc-gateway通过为rpc的method自定义option,来表达由grpc到http的转换关系,通过文件级别和service级别的option来控制生成swagger的行为。
参考
developers.google.cn/protocol-bu…
developers.google.cn/protocol-bu…
github.com/mwitkow/go-…
github.com/grpc-ecosys…
有疑问加站长微信联系(非本文作者)