今年3月,官方Go Protobuf库发布了新版API,见官方博客A new Go API for Protocol Buffers。但依然槽点颇多,首先就是版本号——
谷歌:虽然爷强制你们都按照go mod规范用语义版本(semantic version),按理来说应该在同一仓库下用v2目录区分版本,但爷自己就是不这么干,与之前的 github.com/golang/protobuf 不同,爷就是要把新的代码放在放在 github.com/protocolbuffers/protobuf-go ,然后不升大版本号,而是在之前v1版API库的版本号基础上给中版本号加16,到1.20.x,
都给爷吃屎吧开发者们!!
注:此处为意译,但上面那个官方的公告文章基本上就是这么说的,不信自己去看原文
其次就是这个新库子一点像样的文档都没有,初来乍到根本不知如何使用,尤其是当你想用动态反射protobuf的方式来使用时。感觉好多go的库都是这个样子,以为有了godoc这种文档工具,在代码里写一大堆注释,别人就能知道怎么用了,结果用户看了一头雾水,最后还是得啃源码,本文就是啃了好久源码踩了无数的坑之后得来的,希望您能因为本文少走一些弯路。
看官方的意思是v1版的库(从1.4.0开始)底层也会改为基于v2(禁止套娃)并且没有要和gogo/protobuf合并的迹象,所以目前我的建议是如果你用的是原版或者魔改版的gogo/protobuf,最好不要闲着没事升级这个(又不是不能用.jpg),但如果你像我一样需要动态生成pb并且反射解析的话,那么接下来的内容就可以作为参考了。
动态protobuf的原理
这里的动态并不是指字段可以xjb变,而是在运行时根据代码逻辑构建出FieldDescriptorProto,并以此为字段的定义依据通过反射来序列化/反序列化protobuf消息。
静态pb | 动态pb | |
---|---|---|
字段定义 | 通过.proto文件定义 | 在运行时通过代码逻辑定义,产物是FileDescriptor |
编译 | 通过protoc进行编译,生成固定struct和Marshal,Unmarshal等操作方法的go代码 | 无需编译 |
使用 | 调用生成的代码对消息进行序列化和反序列化等操作 | 使用protoreflect下面的反射方法,依据FileDescriptor的定义,对消息进行序列化和反序列化等操作 |
我这边的场景基本上是这样玩的:某个服务,启动时构建FileDescriptor,从里面掏一个MessageDescriptor出来,用它创建一个Message对象,并将数据塞进去,最后Marshal成二进制(也称为wire format)。另一个服务,启动时使用同样的过程构建FileDescriptor,从里面把MessageDescriptor掏出来,用它创建一个Message对象,把之前的那坨二进制Unmarshal进去。然后按照MessageDescriptor里面的各种FieldDescriptor(也就是字段定义)用Message对象上的一些反射方法把字段的数据取出来。(被一大堆descriptor绕晕的先别着急关闭窗口,后面会讲怎么写的)
你们golang没有泛型是真的辣鸡
准备
首先自己去go get这个版本号非常神奇的的库
go get google.golang.org/protobuf@v1.21.0
然后记得import,下文基本上用了这四个包,不要问我为什么protoreflect为什么缩写成pref,我从他们官方代码里抄的
import (
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protodesc"
pref "google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/descriptorpb"
)
如何定义
下面的代码定义了三个message,其中Foo是简单的无嵌套消息;Bar是消息内嵌套一个Map字段,key是string,value是前面定义的那个Foo;Baz是消息内嵌套repeated也就是列表字段,其中元素的类型是Foo。
先定义FileDescriptorProto,在里面塞MessageDescriptorProto,最后记得用protodesc.NewFile(pb, nil)
来通过那个FileDescriptor生成可用的FileDescriptor,这个东西才是最终我们需要的。
简单消息定义
嵌套Map消息定义
其实是使用repeated里面塞内嵌k/v的message来表示map的,但在通过代码动态创建descriptor的时候,就要完全符合它的要求才可以,不然在后续的使用中会报错。
什么样的字段会被认为是个map呢?主要需要注意以下几点(感兴趣的请去围观该库的checkValidMap方法源代码):
- Label为Repeated
- Type为MessageKind
- TypeName字段需要设置为
.包名.消息名.entryDescriptor名
,例如.example.Bar.BarMapEntry
,这个玩意需要和NestedType里面相一致 - NestedType字段里需要创建一个entry的DescriptorProto
- 这个东西的Name必须是你map字段名改成驼峰后面解Entry,也就是说如果你的map字段是
what_the_fuck
,那么这里的Name必须设置为WhatTheFuckEntry
, - 他的NestedType的Field里有且仅有两个,分别是key和value,而且顺序一定要是先key后value,之后key和value的的Type什么的根据你的需要进行设置
- 它的Options字段里面要设置
MapEntry: proto.Bool(true)
- 这个东西的Name必须是你map字段名改成驼峰后面解Entry,也就是说如果你的map字段是
嵌套List消息定义
嵌套List定义很简单,Label为Repeated,Type根据需要进行设置。
代码
// make FileDescriptorProto
pb := &descriptorpb.FileDescriptorProto{
Syntax: proto.String("proto3"),
Name: proto.String("example.proto"),
Package: proto.String("example"),
MessageType: []*descriptorpb.DescriptorProto{
// define Foo message
&descriptorpb.DescriptorProto{
Name: proto.String("Foo"),
Field: []*descriptorpb.FieldDescriptorProto{
{
Name: proto.String("id"),
JsonName: proto.String("id"),
Number: proto.Int32(1),
Type: descriptorpb.FieldDescriptorProto_Type(pref.Int32Kind).Enum(),
},
{
Name: proto.String("title"),
JsonName: proto.String("title"),
Number: proto.Int32(2),
Type: descriptorpb.FieldDescriptorProto_Type(pref.StringKind).Enum(),
},
},
},
// define Bar message
&descriptorpb.DescriptorProto{
Name: proto.String("Bar"),
Field: []*descriptorpb.FieldDescriptorProto{
{
Name: proto.String("bar_map"),
JsonName: proto.String("bar_map"),
Number: proto.Int32(1),
Label: descriptorpb.FieldDescriptorProto_Label(pref.Repeated).Enum(),
Type: descriptorpb.FieldDescriptorProto_Type(pref.MessageKind).Enum(),
TypeName: proto.String(".example.Bar.BarMapEntry"),
},
},
NestedType: []*descriptorpb.DescriptorProto{
{
Name: proto.String("BarMapEntry"),
Field: []*descriptorpb.FieldDescriptorProto{
{
Name: proto.String("key"),
JsonName: proto.String("key"),
Number: proto.Int32(1),
Label: descriptorpb.FieldDescriptorProto_Label(pref.Optional).Enum(),
Type: descriptorpb.FieldDescriptorProto_Type(pref.StringKind).Enum(),
}, {
Name: proto.String("value"),
JsonName: proto.String("value"),
Number: proto.Int32(2),
Label: descriptorpb.FieldDescriptorProto_Label(pref.Optional).Enum(),
Type: descriptorpb.FieldDescriptorProto_Type(pref.MessageKind).Enum(),
TypeName: proto.String(".example.Foo"),
},
},
Options: &descriptorpb.MessageOptions{
MapEntry: proto.Bool(true),
},
},
},
},
// define Baz message
&descriptorpb.DescriptorProto{
Name: proto.String("Baz"),
Field: []*descriptorpb.FieldDescriptorProto{
{
Name: proto.String("baz_list"),
JsonName: proto.String("baz_list"),
Number: proto.Int32(1),
Label: descriptorpb.FieldDescriptorProto_Label(pref.Repeated).Enum(),
Type: descriptorpb.FieldDescriptorProto_Type(pref.MessageKind).Enum(),
TypeName: proto.String(".example.Foo"),
},
},
},
},
}
// get FileDescriptor
fd, err := protodesc.NewFile(pb, nil)
上面的定义基本等效于这个proto文件,实际的逻辑中不会用到这个文件,仅用作参考,理论上来说用它编译出的go代码也可以正常用来操作动态定义出来的pb生成的数据。
syntax = "proto3";
package myproto;
message Foo {
int id = 1;
string title = 2;
string content = 3;
}
message Bar {
Map<string,Foo> bar_map = 1;
}
message Bazz {
repeated int my_list = 1;
}
如何创建消息及序列化
简单消息
往message里塞数据的套路基本都是一样的,先获取要修改的字段的FieldDescriptor,这个东西可以通过abcMessageDescriptor.Fields().ByName("field_name")
获取,或者也有ByNumber方法等传入字段序号获取,用法类似。取到FieldDescriptor之后就可以用Set方法把值设置上去了。
var (
msg *dynamicpb.Message
data []byte
)
fooMessageDescriptor := fd.Messages().ByName("Foo")
msg := dynamicpb.NewMessage(fooMessageDescriptor)
msg.Set(fooMessageDescriptor.Fields().ByName("id"), pref.ValueOfInt32(42))
msg.Set(fooMessageDescriptor.Fields().ByNumber(1), pref.ValueOfString("aloha"))
嵌套Map
先取到Map字段的FieldDescriptor,然后传入到NewField方法,获取Map字段,使用Set往里面塞数据,最后别忘了把Map通过Set方法写入到msg中,如果你要写入的Value是可变的,要用Mutable方法进行操作。
barMessageDescriptor := fd.Messages().ByName("Bar")
msg := dynamicpb.NewMessage(barMessageDescriptor)
mf := barMessageDescriptor.Fields().ByName("bar_map")
mp:= msg.NewField(mf)
fooMsg := makeFooMsg(fd)
mp.Map().Set(pref.MapKey(pref.ValueOfString("key1")), pref.ValueOfMessage(fooMsg))
mp.Map().Set(pref.MapKey(pref.ValueOfString("key2")), pref.ValueOfMessage(fooMsg))
msg.Set(mf, mp)
嵌套List(即repeated)
先取到List字段的FieldDescriptor,然后传入到NewField方法,获取List,使用Append往里面塞元素,最后别忘了把List通过Set方法写入到msg中,如果你要写入的Value是可变的,要用MutableAppend方法操作。
bazMessageDescriptor := fd.Messages().ByName("Baz")
msg := dynamicpb.NewMessage(bazMessageDescriptor)
lf := bazMessageDescriptor.Fields().ByName("baz_list")
fooMsg := makeFooMsg(fd)
lst := msg.NewField(lf).List()
lst.Append(pref.ValueOf(fooMsg))
lst.Append(pref.ValueOf(fooMsg))
lst.Append(pref.ValueOf(fooMsg))
msg.Set(lf, pref.ValueOf(lst))
如何反序列化消息并获取字段数据
反序列化
先用dynamicpb.NewMessage传入需要的MessageDescriptor新建Message对象,再调用proto.Unmarshal方法,把数据解到msg里面。
var (
data []byte
err error
)
barMessageDescriptor := fd.Messages().ByName("Bar")
msg := dynamicpb.NewMessage(barMessageDescriptor)
if err := proto.Unmarshal(data, msg); err != nil {
panic(err)
}
获取普通字段数据
用Message上的Get方法传需要的字段的descriptor进去,就可以取到值,用Range方法可以传入一个函数对各个字段进行遍历,return false的时候可以直接跳出循环。
fooMessageDescriptor := fd.Messages().ByName("Foo")
msg := dynamicpb.NewMessage(fooMessageDescriptor)
if err := proto.Unmarshal(data, msg); err != nil {
panic(err)
}
// get single field's value
v := msg.Get(fooMessageDescriptor.Fields().ByName("id"))
fmt.Printf("get %v \n", v)
// iterate over all fields
msg.Range(func(descriptor pref.FieldDescriptor, value pref.Value) bool {
fmt.Printf("field: %v value: %v \n", descriptor.Name(), value)
return true
})
获取嵌套Map数据
用Message上的Get方法传Map字段的descriptor进去,return false的时候可以直接跳出循环。
barMessageDescriptor := fd.Messages().ByName("Bar")
msg := dynamicpb.NewMessage(barMessageDescriptor)
if err := proto.Unmarshal(data, msg); err != nil {
panic(err)
}
mp := msg.Get(barMessageDescriptor.Fields().ByName("bar_map")).Map()
// iterate over map field
mp.Range(func(key pref.MapKey, value pref.Value) bool {
fmt.Printf("key: %v value: %v \n", key.String(), value.Message())
return true
})
获取List(即repeated)数据
用Message上的Get方法传List字段的descriptor进去,然后先用Len获取长度,再用Get方法传入index获取各个元素。
bazMessageDescriptor := fd.Messages().ByName("Baz")
msg := dynamicpb.NewMessage(bazMessageDescriptor)
if err := proto.Unmarshal(data, msg); err != nil {
panic(err)
}
lf := bazMessageDescriptor.Fields().ByName("baz_list")
lst := msg.Get(lf).List()
length := lst.Len()
for i:= 0; i<length; i++ {
ele := lst.Get(i)
fmt.Printf("index: %v value: %v \n", i, ele.Message())
}
总结
理清这几个东西的关系,就能弄清这个动态pb是怎么玩的了。简单概述如下:
- 创建FileDescriptorProto对象
- 往里面放descriptorpb.DescriptorProto来定义消息结构
- 里面放descriptorpb.FieldDescriptorProto来定义字段
- 往里面放descriptorpb.DescriptorProto来定义消息结构
- 用上面的那个FileDescriptorProto创建FileDescriptor
- FileDescriptor里面可以掏出MessageDescriptor,用它可以新建dynamicpb.Message
- dynamicpb.Message就是相当于原来编译出来go代码的那个message的结构体,里面存各种数据的
- 可以通过proto.Marshal和proto.Unmarshal来序列化和反序列化
- 要设值或取值时,要从MessageDescriptor里获取FieldDescriptor,再通过这个FieldDescriptor来对Message设值或取值
你,绕晕了吗?(逃
本文完整代码示例请见https://github.com/Windfarer/dynamic-protobuf-example
最后据官方称,他们之前的v1版API会持续维护下去,那么可以预见它的版本号会一直增加,但是还不能到1.20,让我们拭目以待v1.19.31415926的到来(滑稽