1
0
mirror of https://github.com/chai2010/advanced-go-programming-book.git synced 2025-05-29 08:12:21 +00:00
2018-06-29 18:04:33 +08:00

17 KiB
Raw Blame History

4.2. Protobuf

Protobuf是Protocol Buffers的简称它是Google公司开发的一种数据描述语言并于2008年对外开源。Protobuf刚开源时的定位类似于XML、JSON等数据描述语言通过附带工具生成等代码提实现将结构化数据序列化的功能。但是我们更关注的是Protobuf作为接口规范的描述语言可以作为设计安全的跨语言PRC接口的基础工具。

Protobuf入门

对于没有用过Protobuf读者建议先从官网了解下基本用法。这里我们尝试如何将Protobuf和RPC结合在一起使用通过Protobuf来最终保证RPC的接口规范和完全。Protobuf中最基本的数据单元是message是类似Go语言中结构体的存在。在message中可以嵌套message或其它的基础数据类型的成员。

首先创建hello.proto文件其中包装HelloService服务中用到的字符串类型

syntax = "proto3";

package main;

message String {
	string value = 1;
}

开头的syntax语句表示采用Protobuf第三版本的语法。第三版的Protobuf对语言进行的提炼简化所有成员均采用类似Go语言中的零值初始化不在支持自定义默认值同时消息成员也不再支持required特性。然后package指令指明当前是main包这样可以和Go的包明保持一致当然用户也可以针对不同的语言定制对应的包路径和名称。最后message关键字定义一个新的String类型在最终生成的Go语言代码中对应一个String结构体。String类型中只有一个字符串类型的value成员该成员的Protobuf编码时的成员编号为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结构体内容如下

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

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

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编码实现一个插件。虽然做了这么多工作但是似乎并没有看到什么收益

回顾第一章中更安全的PRC接口部分的内容当时我们花费了极大的力气去给RPC服务增加安全的保障。最终得到的更安全的PRC接口的代码本书就非常繁琐比利于手工维护同时全部安全相关的代码只适用于Go语言环境既然使用了Protobuf定义的输入和输出参数那么RPC服务接口是否也可以通过Protobuf定义呢其实用Protobuf定义语言无关的PRC服务接口才是它真正的价值所在

下面更新hello.proto文件通过Protobuf来定义HelloService服务

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生成安全的代码。

定制代码生成插件

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接口

// 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框架生成代码

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.P("// TODO: import code")
	}
}

func (p *netrpcPlugin) Generate(file *generator.FileDescriptor) {
	for _, svc := range file.Service {
		p.P("// TODO: service code, Name = " + svc.GetName())
		_ = svc
	}
}

首先Name方法返回插件的名字。netrpcPlugin插件内置了一个匿名的*generator.Generator成员然后在Init初始化的时候用参数g进行初始化因此插件是从g参数对象继承了全部的公有方法。在GenerateImports方法中当判断表示服务数列的file.Service切片非空时输出一个注释信息。在Generate方法也是才有类似的测试但是遍历每个服务输出一个注释并且输出服务的名字。至此一个最简陋的自定义的protoc-gen-go静态插件已经成型了。

要使用该插件需要先通过generator.RegisterPlugin函数注册插件可以在init函数完成

func init() {
	generator.RegisterPlugin(new(netrpcPlugin))
}

因为Go语言的包只能静态导入我们无法向已经安装的protoc-gen-go添加我们新编写的插件。我们可以完整克隆protoc-gen-go对应main函数

// copy from https://github.com/golang/protobuf/blob/master/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() {
	// Begin by allocating a generator. The request and response structures are stored there
	// so we can do error handling easily - the response structure contains the field to
	// report failure.
	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表示包含了nerpc插件。然后用以下命令重新编译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文件中将包含以下的代码

// TODO: import code
// TODO: service code, Name = HelloService

至此手工定制的Protobuf代码生成插件终于可以工作了。

自动生成完整的RPC代码

在前面的例子中我们已经构件了最小化的netrpcPlugin插件并且通过克隆protoc-gen-go的主程序创建了新的protoc-gen-go-netrpc的插件程序。我们现在开始继续完善netrpcPlugin插件最终目标是生成RPC安全接口。

以下是完善后的GenerateImports和Generate方法

func (p *netrpcPlugin) GenerateImports(file *generator.FileDescriptor) {
	if len(file.Service) > 0 {
		p.P(`import "net/rpc"`)
	}
}

func (p *netrpcPlugin) Generate(file *generator.FileDescriptor) {
	for _, svc := range file.Service {
		p.genServiceInterface(file, svc)
		p.genServiceServer(file, svc)
		p.genServiceClient(file, svc)
	}
}

在导入部分我们增加了导入net/rpc包的语句。而在每个服务部分则通过genServiceInterface方法生成服务的接口通过genServiceServer方法生成服务的注册函数通过genServiceClient方法生成客户端包装代码。

首先看看genServiceInterface如何生成服务器接口

func (p *netrpcPlugin) genServiceInterface(
	file *generator.FileDescriptor,
	svc *descriptor.ServiceDescriptorProto,
) {
	const serviceInterfaceTmpl = `
type {{.ServiceName}}Interface interface {
	{{.CallMethodList}}
}
`
	const callMethodTmpl = `
{{.MethodName}}(in {{.ArgsType}}, out *{{.ReplyType}}) error`

	// gen call method list
	var callMethodList string
	for _, m := range svc.Method {
		out := bytes.NewBuffer([]byte{})
		t := template.Must(template.New("").Parse(callMethodTmpl))
		t.Execute(out, &struct{ ServiceName, MethodName, ArgsType, ReplyType string }{
			ServiceName: generator.CamelCase(svc.GetName()),
			MethodName:  generator.CamelCase(m.GetName()),
			ArgsType:    p.TypeName(p.ObjectNamed(m.GetInputType())),
			ReplyType:   p.TypeName(p.ObjectNamed(m.GetOutputType())),
		})
		callMethodList += out.String()

		p.RecordTypeUse(m.GetInputType())
		p.RecordTypeUse(m.GetOutputType())
	}

	// gen all interface code
	out := bytes.NewBuffer([]byte{})
	t := template.Must(template.New("").Parse(serviceInterfaceTmpl))
	t.Execute(out, &struct{ ServiceName, CallMethodList string }{
		ServiceName:    generator.CamelCase(svc.GetName()),
		CallMethodList: callMethodList,
	})
	p.P(out.String())
}

生成服务接口时首先需要服务的名字,可以通过svc.GetName()获取服务在Proto文件中的名字然后通过generator.CamelCase函数转为Go语言中修饰后的名字。

服务中svc.Method是一个表示方法信息的切片。要生成每个方法需要知道每个方法的名字、输入参数类型、输出参数类型。其中m.GetName()是获取原始的方法名字同样需要通过generator.CamelCase转化为Go语言中修饰后的名字。而p.TypeName(p.ObjectNamed(m.GetInputType()))p.TypeName(p.ObjectNamed(m.GetOutputType()))分别用户获取输入参数和输出参数的类型名字。

然后是生成RPC注册方法的genServiceServer函数

func (p *netrpcPlugin) genServiceServer(
	file *generator.FileDescriptor,
	svc *descriptor.ServiceDescriptorProto,
) {
	const serviceHelperFunTmpl = `
func Register{{.ServiceName}}(srv *rpc.Server, x {{.ServiceName}}) error {
	if err := srv.RegisterName("{{.ServiceName}}", x); err != nil {
		return err
	}
	return nil
}
`
	out := bytes.NewBuffer([]byte{})
	t := template.Must(template.New("").Parse(serviceHelperFunTmpl))
	t.Execute(out, &struct{ PackageName, ServiceName, ServiceRegisterName string }{
		PackageName: file.GetPackage(),
		ServiceName: generator.CamelCase(svc.GetName()),
	})
	p.P(out.String())
}

genServiceServer函数的实现和生成接口的代码类似依然是才有Go语言的模板生成目标代码。

最后是genServiceClient函数生成客户端包装代码


func (p *netrpcPlugin) genServiceClient(
	file *generator.FileDescriptor,
	svc *descriptor.ServiceDescriptorProto,
) {
	const clientHelperFuncTmpl = `
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
}

{{.MethodList}}
`
	const clientMethodTmpl = `
func (p *{{.ServiceName}}Client) {{.MethodName}}(in {{.ArgsType}}, out *{{.ReplyType}}) error {
	return p.Client.Call("{{.ServiceName}}.{{.MethodName}}", in, out)
}
`

	// gen client method list
	var methodList string
	for _, m := range svc.Method {
		out := bytes.NewBuffer([]byte{})
		t := template.Must(template.New("").Parse(clientMethodTmpl))
		t.Execute(out, &struct{ ServiceName, ServiceRegisterName, MethodName, ArgsType, ReplyType string }{
			ServiceName:         generator.CamelCase(svc.GetName()),
			ServiceRegisterName: file.GetPackage() + "." + generator.CamelCase(svc.GetName()),
			MethodName:          generator.CamelCase(m.GetName()),
			ArgsType:            p.TypeName(p.ObjectNamed(m.GetInputType())),
			ReplyType:           p.TypeName(p.ObjectNamed(m.GetOutputType())),
		})
		methodList += out.String()
	}

	// gen all client code
	out := bytes.NewBuffer([]byte{})
	t := template.Must(template.New("").Parse(clientHelperFuncTmpl))
	t.Execute(out, &struct{ PackageName, ServiceName, MethodList string }{
		PackageName: file.GetPackage(),
		ServiceName: generator.CamelCase(svc.GetName()),
		MethodList:  methodList,
	})
	p.P(out.String())
}

除了模板不同,客户端的生成代码逻辑服务接口的生成函数也是类似的。

最后我们可以查看下netrpcPlugin插件生成的RPC代码

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)
}

当Protobuf的插件定制工作完成后每次hello.proto文件中RPC服务的变化都可以自动生成代码。同时才有类似的技术也可以为其它语言编写代码生成插件。

在掌握了定制Protobuf插件技术后你将彻底拥有这个技术。