protobuf 中提供的编译器protoc,支持开发者自定义plugin的扩展,根据.proto文件生成各种各样的代码。官方文档 https://developers.google.com/protocol-buffers/docs/reference/other中的介绍:
protoc
, the Protocol Buffers Compiler, can be extended to support new languages via plugins. A plugin is just a program which reads aCodeGeneratorRequest
protocol buffer from standard input and then writes aCodeGeneratorResponse
protocol buffer to standard output. These message types are defined in plugin.proto. We recommend that all third-party code generators be written as plugins, as this allows all generators to provide a consistent interface and share a single parser implementation.
Additionally, plugins are able to insert code into the files generated by other code generators. See the comments about "insertion points" in plugin.proto for more on this. This could be used, for example, to write a plugin which generates RPC service code that is tailored for a particular RPC system. See the documentation for the generated code in each language to find out what insertion points they provide.
大概就是说,允许开发者自己实现插件,protoc解析.proto文件生成语法树,然后protoc把生成结果通过标准输入传递给插件,下一步 插件生成的内容,返回给protoc,最后把生成的内容写入到文件。总得来说,就是google提供了protogen这个东西,做了比较好的封装。对于想利用proto生成些什么东西的开发者来说,不需要自己重头搞起,可以基于protoc的工具进行二次开发。 不过,官方没有给出上手指南或者入门的例子。找到个算是官方的参考 protoc-gen-go
,pb生成go的代码https://github.com/protocolbuffers/protobuf-go/blob/master/cmd/protoc-gen-go/main.go,可以提供些参考。
网上找到一张画的很好的图,插件的运行流程基本入下图所示:
了解了基本的流程,那下一步开始搞起。
首先先把环境搭建好,先写一个生成hello world的例子。整个项目的目录结构如下:
➜ helloworld tree
|-- build.sh
|-- demo.proto
|-- go.mod
|-- go.sum
|-- main.go
`-- out
`-- hello.txt
1 directory, 6 files
核心的代码在这里:
// main.go
package main
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/pluginpb"
)
func main() {
// 1. 读取标准输入,接收proto 解析的文件内容,并解析成结构体
input, _ := ioutil.ReadAll(os.Stdin)
var req pluginpb.CodeGeneratorRequest
proto.Unmarshal(input, &req)
// 2. 生成插件
opts := protogen.Options{}
plugin, err := opts.New(&req)
if err != nil {
panic(err)
}
// 3. 写入内容
var buf bytes.Buffer
pkg := fmt.Sprintf("hello world")
buf.Write([]byte(pkg))
// 指定输入文件名
filename := "hello.txt"
file := plugin.NewGeneratedFile(filename, ".")
// 将内容写入插件文件内容
file.Write(buf.Bytes())
// 4. 生成响应
stdout := plugin.Response()
out, err := proto.Marshal(stdout)
if err != nil {
panic(err)
}
// 5. 将响应写回标准输入, protoc会读取这个内容
fmt.Fprintf(os.Stdout, string(out))
}
执行的流程,最后的结果就是在out的目录下生成一个hello.txt文件,里面的内容是hello world。全部的代码在这里 > 我是代码
# init go mod
go mod init protoc-gen-helloworld
# 查看结果
ls $GOPATH/bin
# build
go install .
# run
protoc --helloworld_out=./out demo.proto
那进一步,尝试解析proto生成更复杂的东西。基本的语法接口是这样的,类似于是一颗语法树,可以一层层的向下解析,找到各种元数据的定义,然后打印出来。
for _, file := range plugin.Files {
//创建一个buf 写入生成的文件内容
var buf bytes.Buffer
content := ""
content += fmt.Sprintf("%s\n", file.GoPackageName)
// 遍历proto文件中的每个msg
for _, msg := range file.Messages {
for _, field := range msg.Fields {
content += fmt.Sprintf(" msg field : %s \n", field.Desc.TextName())
}
}
// 遍历service
for _, svc := range file.Services {
content += fmt.Sprintf(" msg field : %s \n", field.Desc.TextName())
for _, method := range svc.Methods {
content += fmt.Sprintf(" msg field : %s \n", field.Desc.TextName())
}
}
}
对于一个.proto文件中的每一级结构,protogen 都有对应的数据类型,分别是:
// 对于pb定义的一个message
File > Messages > Field
// 对于pb定义的一个service
File > Service > Method > Field。
google.golang.org/protobuf/compiler/protogen 中可以看到每一级的定义,以及如何访问这些数据的方法。
- File的定义
// A File describes a .proto source file.
type File struct {
Desc protoreflect.FileDescriptor
Proto *descriptorpb.FileDescriptorProto
GoDescriptorIdent GoIdent // name of Go variable for the file descriptor
GoPackageName GoPackageName // name of this file's Go package
GoImportPath GoImportPath // import path of this file's Go package
Enums []*Enum // top-level enum declarations
Messages []*Message // top-level message declarations
Extensions []*Extension // top-level extension declarations
Services []*Service // top-level service declarations
Generate bool // true if we should generate code for this file
// GeneratedFilenamePrefix is used to construct filenames for generated
// files associated with this source file.
//
// For example, the source file "dir/foo.proto" might have a filename prefix
// of "dir/foo". Appending ".pb.go" produces an output file of "dir/foo.pb.go".
GeneratedFilenamePrefix string
location Location
}
//
- Enums代表文件中的枚举
- Messages代表proto 文件中的所有消息
- Extensions代表文件中的扩展信息
- Services消息中定义的服务,跟grpc 有关
- GeneratedFilenamePrefix 来源proto 文件的前缀,上面有例子,例如"dir/foo.proto",这个值就是"dir/foo",后面加上".pb.go"代表最后生成的文件
- Comments字段代表字段上面,后面的注释
- Message的定义
// A Message describes a message.
type Message struct {
Desc protoreflect.MessageDescriptor
GoIdent GoIdent // name of the generated Go type
Fields []*Field // message field declarations
Oneofs []*Oneof // message oneof declarations
Enums []*Enum // nested enum declarations
Messages []*Message // nested message declarations
Extensions []*Extension // nested extension declarations
Location Location // location of this message
Comments CommentSet // comments associated with this message
}
- Fields代表消息的每个字段,遍历这个字段就可以得到字段信息
- Oneofs 代表消息中Oneof结构
- Enums 消息中的枚举
- Messages嵌套消息,消息是可以嵌套消息,所以这个代表嵌套的消息
- Extensions代表扩展信息
- Comments字段代表字段上面,后面的注释
- Field的定义
// A Field describes a message field.
type Field struct {
Desc protoreflect.FieldDescriptor
// GoName is the base name of this field's Go field and methods.
// For code generated by protoc-gen-go, this means a field named
// '{{GoName}}' and a getter method named 'Get{{GoName}}'.
GoName string // e.g., "FieldName"
// GoIdent is the base name of a top-level declaration for this field.
// For code generated by protoc-gen-go, this means a wrapper type named
// '{{GoIdent}}' for members fields of a oneof, and a variable named
// 'E_{{GoIdent}}' for extension fields.
GoIdent GoIdent // e.g., "MessageName_FieldName"
Parent *Message // message in which this field is declared; nil if top-level extension
Oneof *Oneof // containing oneof; nil if not part of a oneof
Extendee *Message // extended message for extension fields; nil otherwise
Enum *Enum // type for enum fields; nil otherwise
Message *Message // type for message or group fields; nil otherwise
Location Location // location of this field
Comments CommentSet // comments associated with this field
}
- Desc 代表该字段的描述
- GoName代表字段名
- Parent代表父消息
- Comments字段代表字段上面,后面的注释
了解了protoc的基本原理,那就可以基于protoc去实现各种插件,去提高开发效率。
protoc插件将protobuff转换为pod对象
在日常中,会有些场景需要pb对象和pod对象相互转换。如果这个过程依赖手动定义的话,是很无聊并且容易出错的。利用protoc-gen可以更具proto文件的描述,生成对应的class定义,并且提供了将一个pb对象转化为cpp对象的接口。比如:
message UserMsg
{
uint32 id = 1;
uint32 age = 2;
uint32 passwd = 3;
}
// hpp 文件定义
struct PODUserMsg // pb name UserMsg
{
uint32_t id; // id = 1
uint32_t age; // age = 2
uint32_t passwd; // passwd = 3
// method
void clear();
int pb2pod(const UserMsg & pb); // 提供pb对象转换为pod对象
int pod2pb(UserMsg * pb); // 将pod对象装换为pb对象
};
// cpp 文件实现
void PODUserMsg::clear()
{
id = 0;
age = 0;
passwd = 0;
}
int PODUserMsg::pb2pod(const UserMsg & pb)
{
id = pb.id();
age = pb.age();
passwd = pb.passwd();
return 0;
}
int PODUserMsg::pod2pb(UserMsg * pb)
{
pb->set_id(id);
pb->set_age(age);
pb->set_passwd(passwd);
return 0;
}
理想的办法是,更具pb的proto描述文件,利用protoc去生成pod的hpp和cpp文件。那完全可以利用上面介绍的protoc工具,生成这样的代码。
核心的结构如下:
// https://godocs.io/google.golang.org/protobuf/compiler/protogen
func main() {
//1.读取标准输入,接收proto 解析的文件内容,并解析成结构体
input, _ := ioutil.ReadAll(os.Stdin)
var req pluginpb.CodeGeneratorRequest
proto.Unmarshal(input, &req)
//2.生成插件
opts := protogen.Options{}
plugin, err := opts.New(&req)
if err != nil {
panic(err)
}
pb_gen(plugin)
// 生成响应
stdout := plugin.Response()
out, err := proto.Marshal(stdout)
if err != nil {
panic(err)
}
// 将响应写回标准输入, protoc会读取这个内容
fmt.Fprintf(os.Stdout, string(out))
}
func pb_gen(plugin *protogen.Plugin) {
pb_gen_hpp(plugin)
pb_gen_cpp(plugin)
}
里面最核心的两个函数pb_gen_hpp 和 pb_gen_cpp ,是通过解析proto中的field,生成相关的代码。其中,有两个文件比较难搞。第一点,由于pb的message中可以嵌套的,既可以在一个message中包含另外一个message,对于这样的message需要做相关的特殊处理。第二点,pb中对于数组的定义是通过repeated
来定义实现的,pod中是传统的定义一个数组。在数组类型的对象进行相互装换的时候,需要生成如下的代码。
// proto 定义
message FamilyMsg
{
repeated UserMsg familys = 1 [(custom.opt_count)=10];
repeated uint32 rid_arrs = 2 [(custom.opt_count)=50];
}
// pod 定义
struct PODFamilyMsg // pb name FamilyMsg
{
int family_cnt;
PODUserMsg familys[10];
int rid_cnt;
uint32_t rid_arrs[50]; // rid_arrs = 2
// method
void clear();
int pb2pod(const FamilyMsg & pb);
int pod2pb(FamilyMsg * pb);
};
int PODFamilyMsg::pb2pod(const FamilyMsg & pb)
{
for (int i = 0; i < 10; i++) {
familys[i].pb2pod(pb.familys(i));
}
for (int i = 0; i < 50; i++) {
rid_arrs[i] = pb.rid_arrs(i);
}
return 0;
}
int PODFamilyMsg::pod2pb(FamilyMsg * pb)
{
for (int i = 0; i < 10; i++) {
familys[i].pod2pb(pb->add_familys());
}
for (int i = 0; i < 50; i++) {
pb->add_rid_arrs(rid_arrs[i]);
}
return 0;
}
所以,从语义上来说,pb对象的描述转换为pod对象的定义的时候,需要指定这个数组的最大元素个数。这里呢,就有是一个新的问题了。这里先不整理了,简单的说就是protobuff支持custom option字段,可以利用这样的机制去指定数组的长度。repeated UserMsg familys = 1 [(custom.opt_count)=10];
在利用protoc-gen提供的接口去解析option中的内容去生成对象的pod方法。
将最后的结果是呢,给出一份proto的描述文件,可以生成一份对应的pod对象的定义,并且提供的pb和pod对象相互装换的接口。实例代码如下:
int test_case()
{
UserMsg pb;
PODUserMsg pod;
pod.clear();
pod.id = 1;
pod.age = 2;
pod.pod2pb(&pb); // pod对象转换为pb对象
assert(pb.id() == pod.id);
assert(pb.age() == pod.age);
pod.clear();
pod.pb2pod(pb); // pd对象装换为pod对象
assert(pb.id() == pod.id);
assert(pb.age() == pod.age);
}
代码地址:
https://github.com/zhaozhengcoder/CoderNoteBook/tree/master/example_code/pb_demo/protoc-gen/protoc-gen-pod
官方文档:
https://godocs.io/google.golang.org/protobuf/reflect/protoreflect#Kind
https://pkg.go.dev/github.com/golang/protobuf/v2/reflect/protoreflect#Descriptor
其他参考:
https://zhuanlan.zhihu.com/p/443397856
https://weakptr.site/p/protogen-code-generation/
https://taoshu.in/go/create-protoc-plugin.html
https://owent.net/2021/2101.html
https://cloud.tencent.com/developer/article/1753977