Protobuf语法

Protocol Buffers(protobuf)是一种语言无关,平台无关,可扩展的用于序列化结构化数据的方式——类似XML,但比XML更灵活,更高效。

 

使用protobuf语法编写xxx.proto文件,然后将其编译成可供特定语言识别和使用的代码文件,供程序调用,这是protobuf的基本工作原理。

以Go语言为例,使用官方提供的编译器会将xxx.proto文件编译成xxx.pb.go文件——一个普通的go代码文件。
要使用protobuf,首先我们需要下载protobuf编译器——protoc,但Go语言并没有被编译器直接支持,而是通过插件的方式被编译器引用,所以同时我们还需要下载Go语言的编译插件:

go语言编译插件: 
go install google.golang.org/protobuf/cmd/protoc-gen-go

 

 

示例:

helloword.proto

syntax = "proto3";

option go_package
= "proto-demo/quick_start/pb"; // 当前文件所在包 package pb; message SearchRequest { string query = 1; int32 page = 2; int32 page_size = 3; } message SearchResponse { int32 code = 1; string msg = 2; } service Search { rpc user(SearchRequest) returns (SearchResponse); }

 

 

 

 

常用命令:

protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative helloworld/helloworld.proto



// 这里可以将--go_out=. 和 --go_opt=paths=source_relative 进行合并使用
protoc --go_opt=paths=source_relative:. --go-grpc_out=. --go-grpc_opt=paths=source_relative helloword/helloword.proto

--proto_path 或 -I: 指定锁编译源码的搜索路径

 

生成基于go语言的*.pb.go文件

  --go_out: go代码生成目录

  --go_opt:  import/source_relative

默认为 import,代表按照生成的 go代码的包的全路径去创建目录层级,
source_relative代表按照 proto 源文件的目录层级去创建 go 代码的目录层级,如果目录已存在则不用创建。

 

生成grpc文件

  --go-grpc_out:

  --go_grpc_opt:

 

 

protoc使用:

示例文件:

syntax = "proto3";

package greeter;

option go_package="proto1/greeter";

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
    string name = 1;
}
message HelloReply {
    string message = 1;
}

执行命令:

protoc --proto_path=. --go_out=. proto1/greeter/greeter.proto

 

$ protoc --help
Usage: protoc [OPTION] PROTO_FILES

  -IPATH, --proto_path=PATH   指定搜索路径
  --plugin=EXECUTABLE:
  
  ....
 
  --cpp_out=OUT_DIR           Generate C++ header and source.
  --csharp_out=OUT_DIR        Generate C# source file.
  --java_out=OUT_DIR          Generate Java source file.
  --js_out=OUT_DIR            Generate JavaScript source.
  --objc_out=OUT_DIR          Generate Objective C header and source.
  --php_out=OUT_DIR           Generate PHP source file.
  --python_out=OUT_DIR        Generate Python source file.
  --ruby_out=OUT_DIR          Generate Ruby source file
  
   @<filename>                proto文件的具体位置

1.搜索路径参数

第一个比较重要的参数就是搜索路径参数,即上述展示的-IPATH, --proto_path=PATH。它表示的是我们要在哪个路径下搜索.proto文件,这个参数既可以用-I指定,也可以使用--proto_path=指定。

如果不指定该参数,则默认在当前路径下进行搜索;另外,该参数也可以指定多次,这也意味着我们可以指定多个路径进行搜索。

比如下面这三条指令,表达的含义都是一样的


 

2.语言插件参数

语言参数即上述的--cpp_out=--python_out=等,protoc支持的语言长达13种,且都是比较常见的。像上面出现的语言参数,说明protoc本身已经内置该语言对应的编译插件,我们无需安装。

而如果上面没出现的,比如--go_out=,就得自己单独安装语言插件,比如--go_out=对应的是protoc-gen-go


 

 

3.proto文件位置参数

proto文件位置参数即上述的@<filename>参数,指定了我们proto文件的具体位置,如proto1/greeter/greeter.proto

 

 

proto文件(非protoc)有两个易混参数,即packagexx_package,xx指的是你的编译语言,比如你要编程成Go语言,对应的就是go_package

package

package参数针对的是protobuf,是proto文件的命名空间,它的作用是为了避免我们定义的接口,或者message出现冲突。


 

举一个小栗子,假设我有A.protoB.proto两份文件,如下

 
ini
复制代码
# A.proto
message UserInfo {
    uint32 uid = 1;
    string name = 2;
}

# B.proto
message UserInfo {
    uint32 uid = 1;
    string name = 2;
    uint32 age = 3;
    string work = 4;
}

如上,两份文件同时有一个UserInfo的message,这时候如果我需要在A文件引用B文件,如果没有指定package,就无法区分是要调A的UserInfo还是调B的。


xx_package

这里以go_package进行举例说明,该参数主要声明Go代码的存放位置,也可以说它解决的是包名问题(因为proto文件编译后会生成一份.pb.go文件,既然是go文件,就有包名问题)

.pb.go常规的存放路径一般是放在同名proto文件下,但也有些人不想这么做,比如他想把所有.pb.go文件都存放在一个特定文件夹下,比如上述的 pb_go,那么他有两种办法:

第一种:

 
shell
复制代码
# 修改 --go_out,go_package 保持不变
$ protoc --proto_path=. --go_out=./proto1/pb_go proto1/greeter/greeter.proto

这样生成的pb文件在 pb_go/proto1/greeter 目录下,文件目录有点冗余,不过pb文件的包名仍然是 greeter

第二种:

 
shell
复制代码
# 修改 go_package, go_out 保持不变
option go_package="proto1/pb_go";

$ protoc --proto_path=. --go_out=. proto1/greeter/greeter_v2.proto

这样生成的pb文件在 pb_go 目录下,pb文件的包名为 pb_go

另外,xx_package其实有两种声明方式,第一种是在文件中声明,你已经见过了;第二种是在命令行中声明,声明格式如下:M${PROTO_FILE}=${GO_IMPORT_PATH}

目前,官方文档比较推荐在文件中声明,目的是可以缩短protoc指令的长度,我个人也比较推荐这种方式。

好了,相信通过以上几个例子,你已经能大致弄懂packagexx_package的区别了,再次强调下,官方文档中已经说明,packagexx_package两者没有关联,属于不同范畴


--go_out详细解读

想必大家在使用的时候,应该遇到过这些写法:--go_out=paths=import:.--go_out=paths=source_relative:.,或者--go_out=plugins=grpc:.

这样写表达的是啥意思呢?

所以我们需要知道,--go_out参数是用来指定 protoc-gen-go 插件的工作方式和Go代码的生成位置,而上面的写法正是表明该插件的工作方式。

--go_out主要的两个参数为pluginspaths,分别表示生成Go代码所使用的插件,以及生成的Go代码的位置。--go_out的写法是,参数之间用逗号隔开,最后加上冒号来指定代码的生成位置,比如--go_out=plugins=grpc,paths=import:.

paths参数有两个选项,分别是 importsource_relative,默认为 import,表示按照生成的Go代码的包的全路径去创建目录层级,source_relative 表示按照 proto源文件的目录层级去创建Go代码的目录层级,如果目录已存在则不用创建。


 

 

 

 

 

 


 

 

protobuf文件语法解析

syntax = "proto3";
option go_package = "examplepb";  // 编译后的golang包名
package example.everything; // proto包名
...
在示例文件的起始位置会看到go_packagepackage两个关于包的声明,但这两个package表达的意义并不相同,
package example.everything;表明的是当前.proto文件所在的包名,跟Go语言类似,在相同的包名下,不能定义相同名称的messageenum或是service
option go_package = "examplepb" 则定义了一个文件级别的option,用于指定编译后的golang包名。

...
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

 

 

message:

// 普通的message
message SearchRequest {
    string query = 1;
    int32 page_number = 2;
    int32 result_per_page = 3;
}

message可以包含多个字段声明,每个字段声明需要包含字段类型,字段名称和一个唯一序号。

字段类型可以是标量,枚举或是其他message类型。

唯一序号用于标识该字段在消息二进制编码中位置。

 

枚举类型

...
// 枚举 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允许多层嵌套,messageenum都可以嵌套。被嵌套的messageenum不仅可以在当前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.protoany类型字段的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仅能作用于这些定义optionmessage——仅用于自定义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))
}

 


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))
}

 


 
 

 protoc-go-inject-tag

一般使用protoc生成的*.pb.go文件中结构体中protobuf中的tag数据是固定的, 使用该插件完成结构体的tag的自定义

github: https://github.com/favadi/protoc-go-inject-tag

 

 1.安装并加入环境变量

go install github.com/favadi/protoc-go-inject-tag@latest

 

2.在proto文件中加入注释信息

test.proto

// file: test.proto
syntax = "proto3";

package pb;
option go_package = "/pb";

message IP {
  // @gotags: valid:"ip"
  string Address = 1;

  // Or:
  string MAC = 2; // @gotags: validate:"omitempty"
}

使用protoc生成test.pb.go文件

protoc --proto_path=. --go_out=paths=source_relative:. test.proto

3.替换test.pb.go文件中的内容

$ protoc-go-inject-tag -input=./test.pb.go
# or
$ protoc-go-inject-tag -input="*.pb.go"

注: 在v1.3.0版本之后, 已经不推荐使用@inject_tags了, 使用最新提供的@gotags

 

 

 

 

 

 

 

 

posted @ 2023-10-11 09:24  X-Wolf  阅读(176)  评论(0编辑  收藏  举报