mirror of
https://github.com/chai2010/advanced-go-programming-book.git
synced 2025-05-24 04:22:22 +00:00
389 lines
16 KiB
Markdown
389 lines
16 KiB
Markdown
# 4.2 Protobuf
|
||
|
||
Protobuf 是 Protocol Buffers 的简称,它是 Google 公司开发的一种数据描述语言,并于 2008 年对外开源。Protobuf 刚开源时的定位类似于 XML、JSON 等数据描述语言,通过附带工具生成代码并实现将结构化数据序列化的功能。但是我们更关注的是 Protobuf 作为接口规范的描述语言,可以作为设计安全的跨语言 PRC 接口的基础工具。
|
||
|
||
## 4.2.1 Protobuf 入门
|
||
|
||
对于没有用过 Protobuf 的读者,建议先从官网了解下基本用法。这里我们尝试将 Protobuf 和 RPC 结合在一起使用,通过 Protobuf 来最终保证 RPC 的接口规范和安全。Protobuf 中最基本的数据单元是 message,是类似 Go 语言中结构体的存在。在 message 中可以嵌套 message 或其它的基础数据类型的成员。
|
||
|
||
首先创建 hello.proto 文件,其中包装 HelloService 服务中用到的字符串类型:
|
||
|
||
```protobuf
|
||
syntax = "proto3";
|
||
|
||
package main;
|
||
|
||
message String {
|
||
string value = 1;
|
||
}
|
||
```
|
||
|
||
开头的 syntax 语句表示采用 proto3 的语法。第三版的 Protobuf 对语言进行了提炼简化,所有成员均采用类似 Go 语言中的零值初始化(不再支持自定义默认值),因此消息成员也不再需要支持 required 特性。然后 package 指令指明当前是 main 包(这样可以和 Go 的包名保持一致,简化例子代码),当然用户也可以针对不同的语言定制对应的包路径和名称。最后 message 关键字定义一个新的 String 类型,在最终生成的 Go 语言代码中对应一个 String 结构体。String 类型中只有一个字符串类型的 value 成员,该成员编码时用 1 编号代替名字。
|
||
|
||
在 XML 或 JSON 等数据描述语言中,一般通过成员的名字来绑定对应的数据。但是 Protobuf 编码却是通过成员的唯一编号来绑定对应的数据,因此 Protobuf 编码后数据的体积会比较小,但是也非常不便于人类查阅。我们目前并不关注 Protobuf 的编码技术,最终生成的 Go 结构体可以自由采用 JSON 或 gob 等编码格式,因此大家可以暂时忽略 Protobuf 的成员编码部分。
|
||
|
||
Protobuf 核心的工具集是 C++ 语言开发的,在官方的 protoc 编译器中并不支持 Go 语言。要想基于上面的 hello.proto 文件生成相应的 Go 代码,需要安装相应的插件。首先是安装官方的 protoc 工具,可以从 https://github.com/google/protobuf/releases 下载。然后是安装针对 Go 语言的代码生成插件,可以通过 `go get github.com/golang/protobuf/protoc-gen-go` 命令安装。
|
||
|
||
然后通过以下命令生成相应的 Go 代码:
|
||
|
||
```
|
||
$ protoc --go_out=. hello.proto
|
||
```
|
||
|
||
其中 `go_out` 参数告知 protoc 编译器去加载对应的 protoc-gen-go 工具,然后通过该工具生成代码,生成代码放到当前目录。最后是一系列要处理的 protobuf 文件的列表。
|
||
|
||
这里只生成了一个 hello.pb.go 文件,其中 String 结构体内容如下:
|
||
|
||
```go
|
||
type String struct {
|
||
Value string `protobuf:"bytes,1,opt,name=value" json:"value,omitempty"`
|
||
}
|
||
|
||
func (m *String) Reset() { *m = String{} }
|
||
func (m *String) String() string { return proto.CompactTextString(m) }
|
||
func (*String) ProtoMessage() {}
|
||
func (*String) Descriptor() ([]byte, []int) {
|
||
return fileDescriptor_hello_069698f99dd8f029, []int{0}
|
||
}
|
||
|
||
func (m *String) GetValue() string {
|
||
if m != nil {
|
||
return m.Value
|
||
}
|
||
return ""
|
||
}
|
||
```
|
||
|
||
生成的结构体中还会包含一些以 `XXX_` 为名字前缀的成员,我们已经隐藏了这些成员。同时 String 类型还自动生成了一组方法,其中 ProtoMessage 方法表示这是一个实现了 proto.Message 接口的方法。此外 Protobuf 还为每个成员生成了一个 Get 方法,Get 方法不仅可以处理空指针类型,而且可以和 Protobuf 第二版的方法保持一致(第二版的自定义默认值特性依赖这类方法)。
|
||
|
||
基于新的 String 类型,我们可以重新实现 HelloService 服务:
|
||
|
||
```go
|
||
type HelloService struct{}
|
||
|
||
func (p *HelloService) Hello(request *String, reply *String) error {
|
||
reply.Value = "hello:" + request.GetValue()
|
||
return nil
|
||
}
|
||
```
|
||
|
||
其中 Hello 方法的输入参数和输出的参数均改用 Protobuf 定义的 String 类型表示。因为新的输入参数为结构体类型,因此改用指针类型作为输入参数,函数的内部代码同时也做了相应的调整。
|
||
|
||
至此,我们初步实现了 Protobuf 和 RPC 组合工作。在启动 RPC 服务时,我们依然可以选择默认的 gob 或手工指定 json 编码,甚至可以重新基于 protobuf 编码实现一个插件。虽然做了这么多工作,但是似乎并没有看到什么收益!
|
||
|
||
回顾第一章中更安全的 RPC 接口部分的内容,当时我们花费了极大的力气去给 RPC 服务增加安全的保障。最终得到的更安全的 RPC 接口的代码本身就非常繁琐的使用手工维护,同时全部安全相关的代码只适用于 Go 语言环境!既然使用了 Protobuf 定义的输入和输出参数,那么 RPC 服务接口是否也可以通过 Protobuf 定义呢?其实用 Protobuf 定义语言无关的 RPC 服务接口才是它真正的价值所在!
|
||
|
||
下面更新 hello.proto 文件,通过 Protobuf 来定义 HelloService 服务:
|
||
|
||
```protobuf
|
||
service HelloService {
|
||
rpc Hello (String) returns (String);
|
||
}
|
||
```
|
||
|
||
但是重新生成的 Go 代码并没有发生变化。这是因为世界上的 RPC 实现有千万种,protoc 编译器并不知道该如何为 HelloService 服务生成代码。
|
||
|
||
不过在 protoc-gen-go 内部已经集成了一个名字为 `grpc` 的插件,可以针对 gRPC 生成代码:
|
||
|
||
```
|
||
$ protoc --go_out=plugins=grpc:. hello.proto
|
||
```
|
||
|
||
在生成的代码中多了一些类似 HelloServiceServer、HelloServiceClient 的新类型。这些类型是为 gRPC 服务的,并不符合我们的 RPC 要求。
|
||
|
||
不过 gRPC 插件为我们提供了改进的思路,下面我们将探索如何为我们的 RPC 生成安全的代码。
|
||
|
||
|
||
## 4.2.2 定制代码生成插件
|
||
|
||
Protobuf 的 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 接口:
|
||
|
||
```go
|
||
// 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 语言的 Protobuf 实现的插件体系,和 protoc 插件的名字并无关系。然后 Init 函数是通过 g 参数对插件进行初始化,g 参数中包含 Proto 文件的所有信息。最后的 Generate 和 GenerateImports 方法用于生成主体代码和对应的导入包代码。
|
||
|
||
因此我们可以设计一个 netrpcPlugin 插件,用于为标准库的 RPC 框架生成代码:
|
||
|
||
```go
|
||
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 方法只是输出一行简单的注释:
|
||
|
||
```go
|
||
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 函数中完成:
|
||
|
||
```go
|
||
func init() {
|
||
generator.RegisterPlugin(new(netrpcPlugin))
|
||
}
|
||
```
|
||
|
||
因为 Go 语言的包只能静态导入,我们无法向已经安装的 protoc-gen-go 添加我们新编写的插件。我们将重新克隆 protoc-gen-go 对应的 main 函数:
|
||
|
||
```go
|
||
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 文件:
|
||
|
||
```
|
||
$ protoc --go-netrpc_out=plugins=netrpc:. hello.proto
|
||
```
|
||
|
||
其中 `--go-netrpc_out` 参数告知 protoc 编译器加载名为 protoc-gen-go-netrpc 的插件,插件中的 `plugins=netrpc` 指示启用内部唯一的名为 netrpc 的 netrpcPlugin 插件。在新生成的 hello.pb.go 文件中将包含增加的注释代码。
|
||
|
||
至此,手工定制的 Protobuf 代码生成插件终于可以工作了。
|
||
|
||
## 4.2.3 自动生成完整的 RPC 代码
|
||
|
||
在前面的例子中我们已经构建了最小化的 netrpcPlugin 插件,并且通过克隆 protoc-gen-go 的主程序创建了新的 protoc-gen-go-netrpc 的插件程序。现在开始继续完善 netrpcPlugin 插件,最终目标是生成 RPC 安全接口。
|
||
|
||
首先是自定义的 genImportCode 方法中生成导入包的代码:
|
||
|
||
```go
|
||
func (p *netrpcPlugin) genImportCode(file *generator.FileDescriptor) {
|
||
p.P(`import "net/rpc"`)
|
||
}
|
||
```
|
||
|
||
然后要在自定义的 genServiceCode 方法中为每个服务生成相关的代码。分析可以发现每个服务最重要的是服务的名字,然后每个服务有一组方法。而对于服务定义的方法,最重要的是方法的名字,还有输入参数和输出参数类型的名字。
|
||
|
||
为此我们定义了一个 ServiceSpec 类型,用于描述服务的元信息:
|
||
|
||
```go
|
||
type ServiceSpec struct {
|
||
ServiceName string
|
||
MethodList []ServiceMethodSpec
|
||
}
|
||
|
||
type ServiceMethodSpec struct {
|
||
MethodName string
|
||
InputTypeName string
|
||
OutputTypeName string
|
||
}
|
||
```
|
||
|
||
然后我们新建一个 buildServiceSpec 方法用来解析每个服务的 ServiceSpec 元信息:
|
||
|
||
```go
|
||
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()` 就可以获取 Protobuf 文件中定义的服务的名字。Protobuf 文件中的名字转为 Go 语言的名字后,需要通过 `generator.CamelCase` 函数进行一次转换。类似的,在 for 循环中我们通过 `m.GetName()` 获取方法的名字,然后再转为 Go 语言中对应的名字。比较复杂的是对输入和输出参数名字的解析:首先需要通过 `m.GetInputType()` 获取输入参数的类型,然后通过 `p.ObjectNamed` 获取类型对应的类对象信息,最后获取类对象的名字。
|
||
|
||
然后我们就可以基于 buildServiceSpec 方法构造的服务的元信息生成服务的代码:
|
||
|
||
```go
|
||
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 是服务的模板。
|
||
|
||
在编写模板之前,我们先查看下我们期望生成的最终代码大概是什么样子:
|
||
|
||
```go
|
||
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 是服务名字,同时还有一系列的方法相关的名字。
|
||
|
||
参考最终要生成的代码可以构建如下模板:
|
||
|
||
```go
|
||
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}}
|
||
`
|
||
```
|
||
|
||
当 Protobuf 的插件定制工作完成后,每次 hello.proto 文件中 RPC 服务的变化都可以自动生成代码。也可以通过更新插件的模板,调整或增加生成代码的内容。在掌握了定制 Protobuf 插件技术后,你将彻底拥有这个技术。
|
||
|