Go 博客

Go Protobuf:新的 Opaque API

Michael Stapelberg
2024年12月16日

[协议缓冲区 (Protobuf) 是 Google 的语言中立的数据交换格式。 参见 protobuf.dev。]

回溯到 2020 年 3 月,我们发布了 google.golang.org/protobuf 模块,这是对 Go Protobuf API 的重大改进。该包引入了对 反射的一流支持、一个 dynamicpb 实现以及用于更轻松测试的 protocmp 包。

该版本引入了一个带有新 API 的新 protobuf 模块。今天,我们将发布一个针对生成代码的附加 API,即由协议编译器 (protoc) 创建的 .pb.go 文件中的 Go 代码。这篇博文解释了我们创建新 API 的动机,并向您展示了如何在您的项目中使用它。

需要明确的是:我们不会移除任何东西。我们将继续支持现有的生成代码 API,就像我们仍然支持旧的 protobuf 模块一样(通过包装 google.golang.org/protobuf 实现)。Go 致力于向下兼容性,Go Protobuf 也是如此!

背景:现有的开放结构 (Open Struct) API

我们现在将现有 API 称为开放结构 (Open Struct) API,因为生成的结构类型允许直接访问。在下一节中,我们将看到它与新的不透明 (Opaque) API 有何不同。

要使用协议缓冲区,您首先创建一个 .proto 定义文件,如下所示

edition = "2023";  // successor to proto2 and proto3

package log;

message LogEntry {
  string backend_server = 1;
  uint32 request_size = 2;
  string ip_address = 3;
}

然后,您运行协议编译器 (protoc) 来生成如下代码(在 .pb.go 文件中)

package logpb

type LogEntry struct {
  BackendServer *string
  RequestSize   *uint32
  IPAddress     *string
  // …internal fields elided…
}

func (l *LogEntry) GetBackendServer() string { … }
func (l *LogEntry) GetRequestSize() uint32   { … }
func (l *LogEntry) GetIPAddress() string     { … }

现在,您可以从 Go 代码中导入生成的 logpb 包,并调用诸如 proto.Marshal 之类的函数,将 logpb.LogEntry 消息编码为 protobuf wire 格式。

您可以在生成代码 API 文档中找到更多详细信息。

(现有)开放结构 (Open Struct) API:字段存在性 (Field Presence)

此生成代码的一个重要方面是字段存在性 (field presence)(即字段是否被设置)是如何建模的。例如,上面的例子使用指针来建模存在性,因此您可以将 BackendServer 字段设置为

  1. proto.String("zrh01.prod"):字段已设置并包含“zrh01.prod”
  2. proto.String(""):字段已设置(非 nil 指针)但包含空值
  3. nil 指针:字段未设置

如果您习惯于不带指针的生成代码,您可能正在使用以 syntax = "proto3" 开头的 .proto 文件。字段存在性行为多年来有所变化

新的不透明 (Opaque) API

我们创建了新的不透明 (Opaque) API,以将生成代码 API 与底层的内存表示解耦。(现有)开放结构 (Open Struct) API 没有这种分离:它允许程序直接访问 protobuf 消息内存。例如,可以使用 flag 包将命令行标志值解析到 protobuf 消息字段中

var req logpb.LogEntry
flag.StringVar(&req.BackendServer, "backend", os.Getenv("HOST"), "…")
flag.Parse() // fills the BackendServer field from -backend flag

这种紧密耦合的问题在于我们永远无法改变 protobuf 消息在内存中的布局方式。解除这一限制可以带来许多实现上的改进,我们将在下面看到。

新的不透明 (Opaque) API 会带来哪些变化?下面是上面示例中生成的代码会如何改变

package logpb

type LogEntry struct {
  xxx_hidden_BackendServer *string // no longer exported
  xxx_hidden_RequestSize   uint32  // no longer exported
  xxx_hidden_IPAddress     *string // no longer exported
  // …internal fields elided…
}

func (l *LogEntry) GetBackendServer() string { … }
func (l *LogEntry) HasBackendServer() bool   { … }
func (l *LogEntry) SetBackendServer(string)  { … }
func (l *LogEntry) ClearBackendServer()      { … }
// …

使用不透明 (Opaque) API,结构字段被隐藏,不再能直接访问。取而代之的是,新的 accessor 方法允许获取、设置或清除字段。

不透明 (Opaque) 结构使用更少的内存

我们对内存布局做的一个改变是更有效地建模基本字段的字段存在性

  • (现有)开放结构 (Open Struct) API 使用指针,这为字段的空间成本增加了 64 位字。
  • 不透明 (Opaque) API 使用位字段 (bit fields),每个字段需要一位(忽略填充开销)。

使用更少的变量和指针也降低了分配器和垃圾回收器的负载。

性能提升在很大程度上取决于您的协议消息的形状:此更改只影响基本字段,如整数、布尔值、枚举和浮点数,但不影响字符串、重复字段或子消息(因为对于这些类型来说 收益较低)。

我们的基准测试结果表明,基本字段较少的消息性能与之前一样好,而基本字段较多的消息则以显著更少的分配进行解码

             │ Open Struct API │             Opaque API             │
             │    allocs/op    │  allocs/op   vs base               │
Prod#1          360.3k ± 0%       360.3k ± 0%  +0.00% (p=0.002 n=6)
Search#1       1413.7k ± 0%       762.3k ± 0%  -46.08% (p=0.002 n=6)
Search#2        314.8k ± 0%       132.4k ± 0%  -57.95% (p=0.002 n=6)

减少分配也使得解码 protobuf 消息更有效率

             │ Open Struct API │             Opaque API            │
             │   user-sec/op   │ user-sec/op  vs base              │
Prod#1         55.55m ± 6%        55.28m ± 4%  ~ (p=0.180 n=6)
Search#1       324.3m ± 22%       292.0m ± 6%  -9.97% (p=0.015 n=6)
Search#2       67.53m ± 10%       45.04m ± 8%  -33.29% (p=0.002 n=6)

(所有测量均在 AMD Castle Peak Zen 2 上完成。在 ARM 和 Intel CPU 上的结果相似。)

注意:proto3 带有隐式存在性 (implicit presence),同样不使用指针,因此如果您来自 proto3,您不会看到性能提升。如果您出于性能原因使用隐式存在性,放弃了区分空字段和未设置字段的便利性,那么不透明 (Opaque) API 现在可以在不损失性能的情况下使用显式存在性。

动机:延迟解码 (Lazy Decoding)

延迟解码 (Lazy decoding) 是一种性能优化,其中子消息的内容在首次访问时而不是在 proto.Unmarshal 期间进行解码。延迟解码可以通过避免不必要地解码从未访问过的字段来提高性能。

(现有)开放结构 (Open Struct) API 无法安全地支持延迟解码。虽然开放结构 API 提供了 getter,但将(未解码的)结构字段暴露在外将非常容易出错。为了确保在字段首次访问之前立即运行解码逻辑,我们必须将字段设为私有,并通过 getter 和 setter 函数来中介所有对它的访问。

这种方法使得使用不透明 (Opaque) API 实现延迟解码成为可能。当然,并非所有工作负载都能从这种优化中受益,但对于那些确实受益的工作负载,结果可能会非常显著:我们看到过一些日志分析管道,它们根据顶级消息条件(例如 backend_server 是否是运行新 Linux 内核版本的机器之一)丢弃消息,并且可以跳过解码深度嵌套的消息子树。

举个例子,下面是我们包含的微基准测试结果,演示了延迟解码如何节省超过 50% 的工作量和超过 87% 的分配!

                  │   nolazy    │                lazy                │
                  │   sec/op    │   sec/op     vs base               │
Unmarshal/lazy-24   6.742µ ± 0%   2.816µ ± 0%  -58.23% (p=0.002 n=6)

                  │    nolazy    │                lazy                 │
                  │     B/op     │     B/op      vs base               │
Unmarshal/lazy-24   3.666Ki ± 0%   1.814Ki ± 0%  -50.51% (p=0.002 n=6)

                  │   nolazy    │               lazy                │
                  │  allocs/op  │ allocs/op   vs base               │
Unmarshal/lazy-24   64.000 ± 0%   8.000 ± 0%  -87.50% (p=0.002 n=6)

动机:减少指针比较错误

使用指针建模字段存在性容易引入与指针相关的 bug。

考虑一个在 LogEntry 消息中声明的枚举

message LogEntry {
  enum DeviceType {
    DESKTOP = 0;
    MOBILE = 1;
    VR = 2;
  };
  DeviceType device_type = 1;
}

一个简单的错误是将 device_type 枚举字段如此比较

if cv.DeviceType == logpb.LogEntry_DESKTOP.Enum() { // incorrect!

您发现 bug 了吗?条件比较的是内存地址而不是值。因为每次调用 Enum() accessor 都会分配一个新的变量,所以这个条件永远不会为真。检查应该这样写

if cv.GetDeviceType() == logpb.LogEntry_DESKTOP {

新的不透明 (Opaque) API 防止了这种错误:因为字段被隐藏,所有访问都必须通过 getter。

动机:减少意外共享错误

让我们考虑一个稍微复杂一点的与指针相关的 bug。假设您正试图稳定一个在高负载下失败的 RPC 服务。请求中间件的以下部分看起来是正确的,但只要一个客户发送大量请求,整个服务就会宕机

logEntry.IPAddress = req.IPAddress
logEntry.BackendServer = proto.String(hostname)
// The redactIP() function redacts IPAddress to 127.0.0.1,
// unexpectedly not just in logEntry *but also* in req!
go auditlog(redactIP(logEntry))
if quotaExceeded(req) {
    // BUG: All requests end up here, regardless of their source.
    return fmt.Errorf("server overloaded")
}

您发现 bug 了吗?第一行意外地复制了指针(从而在 logEntryreq 消息之间共享了指向的变量),而不是它的值。它应该这样写

logEntry.IPAddress = proto.String(req.GetIPAddress())

新的不透明 (Opaque) API 防止了这个问题,因为 setter 接受一个值(string)而不是指针

logEntry.SetIPAddress(req.GetIPAddress())

动机:修复尖锐边缘:反射

要编写不仅适用于特定消息类型(例如 logpb.LogEntry),而且适用于任何消息类型的代码,需要某种形式的反射。前面的例子使用了一个函数来编辑(隐藏)IP 地址。为了适用于任何类型的消息,它可以被定义为 func redactIP(proto.Message) proto.Message { … }

许多年前,实现像 redactIP 这样的函数唯一的选择是使用 Go 的 reflect,这导致了非常紧密的耦合:您只有生成器的输出,必须逆向工程输入 protobuf 消息定义可能是什么样子。google.golang.org/protobuf 模块的发布(2020 年 3 月)引入了 Protobuf 反射,这应该始终是首选:Go 的 reflect 包遍历数据结构的表示,而这应该是实现细节。Protobuf 反射遍历协议消息的逻辑树,而不关心其表示。

不幸的是,仅仅提供 Protobuf 反射是不够的,仍然暴露出一些尖锐边缘:在某些情况下,用户可能会意外地使用 Go 反射而不是 Protobuf 反射。

例如,使用 encoding/json 包(它使用 Go 反射)对 protobuf 消息进行编码在技术上是可行的,但结果不是规范的 Protobuf JSON 编码。请改用 protojson 包。

新的不透明 (Opaque) API 防止了这个问题,因为消息结构字段被隐藏:意外使用 Go 反射将看到一个空消息。这足够明确,可以将开发者引导至 Protobuf 反射。

动机:实现理想的内存布局

来自更高效的内存表示部分的基准测试结果已经表明,protobuf 性能很大程度上取决于具体的使用方式:消息是如何定义的?哪些字段被设置了?

为了让 Go Protobuf 对所有人都尽可能快,我们不能实现只帮助一个程序但损害其他程序性能的优化。

Go 编译器曾处于类似的情况,直到 Go 1.20 引入了配置文件引导优化 (PGO)。通过记录生产行为(通过性能分析)并将该配置文件反馈给编译器,我们允许编译器针对特定程序或工作负载做出更好的权衡。

我们认为使用配置文件来优化特定工作负载是进一步优化 Go Protobuf 的一个有前景的方法。不透明 (Opaque) API 使之成为可能:程序代码使用 accessor,并且在内存表示改变时无需更新,因此我们可以例如将很少设置的字段移动到一个溢出结构中。

迁移

您可以按照自己的计划迁移,或者根本不迁移——(现有)开放结构 (Open Struct) API 不会被移除。但是,如果您不使用新的不透明 (Opaque) API,您将无法从其改进的性能或未来针对它的优化中受益。

我们建议您在新开发中选择不透明 (Opaque) API。Protobuf Edition 2024(如果您还不熟悉,请参阅Protobuf 版本概述)将使不透明 (Opaque) API 成为默认。

混合 API (Hybrid API)

除了开放结构 (Open Struct) API 和不透明 (Opaque) API 外,还有混合 API (Hybrid API),它通过保持结构字段导出,使现有代码能够工作,同时通过添加新的 accessor 方法,也使得迁移到不透明 (Opaque) API 成为可能。

使用混合 API (Hybrid API),protobuf 编译器将在两个 API 级别生成代码:.pb.go 文件使用混合 API,而 _protoopaque.pb.go 版本使用不透明 (Opaque) API,可以通过使用 protoopaque 构建标签进行构建来选择。

将代码重写为不透明 (Opaque) API

有关详细说明,请参阅迁移指南。主要步骤是

  1. 启用混合 API (Hybrid API)。
  2. 使用 open2opaque 迁移工具更新现有代码。
  3. 切换到不透明 (Opaque) API。

已发布生成代码的建议:使用混合 API (Hybrid API)

protobuf 的小型使用可以完全在同一仓库内,但通常 .proto 文件会在不同团队拥有的不同项目之间共享。一个明显的例子是涉及不同公司的情况:要调用 Google API(使用 protobuf),请在您的项目中使用Go 版 Google Cloud 客户端库。将 Cloud 客户端库切换到不透明 (Opaque) API 是不可行的,因为这将是一个破坏性的 API 更改,但切换到混合 API (Hybrid API) 是安全的。

我们对此类发布生成代码(.pb.go 文件)的包的建议是:请切换到混合 API (Hybrid API)!请同时发布 .pb.go_protoopaque.pb.go 文件。protoopaque 版本允许您的消费者按自己的计划迁移。

启用延迟解码 (Lazy Decoding)

一旦您迁移到不透明 (Opaque) API,延迟解码即可用(但未启用)! 🎉

要启用:在您的 .proto 文件中,使用 [lazy = true] 注释来标注您的消息类型字段。

要选择退出延迟解码(尽管有 .proto 注释),protolazy 包文档描述了可用的选择退出方式,这些方式会影响单个 Unmarshal 操作或整个程序。

下一步

在过去几年中,通过自动化方式使用 open2opaque 工具,我们已将 Google 绝大多数的 .proto 文件和 Go 代码转换为不透明 (Opaque) API。随着我们将越来越多的生产工作负载迁移到它,我们不断改进不透明 API 的实现。

因此,我们预计您在尝试不透明 (Opaque) API 时不会遇到问题。如果您确实遇到任何问题,请在 Go Protobuf 问题跟踪器上告知我们

Go Protobuf 的参考文档可在 protobuf.dev → Go 参考 中找到。

下一篇文章:2024 年下半年 Go 开发者调查结果
上一篇文章:Go 满 15 周岁
博客索引