Protobuf
Protobuf
(Protocol Buffers),是 Google 开发的一种数据描述语言
,于 2008 年对外开源。
protobuf的使用
Protobuf(如无特殊说明,下称pb
) 刚开源时的定位类似于 XML
、JSON
等数据描述语言
,通过附带工具生成代码
并实现将结构化数据序列化
的功能。时至今日,我们更关注的是 pb 作为接口规范
的描述语言,可以作为设计安全的跨语言 PRC 接口的基础工具。
规范传输消息
如果你还没有用过 pb ,可以先从官网了解下基本语法。本小节我们尝试将 pb 和 RPC 结合在一起使用,通过pb来规范接口传输的消息
。
message定义
pb 中最基本的数据单元是 message
,message 中还可以嵌套
message 或其它的基础数据类型
的成员。来看一个栗子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// hello.proto
// 采用 proto3 的语法
syntax = "proto3";
option go_package="./;hello";
// 指明当前是 hello 包 这样可以和 Go 的包名保持一致,简化例子代码
// 用户也可以针对不同的语言定制对应的包路径和名称
package hello;
// HelloService 服务用到的参数类型
// 最终生成的 Go 语言代码中对应一个 Msg 结构体
message Msg {
// msg 编码时用 1 编号代替名字
string msg = 1;
}
|
如果你之前使用过或者了解过pb,会发现proto3与proto2有些许不同:
-
proto3
进行了提炼简化,不再直接支持自定义
默认值,所有成员均采用类似 Go 中的零值
初始化。
换句话说,所有 message 成员都是可选的
,不需要、也不再支持 required 特性
;
一个proto2的例子:
1
2
3
4
5
6
7
|
syntax = "proto2";
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
}
|
required
关键字用来声明某个字段是必需
的。如果你创建一个 Person
消息实例,但没有设置 name
或 id
字段,编译器将会报错。
这引入了一些复杂性,所以在proto3中被移除了。如果消息实例中没有设置某个字段,该字段将使用该类型的零值
。
-
option go_package 的具体语法为:
1
|
option go_package = "{out_path};out_go_package";
|
{out_path}
指定生成文件的位置
,它只是完整路径的一部分,和protoc
的--go_out
拼接之后才是完整的路径;
out_go_package
指定生成 go文件的package
。
-
在 XML
或 JSON
等数据描述语言中,一般通过成员的名字
来绑定对应的数据;pb 编码则是通过成员的唯一编号
来绑定对应的数据,编码后体积小
,但也不便于人类查阅
。最终生成的 Go 结构体可以自由采用 JSON
或 gob
等编码格式。
代码生成
我们可以借助pb核心工具集
,将 pb文件 转化为go代码,其中的 message
对应生成go结构体,就可以在我们自己的rpc服务中使用了。
pb 核心工具集是 C++
开发的,官方的 protoc 编译器
并不直接支持 Go ,因此我们还需要借助一个插件。具体步骤如下:
-
安装官方的 protoc 编译器;
-
安装 Go 代码生成插件:
1
|
go install github.com/golang/protobuf/protoc-gen-go
|
确保上面两个工具/插件的安装目录已经添加进了环境变量
,然后通过以下命令生成相应的 Go 代码:
1
|
$ protoc --go_out=. hello.proto
|
其中 go_out
参数告知 protoc 编译器去加载对应的 protoc-gen-go
插件,通过该插件生成代码并放入当前目录。最后是一系列要处理的 protobuf 文件的列表。
执行之后会生成一个 hello.pb.go 文件,其中 Msg结构体
内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
type Msg struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
msg string `protobuf:"bytes,1,opt,name=obj,proto3" json:"obj,omitempty"`
}
...
func (x *Msg) Reset() {...}
func (x *Msg) String() string {...}
func (*Msg) ProtoMessage() {}
...
func (x *Msg) GetMsg() string {
if x != nil {
return x.Msg
}
return ""
}
|
重点关注
这几个方法:
Reset
、String
、ProtoMessage
三个方法使得生成的Msg
结构体实现了 protobuf-go/runtime 包中的 MessageV1
接口,以便满足 pb 运行时系统的需求;
- pb 还为
msg
生成了一组 Get 方法
用以获取成员变量
,该方法不仅可以处理空指针类型,还和 proto2 的方法保持一致(proto2 自定义默认值特性依赖这类方法)。
看,多亏pb
,我们省去了手动创建入参
的过程,可以直接利用代码生成的 Msg
类型,重新实现 HelloService 服务:
1
2
3
4
5
6
|
type HelloService struct{}
func (p *HelloService) Hello(request *Msg, reply *Msg) error {
reply.msg = "hello:" + request.GetMsg()
return nil
}
|
和客户端对接的时候也方便许多,双方同步 pb文件
即可。后续协议有更新,也只需再次同步pb文件
、protoc重新生成代码
。
至此,我们初步实现了 pb
和 RPC
组合工作,使用pb定义message
、代码生成结构体
的方式,代替
手动去定义的消息类型。
启动 RPC 服务
时,依然可以选择默认的 gob 或手工指定 json 编码,甚至可以重新基于 pb 编码实现一个插件。
规范服务接口
服务接口定义
在上一小节中,我们使用pb
来定义接口传输的消息,引入的工具不少,但仅仅为了规范消息
,似乎收益有限
。
回顾第一篇中更安全的 RPC 接口部分的内容,当时我们通过定义接口规范
去给 RPC 服务增加安全的保障,这个过程本身就非常繁琐的使用手工维护
,同时全部安全相关的代码只适用于 Go 语言环境!
我们自然会想, RPC 服务接口
是否也可以通过 pb 定义呢?
答案是肯定的!并且定义语言无关
的 RPC 服务接口,才是pb真正的价值所在!
来看栗子:
1
2
3
|
service HelloService {
rpc Hello (String) returns (String);
}
|
重新执行脚本生成代码:
1
|
protoc --go_out=. hello.proto
|
奇怪?重新生成的 Go 代码并没有发生变化。试想一下,世界上的 RPC 实现有千万种,protoc 编译器并不知道该如何为 HelloService 服务生成代码。
不过可以安装protoc-gen-go-grpc
插件,来针对 gRPC 生成代码:
1
2
3
4
5
|
// 安装 protoc-gen-go-grpc
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc
// 编译 proto 文件
$ protoc --go-grpc_out=:. hello.proto
|
老版本的grpc
的插件是集成在 protoc-gen-go 内部的 ,可以通过如下命令来使用:
1
|
$ protoc --go_out=plugins=grpc:. hello.proto
|
在生成的代码中多了一些类似 HelloServiceServer、HelloServiceClient 的新类型。这些类型是为 gRPC 服务的,并不符合我们的 RPC 要求。
不过 gRPC 插件为我们提供了改进的思路,我们是不是也可以定制代码生成插件
,为我们的 RPC 接口生成安全的代码呢?
代码生成
protoc 编译器
通过插件机制
实现对不同语言的支持。
比如 protoc 命令出现 --xxx_out
格式的参数,那么 protoc 将首先查询是否有内置的 xxx 插件,如果没有内置的 xxx 插件那么将继续查询当前系统中是否存在 protoc-gen-xxx 命名
的可执行程序,最终通过查询到的插件生成代码。
对于 Go 语言的 protoc-gen-go 插件
来说,又实现了一层静态插件系统。比如老版 protoc-gen-go 内置了一个 gRPC 插件,支持通过 --go_out=plugins=grpc
参数来生成 gRPC 相关代码,否则只会针对 message 生成相关代码。
参考 gRPC 插件的代码,可以发现 generator.RegisterPlugin 函数可以用来注册插件。插件是一个 generator.Plugin 接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// A Plugin provides functionality to add to the output during
// Go code generation, such as to produce RPC stubs.
type Plugin interface {
// Name identifies the plugin.
Name() string
// Init is called once after data structures are built but before
// code generation begins.
Init(g *Generator)
// Generate produces the code generated by the plugin for this file,
// except for the imports, by calling the generator's methods P, In,
// and Out.
Generate(file *FileDescriptor)
// GenerateImports produces the import declarations for this file.
// It is called after Generate.
GenerateImports(file *FileDescriptor)
}
|
其中 Name 方法
返回插件的名字,这是 Go 语言的 pb 实现的插件体系,和 protoc 插件的名字并无关系。然后 Init 函数
是通过 g 参数对插件进行初始化,g 参数中包含 Proto 文件的所有信息。最后的 Generate 和 GenerateImports 方法用于生成主体代码和对应的导入包代码。
因此我们可以设计一个 netrpcPlugin 插件,用于为标准库的 RPC 框架生成代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import (
"github.com/golang/protobuf/protoc-gen-go/generator"
)
type netrpcPlugin struct{*generator.Generator}
func (p *netrpcPlugin) Name() string { return "netrpc" }
func (p *netrpcPlugin) Init(g *generator.Generator) { p.Generator = g }
func (p *netrpcPlugin) GenerateImports(file *generator.FileDescriptor) {
if len(file.Service) > 0 {
p.genImportCode(file)
}
}
func (p *netrpcPlugin) Generate(file *generator.FileDescriptor) {
for _, svc := range file.Service {
p.genServiceCode(svc)
}
}
|
首先 Name 方法返回插件的名字。netrpcPlugin 插件内置了一个匿名的 *generator.Generator
成员,然后在 Init 初始化的时候用参数 g 进行初始化,因此插件是从 g 参数对象继承了全部的公有方法。其中 GenerateImports 方法调用自定义的 genImportCode 函数生成导入代码。Generate 方法调用自定义的 genServiceCode 方法生成每个服务的代码。
目前,自定义的 genImportCode 和 genServiceCode 方法只是输出一行简单的注释:
1
2
3
4
5
6
7
|
func (p *netrpcPlugin) genImportCode(file *generator.FileDescriptor) {
p.P("// TODO: import code")
}
func (p *netrpcPlugin) genServiceCode(svc *descriptor.ServiceDescriptorProto) {
p.P("// TODO: service code, Name =" + svc.GetName())
}
|
要使用该插件需要先通过 generator.RegisterPlugin 函数注册插件,可以在 init 函数中完成:
1
2
3
|
func init() {
generator.RegisterPlugin(new(netrpcPlugin))
}
|
因为 Go 语言的包只能静态导入,我们无法向已经安装的 protoc-gen-go 添加我们新编写的插件。我们将重新克隆 protoc-gen-go 对应的 main 函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
package main
import (
"io/ioutil"
"os"
"github.com/golang/protobuf/proto"
"github.com/golang/protobuf/protoc-gen-go/generator"
)
func main() {
g := generator.New()
data, err := ioutil.ReadAll(os.Stdin)
if err != nil {
g.Error(err, "reading input")
}
if err := proto.Unmarshal(data, g.Request); err != nil {
g.Error(err, "parsing input proto")
}
if len(g.Request.FileToGenerate) == 0 {
g.Fail("no files to generate")
}
g.CommandLineParameters(g.Request.GetParameter())
// Create a wrapped version of the Descriptors and EnumDescriptors that
// point to the file that defines them.
g.WrapTypes()
g.SetPackageNames()
g.BuildTypeNameMap()
g.GenerateAllFiles()
// Send back the results.
data, err = proto.Marshal(g.Response)
if err != nil {
g.Error(err, "failed to marshal output proto")
}
_, err = os.Stdout.Write(data)
if err != nil {
g.Error(err, "failed to write output proto")
}
}
|
为了避免对 protoc-gen-go 插件造成干扰,我们将我们的可执行程序命名为 protoc-gen-go-netrpc,表示包含了 netrpc 插件。然后用以下命令重新编译 hello.proto 文件:
1
|
$ protoc --go-netrpc_out=plugins=netrpc:. hello.proto
|
其中 --go-netrpc_out
参数告知 protoc 编译器加载名为 protoc-gen-go-netrpc 的插件,插件中的 plugins=netrpc
指示启用内部唯一的名为 netrpc 的 netrpcPlugin 插件。在新生成的 hello.pb.go 文件中将包含增加的注释代码。
至此,手工定制的 pb 代码生成插件终于可以工作了。
自动生成完整的 RPC 代码
在前面的例子中我们已经构建了最小化的 netrpcPlugin 插件,并且通过克隆 protoc-gen-go 的主程序创建了新的 protoc-gen-go-netrpc 的插件程序。现在开始继续完善 netrpcPlugin 插件,最终目标是生成 RPC 安全接口。
首先是自定义的 genImportCode 方法中生成导入包的代码:
1
2
3
|
func (p *netrpcPlugin) genImportCode(file *generator.FileDescriptor) {
p.P(`import "net/rpc"`)
}
|
然后要在自定义的 genServiceCode 方法中为每个服务生成相关的代码。分析可以发现每个服务最重要的是服务的名字,然后每个服务有一组方法。而对于服务定义的方法,最重要的是方法的名字,还有输入参数和输出参数类型的名字。
为此我们定义了一个 ServiceSpec 类型,用于描述服务的元信息:
1
2
3
4
5
6
7
8
9
10
|
type ServiceSpec struct {
ServiceName string
MethodList []ServiceMethodSpec
}
type ServiceMethodSpec struct {
MethodName string
InputTypeName string
OutputTypeName string
}
|
然后我们新建一个 buildServiceSpec 方法用来解析每个服务的 ServiceSpec 元信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
func (p *netrpcPlugin) buildServiceSpec(
svc *descriptor.ServiceDescriptorProto,
) *ServiceSpec {
spec := &ServiceSpec{
ServiceName: generator.CamelCase(svc.GetName()),
}
for _, m := range svc.Method {
spec.MethodList = append(spec.MethodList, ServiceMethodSpec{
MethodName: generator.CamelCase(m.GetName()),
InputTypeName: p.TypeName(p.ObjectNamed(m.GetInputType())),
OutputTypeName: p.TypeName(p.ObjectNamed(m.GetOutputType())),
})
}
return spec
}
|
其中输入参数是 *descriptor.ServiceDescriptorProto
类型,完整描述了一个服务的所有信息。然后通过 svc.GetName()
就可以获取 pb 文件中定义的服务的名字。pb 文件中的名字转为 Go 语言的名字后,需要通过 generator.CamelCase
函数进行一次转换。类似的,在 for 循环中我们通过 m.GetName()
获取方法的名字,然后再转为 Go 语言中对应的名字。比较复杂的是对输入和输出参数名字的解析:首先需要通过 m.GetInputType()
获取输入参数的类型,然后通过 p.ObjectNamed
获取类型对应的类对象信息,最后获取类对象的名字。
然后我们就可以基于 buildServiceSpec 方法构造的服务的元信息生成服务的代码:
1
2
3
4
5
6
7
8
9
10
11
12
|
func (p *netrpcPlugin) genServiceCode(svc *descriptor.ServiceDescriptorProto) {
spec := p.buildServiceSpec(svc)
var buf bytes.Buffer
t := template.Must(template.New("").Parse(tmplService))
err := t.Execute(&buf, spec)
if err != nil {
log.Fatal(err)
}
p.P(buf.String())
}
|
为了便于维护,我们基于 Go 语言的模板来生成服务代码,其中 tmplService 是服务的模板。
在编写模板之前,我们先查看下我们期望生成的最终代码大概是什么样子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
type HelloServiceInterface interface {
Hello(in String, out *String) error
}
func RegisterHelloService(srv *rpc.Server, x HelloService) error {
if err := srv.RegisterName("HelloService", x); err != nil {
return err
}
return nil
}
type HelloServiceClient struct {
*rpc.Client
}
var _ HelloServiceInterface = (*HelloServiceClient)(nil)
func DialHelloService(network, address string) (*HelloServiceClient, error) {
c, err := rpc.Dial(network, address)
if err != nil {
return nil, err
}
return &HelloServiceClient{Client: c}, nil
}
func (p *HelloServiceClient) Hello(in String, out *String) error {
return p.Client.Call("HelloService.Hello", in, out)
}
|
其中 HelloService 是服务名字,同时还有一系列的方法相关的名字。
参考最终要生成的代码可以构建如下模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
const tmplService = `
{{$root := .}}
type {{.ServiceName}}Interface interface {
{{- range $_, $m := .MethodList}}
{{$m.MethodName}}(*{{$m.InputTypeName}}, *{{$m.OutputTypeName}}) error
{{- end}}
}
func Register{{.ServiceName}}(
srv *rpc.Server, x {{.ServiceName}}Interface,
) error {
if err := srv.RegisterName("{{.ServiceName}}", x); err != nil {
return err
}
return nil
}
type {{.ServiceName}}Client struct {
*rpc.Client
}
var _ {{.ServiceName}}Interface = (*{{.ServiceName}}Client)(nil)
func Dial{{.ServiceName}}(network, address string) (
*{{.ServiceName}}Client, error,
) {
c, err := rpc.Dial(network, address)
if err != nil {
return nil, err
}
return &{{.ServiceName}}Client{Client: c}, nil
}
{{range $_, $m := .MethodList}}
func (p *{{$root.ServiceName}}Client) {{$m.MethodName}}(
in *{{$m.InputTypeName}}, out *{{$m.OutputTypeName}},
) error {
return p.Client.Call("{{$root.ServiceName}}.{{$m.MethodName}}", in, out)
}
{{end}}
`
|
当 pb 的插件定制工作完成后,每次 hello.proto 文件中 RPC 服务的变化都可以自动生成代码。也可以通过更新插件的模板,调整或增加生成代码的内容。在掌握了定制 pb 插件技术后,你将彻底拥有这个技术。