今年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)

嵌套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来定义字段
  • 用上面的那个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的到来(滑稽