Go 博客

Protocol Buffers 的新 Go API

Joe Tsai, Damien Neil, and Herbie Ong
2020 年 3 月 2 日

引言

我们很高兴地宣布,Google 的语言中立数据交换格式 Protocol Buffers 的 Go API 已发布重大修订版。

新 API 的动机

Rob Pike 于 2010 年 3 月 宣布 了 Go 的第一个 Protocol Buffers 绑定。两年后 Go 1 才发布。

自首次发布以来,该包与 Go 一起不断发展。用户需求也在增长。

许多人希望编写使用反射来检查 Protocol Buffers 消息的程序。reflect 包提供了 Go 类型和值的视图,但省略了 Protocol Buffers 类型系统中的信息。例如,我们可能想编写一个函数来遍历日志条目并清除任何被注释为包含敏感数据的字段。这些注解不属于 Go 类型系统。

另一个常见需求是使用 Protocol Buffers 编译器生成的类型以外的数据结构,例如动态消息类型,能够表示在编译时未知的消息类型。

我们还注意到,一个常见的问题是,标识生成的消息类型的值的 proto.Message 接口,几乎没有描述这些类型的行为。当用户创建实现该接口的类型(通常是通过将消息嵌入其他结构体而不经意间实现的)并将这些类型的值传递给期望生成的消息值的函数时,程序会崩溃或行为异常。

这三个问题都有一个共同的原因和一个共同的解决方案:Message 接口应完全指定消息的行为,并且操作 Message 值的函数应自由接受正确实现该接口的任何类型。

由于在保持包 API 兼容性的情况下,无法更改 Message 类型的现有定义,因此我们决定是时候开始着手开发一个新的、不兼容的主要版本的 protobuf 模块了。

今天,我们很高兴发布了这个新模块。希望您喜欢它。

反射

反射是新实现的主要功能。与 reflect 包提供 Go 类型和值视图的方式类似,google.golang.org/protobuf/reflect/protoreflect 包根据 Protocol Buffers 类型系统提供了值的视图。

protoreflect 包的完整描述对于这篇博文来说太长了,但让我们看看如何编写前面提到的日志清理函数。

首先,我们将编写一个 .proto 文件,该文件定义了 google.protobuf.FieldOptions 类型的扩展,以便我们可以将字段标记为包含敏感信息或不包含。

syntax = "proto3";
import "google/protobuf/descriptor.proto";
package golang.example.policy;
extend google.protobuf.FieldOptions {
    bool non_sensitive = 50000;
}

我们可以使用此选项将某些字段标记为非敏感。

message MyMessage {
    string public_name = 1 [(golang.example.policy.non_sensitive) = true];
}

接下来,我们将编写一个 Go 函数,该函数接受任意消息值并删除所有敏感字段。

// Redact clears every sensitive field in pb.
func Redact(pb proto.Message) {
   // ...
}

此函数接受一个 proto.Message,这是一个由所有生成的消息类型实现的接口类型。此类型是 protoreflect 包中定义的类型的别名。

type ProtoMessage interface{
    ProtoReflect() Message
}

为避免填充生成消息的命名空间,该接口仅包含一个返回 protoreflect.Message 的方法,该方法提供对消息内容的访问。

(为什么是别名?因为 protoreflect.Message 有一个返回原始 proto.Message 的相应方法,我们需要避免这两个包之间的导入循环。)

protoreflect.Message.Range 方法会为消息中的每个已填充字段调用一个函数。

m := pb.ProtoReflect()
m.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool {
    // ...
    return true
})

调用范围函数时会传递一个描述字段 Protocol Buffers 类型的 protoreflect.FieldDescriptor 和包含字段值的 protoreflect.Value

protoreflect.FieldDescriptor.Options 方法将字段选项返回为 google.protobuf.FieldOptions 消息。

opts := fd.Options().(*descriptorpb.FieldOptions)

(为什么进行类型断言?由于生成的 descriptorpb 包依赖于 protoreflect,因此 protoreflect 包在不导致导入循环的情况下无法返回具体的选项类型。)

然后,我们可以检查选项以查看我们扩展布尔值的值。

if proto.GetExtension(opts, policypb.E_NonSensitive).(bool) {
    return true // don't redact non-sensitive fields
}

请注意,我们这里查看的是字段的*描述符*,而不是字段的*值*。我们感兴趣的信息位于 Protocol Buffers 类型系统中,而不是 Go 类型系统中。

这也代表了我们简化 proto 包 API 的一个方面。原始的 proto.GetExtension 返回值和错误。新的 proto.GetExtension 只返回一个值,如果字段不存在,则返回该字段的默认值。扩展解码错误将在 Unmarshal 时报告。

一旦我们确定了需要编辑的字段,清除它就很简单。

m.Clear(fd)

将以上所有内容放在一起,我们完整的编辑函数是:

// Redact clears every sensitive field in pb.
func Redact(pb proto.Message) {
    m := pb.ProtoReflect()
    m.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool {
        opts := fd.Options().(*descriptorpb.FieldOptions)
        if proto.GetExtension(opts, policypb.E_NonSensitive).(bool) {
            return true
        }
        m.Clear(fd)
        return true
    })
}

更完整的实现可能会递归地深入到消息类型字段。我们希望这个简单的例子能让您一窥 Protocol Buffers 反射及其用法。

版本

我们将 Go Protocol Buffers 的原始版本称为 APIv1,新版本称为 APIv2。由于 APIv2 与 APIv1 不兼容,因此我们需要为每个版本使用不同的模块路径。

(这些 API 版本与 Protocol Buffers 语言版本:proto1proto2proto3 不同。APIv1 和 APIv2 是 Go 中的具体实现,都支持 proto2proto3 语言版本。)

github.com/golang/protobuf 模块是 APIv1。

google.golang.org/protobuf 模块是 APIv2。我们利用了更改导入路径的需要,切换到一个不与特定托管提供商绑定的路径。(我们曾考虑过 google.golang.org/protobuf/v2,以明确这是 API 的第二个主要版本,但最终选择了较短的路径,认为这是长期而言更好的选择。)

我们知道并非所有用户都会以相同的速度迁移到包的新主版本。有些人会快速切换;有些人可能会无限期地停留在旧版本上。即使在同一个程序中,某些部分可能使用一个 API,而其他部分使用另一个 API。因此,我们必须继续支持使用 APIv1 的程序。

  • github.com/golang/protobuf@v1.3.4 是 APIv1 的最新 APIv2 之前的版本。

  • github.com/golang/protobuf@v1.4.0 是一个基于 APIv2 实现的 APIv1 版本。API 相同,但底层实现由新版本支持。此版本包含在 APIv1 和 APIv2 proto.Message 接口之间进行转换的函数,以简化两者之间的迁移。

  • google.golang.org/protobuf@v1.20.0 是 APIv2。此模块依赖于 github.com/golang/protobuf@v1.4.0,因此任何使用 APIv2 的程序都将自动选择一个与之集成的 APIv1 版本。

(为什么从版本 v1.20.0 开始?为了提供清晰度。我们不希望 APIv1 达到 v1.20.0,所以版本号本身应该足以明确区分 APIv1 和 APIv2。)

我们打算无限期地维护对 APIv1 的支持。

这种组织结构确保了任何给定的程序无论使用哪个 API 版本,都只会使用一个 Protocol Buffers 实现。它允许程序逐步采用新 API,或者根本不采用,同时仍然获得新实现的优势。最小版本选择原则意味着程序可以停留在旧实现上,直到维护者选择更新到新实现(直接更新或通过更新依赖项)。

其他值得注意的功能

google.golang.org/protobuf/encoding/protojson 包使用 规范的 JSON 映射 将 Protocol Buffers 消息与 JSON 相互转换,并修复了旧 jsonpb 包中的许多问题,这些问题如果不更改可能会给现有用户带来问题。

google.golang.org/protobuf/types/dynamicpb 包为运行时派生的 Protocol Buffers 类型的消息提供了 proto.Message 的实现。

google.golang.org/protobuf/testing/protocmp 包提供了使用 github.com/google/cmp 包比较 Protocol Buffers 消息的函数。

google.golang.org/protobuf/compiler/protogen 包支持编写 Protocol Compiler 插件。

结论

google.golang.org/protobuf 模块是对 Go 对 Protocol Buffers 支持的重大改版,提供了对反射、自定义消息实现和已清理的 API 表面的头等支持。我们打算无限期地维护旧 API 作为新 API 的包装器,允许用户以自己的步调渐进式地采用新 API。

我们在此次更新中的目标是在提高旧 API 的优势的同时,解决其不足之处。当我们完成了新实现中的每个组件后,都会将其集成到 Google 的代码库中使用。这种增量式推出使我们对新 API 的可用性以及新实现的性能和正确性充满信心。我们相信它是生产就绪的。

我们对这次发布感到兴奋,并希望它能在未来十年及更长时间内为 Go 生态系统服务!

下一篇文章:Go、Go 社区与大流行病
上一篇文章:Go 1.14 发布
博客索引