(rpc系列)02 Protobuf?

带你速通protobuf...

Protobuf

Protobuf (Protocol Buffers),是 Google 开发的一种数据描述语言,于 2008 年对外开源。

protobuf的使用

Protobuf(如无特殊说明,下称pb) 刚开源时的定位类似于 XMLJSON数据描述语言,通过附带工具生成代码并实现将结构化数据序列化的功能。时至今日,我们更关注的是 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 消息实例,但没有设置 nameid 字段,编译器将会报错。

    这引入了一些复杂性,所以在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. XMLJSON 等数据描述语言中,一般通过成员的名字来绑定对应的数据;pb 编码则是通过成员的唯一编号来绑定对应的数据,编码后体积小,但也不便于人类查阅。最终生成的 Go 结构体可以自由采用 JSONgob 等编码格式。

代码生成

我们可以借助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. ResetStringProtoMessage三个方法使得生成的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
主题 StackJimmy 设计