(rpc系列)02 Protobuf?

带你速通protobuf...

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有些许不同:

  1. 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中被移除了。如果消息实例中没有设置某个字段,该字段将使用该类型的零值。

  2. option go_package 的具体语法为:

    1
    
    option go_package = "{out_path};out_go_package";
    
    • {out_path}指定生成文件的位置,它只是完整路径的一部分,和protoc的--go_out拼接之后才是完整的路径;
    • out_go_package指定生成 go文件的package。
  3. 在 XML 或 JSON 等数据描述语言中,一般通过成员的名字来绑定对应的数据;pb 编码则是通过成员的唯一编号来绑定对应的数据,编码后体积小,但也不便于人类查阅。最终生成的 Go 结构体可以自由采用 JSON 或 gob 等编码格式。

代码生成

我们可以借助pb核心工具集,将 pb文件 转化为go代码,其中的 message对应生成go结构体,就可以在我们自己的rpc服务中使用了。

pb 核心工具集是 C++ 开发的,官方的 protoc 编译器并不直接支持 Go ,因此我们还需要借助一个插件。具体步骤如下:

  1. 安装官方的 protoc 编译器;

  2. 安装 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 ""
}

重点关注这几个方法:

  1. Reset、String、ProtoMessage三个方法使得生成的Msg结构体实现了 protobuf-go/runtime 包中的 MessageV1 接口,以便满足 pb 运行时系统的需求;
  2. 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 插件技术后,你将彻底拥有这个技术。

Licensed under CC BY-NC-SA 4.0
我的玫瑰,种在繁星中的一颗~
Built with Hugo
主题 Stack 由 Jimmy 设计