Go 博客

Protocol Buffers 的 Go 新 API

蔡志祥、达米安·尼尔和翁尔比
2020 年 3 月 2 日

简介

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

新 API 的动机

Go 的第一个 Protocol Buffer 绑定是由 Rob Pike 于 2010 年 3 月宣布的。Go 1 还要两年后才会发布。

在最初发布的十年里,该软件包与 Go 一起发展壮大。其用户的需求也随之增长。

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

另一个常见的愿望是使用除了 Protocol Buffer 编译器生成的结构之外的数据结构,例如能够表示在编译时未知其类型的消息的动态消息类型。

我们还观察到,一个经常出现的问题来源是 proto.Message 接口(标识生成的 message 类型的值)在描述这些类型的行为方面做得非常少。当用户创建实现该接口的类型(通常是通过将 message 嵌入到另一个 struct 中无意中实现的)并将这些类型的值传递给期望生成的 message 值的函数时,程序会崩溃或行为不可预测。

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

由于在保持软件包 API 兼容性的同时无法更改 Message 类型的现有定义,因此我们决定是时候开始着手开发 protobuf 模块的新版本(不兼容的重大版本)。

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

反射

反射是新实现的旗舰功能。类似于 reflect 包如何提供 Go 类型和值的视图, google.golang.org/protobuf/reflect/protoreflect 包根据 Protocol Buffer 类型系统提供值的视图。

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,所有生成的 message 类型都实现的接口类型。此类型是 protoreflect 包中定义的一个类型的别名

type ProtoMessage interface{
    ProtoReflect() Message
}

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

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

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

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

范围函数使用 protoreflect.FieldDescriptor(描述字段的 Protocol Buffer 类型)和 protoreflect.Value(包含字段值)调用。

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

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

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

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

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

请注意,我们在这里查看的是字段的描述符,而不是字段的。我们感兴趣的信息位于 Protocol Buffer 类型系统中,而不是 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
    })
}

更完整的实现可能会递归地下降到 message 值字段。我们希望这个简单的示例能让你对 Protocol Buffer 反射及其用途有一个初步的了解。

版本

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

(这些 API 版本与 Protocol Buffer 语言的版本不同: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/[email protected] 是 APIv1 的最新 APIv2 前版本。

  • github.com/golang/[email protected] 是根据 APIv2 实现的 APIv1 版本。API 相同,但底层实现由新版本支持。此版本包含在 APIv1 和 APIv2 proto.Message 接口之间进行转换的函数,以简化两者之间的转换。

  • google.golang.org/[email protected] 是 APIv2。此模块依赖于 github.com/golang/[email protected],因此任何使用 APIv2 的程序都会自动选择与之集成的 APIv1 版本。

(为什么从版本 v1.20.0 开始?为了提供清晰度。我们预计 APIv1 永远不会达到 v1.20.0,因此仅版本号就足以明确区分 APIv1 和 APIv2。)

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

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

其他值得注意的功能

google.golang.org/protobuf/encoding/protojson 包使用 规范 JSON 映射 将 Protocol Buffer message 转换为 JSON 并从 JSON 转换,并修复了旧的 jsonpb 包中的一些难以更改的问题,而不会给现有用户带来问题。

google.golang.org/protobuf/types/dynamicpb 包为在运行时派生的 Protocol Buffer 类型的 message 提供 proto.Message 的实现。

google.golang.org/protobuf/testing/protocmp 包提供使用 github.com/google/cmp 包比较 Protocol Buffer message 的函数。

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

结论

google.golang.org/protobuf 模块是对 Go 对 Protocol Buffer 支持的重大改进,提供了对反射、自定义 message 实现以及清理的 API 表面的头等支持。我们打算无限期地维护以前的 API 作为新 API 的包装器,允许用户根据自己的节奏逐步采用新 API。

本次更新的目标是在改进旧版 API 优势的同时,解决其不足之处。在完成新实现的每个组件后,我们都会将其应用于 Google 的代码库中。这种增量部署使我们对新 API 的可用性以及新实现的性能和正确性充满信心。我们相信它已准备好投入生产。

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

下一篇文章:Go、Go 社区与疫情
上一篇文章:Go 1.14 发布
博客索引