RPC实践 EP2: Protobuf与 它的插件系统。

 

上一篇文章中,我用net/rpc 包实现了一个简单的 RPC接口,并且尝试了net/rpc自带的Gob编码以及JSON编码,学习了Golang RPC 的一些基本知识。本篇文章,我会将net/rpc 跟 protobuf结合起来,然后在了解一下如何使用Protobuf + 插件 生成 gRPC 的代码。 最后会尝试创建一个自己的protobuf插件来帮助我们生成代码,开始吧。

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

我们在工作过程中一定使用过gRPC + protobuf ,但是它们两个并不是绑定关系,gRPC可以使用JSON编码,protobuf也可以在其他语言中实现。

Protocol Buffers 是谷歌推出的编码标准,它在传输效率和编解码性能上都要优于 JSON。但其代价则是需要依赖中间描述语言(IDL)来定义数据和服务的结构( *.proto 文件),并且需要一整套的工具链(protoc 及其插件)来生成对应的序列化和反序列化代码。

除了谷歌官方提供的工具和插件(比如生成 go 代码的 protoc-gen-go)外,我们还可以开发或定制自己的插件,根据业务需要按照 proto 文件的定义生成代码或者文档。由 IDL 生成代码或者文档是元编程的一种形式,可以极大的解放程序员的生产力。

一个使用protobuf 的例子

首先我们写一个proto 文件 hello-service.proto,定义一个message “String“

1
2
3
4
5
6
7
syntax = "proto3";
package api;
option  go_package="api";

message String {
  string value = 1;
}

然后使用protoc 工具生成message String 的 Go代码

1
protoc --go_out=./api proto/hello.proto 

生成的代码 里面有一个 String struct

1
2
3
4
5
6
type String struct {
	Value                string   `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
	XXX_NoUnkeyedLiteral struct{} `json:"-"`
	XXX_unrecognized     []byte   `json:"-"`
	XXX_sizecache        int32    `json:"-"`
}

我们可以直接使用它,修改一下函数的参数。

1
2
3
type HelloServiceInterface = interface {  
    Hello(request api.String, reply *api.String) error  
}  

其实使用起来跟以前没什么区别。甚至还不如直接使用string方便。
那么我们为什么要使用Protobuf?
正如前面所说的,用Protobuf定义与语言无关的RPC服务接口以及message,然后使用protoc工具生成不同语言的代码,才是它真正的价值所在

protoc 的插件系统

protobuf是一个与编程语言无关的协议。它通过protoc跟各种不同的插件来将protobuf文件编译成不同的编程语言,我们以Golang+gRPC 为例子。

1
protoc --go_out=plugins=grpc. hello-service.proto

这里有一个 --go_out 参数。因为我们调用的插件是protoc-gen-go,所以参数名字叫 go_out;如果名字叫 XXX,那参数名字就叫 XXX_out。

protoc 在运行的时候首先会解析 proto 文件的所有内容,生成一组 Protocol Buffers 编码的描述数据,首先会判断protoc 内部是否包含go插件, 然后会尝试在$PATH 里面寻找protoc-gen-go,找不到会报错,然后运行protoc-gen-go 命令,并且通过 stdin 将描述数据发送给插件命令。插件生成好文件内容后再向 stdout 输入 Protocol Buffers 编码的数据来告诉 protoc 生成具体的文件。

plugins=grpc 是 为了调用protoc-gen-go 自带的一个插件,如果不使用它,那么只会生成 Go 语言 的message信息,使用这个插件才会生成grpc 相关的代码。

自定义一个 protoc 插件

如果在protobuf 中添加Hello 接口的定时,我们是不是可以自定义一个 protoc 插件,直接生成代码?

1
2
3
4
5
6
7
8
9
syntax = "proto3";  
package api;  
option  go_package="./api";  
service HelloService {  
  rpc Hello (String) returns (String) {}  
}  
message String {  
  string value = 1;
}

目标

这篇文章,我的目标是创建一个插件,然后用来生成RPC 的服务端与客户端代码,生成的代码大致是这样的。

 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
// HelloService_rpc.pb.go
type HelloServiceInterface interface {  
    Hello(String, *String) error  
}  
  
func RegisterHelloService(  
    srv *rpc.Server, x HelloServiceInterface,  
) 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)  
}

这样我们的业务代码就能改成下面这个样子。

 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
// service
func main() {  
    listener, err := net.Listen("tcp", ":1234")  
    if err != nil {  
       log.Fatal("ListenTCP error:", err)  
    }  
    _ = api.RegisterHelloService(rpc.DefaultServer, new(HelloService))  
    for {  
       conn, err := listener.Accept()  
       if err != nil {  
          log.Fatal("Accept error:", err)  
       }  
       go rpc.ServeConn(conn)  
    }  
}  
  
type HelloService struct{}  
  
func (p *HelloService) Hello(request api.String, reply *api.String) error {  
    log.Println("HelloService.proto Hello")  
    *reply = api.String{Value: "Hello:" + request.Value}  
    return nil  
}
// client.go
func main() {  
    client, err := api.DialHelloService("tcp", "localhost:1234")  
    if err != nil {  
       log.Fatal("net.Dial:", err)  
    }  
    reply := &api.String{}  
    err = client.Hello(api.String{Value: "Hello"}, reply)  
    if err != nil {  
       log.Fatal(err)  
    }  
    log.Println(reply)  
}

基于生成的代码,我们的工作量已经小了很多,并且出错的几率已经很小了。一个不错的开始。
根据上面的api代码,我们可以抽出来一个模板文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const tmplService = `  
package {{.PackageName}}  
import (  
    "net/rpc")  
{{$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}}  
`

整个模板很清晰,里面有一些占位符,比如 MethodName,ServiceName 等,我们后面会介绍。

如何开发 一个插件?

谷歌发布了 Go 语言 API1,其中引入了一个新包 google.golang.org/protobuf/compiler/protogen ,极大的降低了plugins 开发难度:

  1. 首先我们创建一个go 语言工程,比如protoc-gen-go-spprpc
  2. 然后我们需要定义一个protogen.Options,然后调用它的Run方法,并传入一个 func(*protogen.Plugin) error回调。主流程代码到此就结束了。
  3. 我们还可以设置protogen.OptionsParamFunc参数,这样 protogen 会自动为我们解析命令行传入的参数。诸如从标准输入读取并解码 protobuf 信息,将输入信息编码成 protobuf 写入 stdout 等操作全部由 protogen 包办了。我们要做的就是与 protogen.Plugin 交互实现代码生成逻辑。

每个服务最重要的是服务的名字,然后每个服务有一组方法。而对于服务定义的方法,最重要的是方法的名字,还有输入参数和输出参数类型的名字。我们首先定义一个ServiceData,用于描述服务的元信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ServiceData 结构体定义  
type ServiceData struct {  
    PackageName string  
    ServiceName string  
    MethodList  []Method  
}
// Method 结构体定义  
type Method struct {  
    MethodName     string  
    InputTypeName  string  
    OutputTypeName string  
}

然后就是主逻辑,以及代码生成逻辑,最后调用tmpl生成代码。

 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
func main() {  
    protogen.Options{}.Run(func(gen *protogen.Plugin) error {  
       for _, file := range gen.Files {  
          if !file.Generate {  
             continue  
          }  
          generateFile(gen, file)  
       }  
       return nil  
    })  
}  
  
// generateFile 函数定义  
func generateFile(gen *protogen.Plugin, file *protogen.File) {  
    filename := file.GeneratedFilenamePrefix + "_rpc.pb.go"  
    g := gen.NewGeneratedFile(filename, file.GoImportPath)  
    tmpl, err := template.New("service").Parse(tmplService)  
    if err != nil {  
       log.Fatalf("Error parsing template: %v", err)  
    }  
    packageName := string(file.GoPackageName)  
    // 遍历每个服务生成代码  
    for _, service := range file.Services {  
       serviceData := ServiceData{  
          ServiceName: service.GoName,  
          PackageName: packageName,  
       }  
       for _, method := range service.Methods {  
          inputType := method.Input.GoIdent.GoName  
          outputType := method.Output.GoIdent.GoName  
  
          serviceData.MethodList = append(serviceData.MethodList, Method{  
             MethodName:     method.GoName,  
             InputTypeName:  inputType,  
             OutputTypeName: outputType,  
          })  
       }  
       // 执行模板渲染  
       err = tmpl.Execute(g, serviceData)  
       if err != nil {  
          log.Fatalf("Error executing template: %v", err)  
       }  
    }  
}

调试插件

最后我们将编译后的 二进制执行文件protoc-gen-go-spprpc,放在$PATH 里面, 然后运行protoc 就能生成我们想要的代码了。

1
protoc --go_out=.. --go-spprpc_out=.. HelloService.proto

因为protoc-gen-go-spprpc 必须依赖 protoc 才能运行,所以调试起来比较麻烦。我们可以使用
fmt.Fprintf(os.Stderr, "Fprintln: %v\n", err) 打印错误日志的方式调试。

总结

以上就是本文的全部内容了。我们首先使用protobuf 实现了一个rpc call,然后创建了一个protobuf 插件来帮助我们生成代码。为我们打开了一扇学习protobuf + RPC 的大门,也是我们通往彻底理解gRPC的路。希望大家都能掌握这个技术。

参考文档

  1. https://taoshu.in/go/create-protoc-plugin.html
  2. https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-02-pb-intro.html
Licensed under CC BY-NC-SA 4.0
最后更新于 Aug 13, 2024 15:59 CST
使用 Hugo 构建
主题 StackJimmy 设计