在上一篇文章中,我用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 开发难度:
- 首先我们创建一个go 语言工程,比如
protoc-gen-go-spprpc
- 然后我们需要定义一个
protogen.Options
,然后调用它的Run
方法,并传入一个 func(*protogen.Plugin) error
回调。主流程代码到此就结束了。
- 我们还可以设置
protogen.Options
的ParamFunc
参数,这样 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的路。希望大家都能掌握这个技术。
参考文档
- https://taoshu.in/go/create-protoc-plugin.html
- https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-02-pb-intro.html