Protobuf如何使用动态反射
文章目录
反射
Reflection(反射)是新实现的旗舰特性。与 reflect 包提供 Go 类型和值的视图相似,protoreflect 包根据 protocol buffer 类型系统提供值的视图。
完整的描述 protoreflect package 对于这篇文章来说太长了,但是,我们可以来看看如何编写前面提到的日志清理函数。
首先,我们将编写 .proto 文件来定义 google.protobuf.FieldOptions
类型的扩展名,以便我们可以将注释字段作为标识敏感信息的与否。
|
|
我们可以使用此选项来将某些字段标识为非敏感字段。
|
|
接下来,我们将编写一个 Go 函数,它用于接收任意 message 值以及删除所有敏感字段。
|
|
函数接收 proto.Message 参数,这是由所有已生成的 message 类型实现的接口类型。此类型是 protoreflect 包中已定义的别名:
|
|
为了避免填充生成 message 的命名空间,接口仅包含一个返回 protoreflect.Message 的方法,此方法提供对 message 内容的访问。
(为什么是别名?由于 protoreflect.Message
有返回原始 proto.Message 的相应方法,我们需要避免在两个包中循环导入。)
protoreflect.Message.Range
方法为 message 中的每一个填充字段调用一个函数。
|
|
使用描述 protocol buffer 类型的 protoreflect.FieldDescriptor
字段和包含字段值的 protoreflect.Value 字段来调用 range 函数。
protoreflect.FieldDescriptor.Options
方法以 google.protobuf.FieldOptions message
的形式返回字段选项。
|
|
(为什么使用类型断言?由于生成的 descriptorpb package 依赖于 protoreflect,所以 protoreflect package 无法返回正确的选项类型,否则会导致循环导入的问题)
然后,我们可以检查选项以查看扩展为 boolean 类型的值:
|
|
请注意,我们在这里看到的是字段描述符,而不是字段值,我们感兴趣的信息在于 protocol buffer 类型系统,而不是 Go 语言。
这也是我们已经简化了 proto package API 的一个示例,原来的 proto.GetExtension 返回一个值和错误信息,新的 proto.GetExtension 只返回一个值,如果字段不存在,则返回该字段的默认值。在 Unmarshal 的时候报告扩展解码错误。
一旦我们确定了需要修改的字段,将其清除就很简单了:
|
|
综上所述,我们完整的修改函数如下:
|
|
一个更加完整的实现应该是以递归的方式深入这些 message 值字段。我们希望这些简单的示例能让你更了解 protocol buffer reflection(反射)以及它的用法。
动态 protobuf 的原理
这里的动态并不是指字段可以随便改变,而是在运行时根据代码逻辑构建出 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 对象上的一些反射方法把字段的数据取出来。
准备
go get 库
|
|
然后记得 import,下文基本上用了这四个包:
|
|
如何定义
下面的代码定义了三个 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 呢?主要需要注意以下几点:
- 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)
嵌套 List 消息定义
嵌套 List 定义很简单,Label 为 Repeated,Type 根据需要进行设置。
代码
|
|
上面的定义基本等效于这个 proto 文件,实际的逻辑中不会用到这个文件,仅用作参考,理论上来说用它编译出的 go 代码也可以正常用来操作动态定义出来的 pb 生成的数据。
|
|
如何创建消息及序列化
简单消息
往 message 里塞数据的套路基本都是一样的,先获取要修改的字段的 FieldDescriptor
,这个东西可以通过 abcMessageDescriptor.Fields().ByName("field_name")
获取,或者也有 ByNumber 方法等传入字段序号获取,用法类似。取到 FieldDescriptor 之后就可以用 Set 方法把值设置上去了。
|
|
嵌套 Map
先取到 Map 字段的 FieldDescriptor,然后传入到 NewField 方法,获取 Map 字段,使用 Set 往里面塞数据,最后别忘了把 Map 通过 Set 方法写入到 msg 中,如果你要写入的 Value 是可变的,要用 Mutable 方法进行操作。
|
|
嵌套 List (即 repeated)
先取到 List 字段的 FieldDescriptor,然后传入到 NewField 方法,获取 List,使用 Append 往里面塞元素,最后别忘了把 List 通过 Set 方法写入到 msg 中,如果你要写入的 Value 是可变的,要用 MutableAppend 方法操作。
|
|
如何反序列化消息并获取字段数据
反序列化
先用 dynamicpb.NewMessage 传入需要的 MessageDescriptor 新建 Message 对象,再调用 proto.Unmarshal 方法,把数据解到 msg 里面。
|
|
获取普通字段数据
用 Message 上的 Get 方法传需要的字段的 descriptor 进去,就可以取到值,用 Range 方法可以传入一个函数对各个字段进行遍历,return false 的时候可以直接跳出循环。
|
|
获取嵌套 Map 数据
用 Message 上的 Get 方法传 Map 字段的 descriptor 进去,return false 的时候可以直接跳出循环。
|
|
获取 List (即 repeated) 数据
用 Message 上的 Get 方法传 List 字段的 descriptor 进去,然后先用 Len 获取长度,再用 Get 方法传入 index 获取各个元素。
|
|
总结
理清这几个东西的关系,就能弄清这个动态 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 设值或取值
源码
|
|
参考
文章作者 Forz
上次更新 2021-06-16