Go 博客
Go Protobuf:新的不透明 API
[Protocol Buffers (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!
背景:(现有)开放结构 API
我们现在将现有 API 称为开放结构 API,因为生成的结构体类型可以被直接访问。在下一节中,我们将看到它与新的不透明 API 有何不同。
要使用 Protocol Buffers,您首先需要创建一个 .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 线格式。
您可以在生成的代码 API 文档中找到更多详细信息。
(现有)开放结构 API:字段存在性
此生成代码的一个重要方面是如何模拟字段存在性(字段是否已设置)。例如,上面的示例使用指针来模拟存在性,因此您可以将 BackendServer
字段设置为
proto.String("zrh01.prod")
:字段已设置并包含“zrh01.prod”。proto.String("")
:字段已设置(非nil
指针)但包含空值。nil
指针:字段未设置。
如果您习惯于生成的代码不包含指针,那么您可能正在使用以 syntax = "proto3"
开头的 .proto
文件。字段存在性的行为多年来一直在变化:
syntax = "proto2"
默认使用显式存在性。syntax = "proto3"
默认使用隐式存在性(其中情况 2 和 3 无法区分,两者都表示为空字符串),但后来扩展为允许使用optional
关键字选择加入显式存在性。edition = "2023"
,即 proto2 和 proto3 的后继者,默认使用显式存在性。
新的不透明 API
我们创建了新的不透明 API,以将生成的代码 API 与底层的内存表示分离开来。 (现有)开放结构 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 消息在内存中的布局方式。解除此限制可以实现许多实现上的改进,我们将在下文看到。
新的不透明 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() { … }
// …
使用不透明 API,结构体字段是隐藏的,无法再直接访问。取而代之的是,新的访问器方法允许获取、设置或清除字段。
不透明结构体占用更少的内存
我们对内存布局所做的一项更改是更有效地模拟基本字段的字段存在性。
- (现有)开放结构 API 使用指针,这为字段的空间成本增加了 64 位字。
- 不透明 API 使用位字段,每个字段需要一位(忽略填充开销)。
使用更少的变量和指针还可以降低分配器和垃圾回收器的负载。
性能提升在很大程度上取决于协议消息的形状:更改仅影响整数、布尔值、枚举和浮点数等基本字段,而不影响字符串、重复字段或子消息(因为对于这些类型利润较低)。
我们的基准测试结果显示,具有少量基本字段的消息的性能与以前一样好,而具有更多基本字段的消息的解码分配次数则显著减少。
│ 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 同样不使用指针,因此如果您是从 proto3 迁移过来的,将不会看到性能提升。如果您出于性能原因而使用隐式存在性,放弃区分空字段和未设置字段的便利性,那么不透明 API 现在可以在没有性能损失的情况下使用显式存在性。
动机:延迟解码
延迟解码是一种性能优化,其中子消息的内容在首次访问时解码,而不是在 proto.Unmarshal
期间解码。通过避免不必要地解码从未访问过的字段,延迟解码可以提高性能。
(现有)开放结构 API 无法安全地支持延迟解码。虽然开放结构 API 提供了 getter,但让(未解码的)结构体字段暴露出来会非常容易出错。为了确保解码逻辑在字段首次访问之前立即运行,我们必须使字段私有化,并通过 getter 和 setter 函数来协调对其的所有访问。
这种方法使得使用不透明 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)
动机:减少指针比较错误
使用指针模拟字段存在性会引起与指针相关的错误。
考虑一个在 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!
您发现错误了吗?条件比较的是内存地址而不是值。因为 Enum()
访问器每次调用都会分配一个新变量,所以条件永远不会为真。检查应该改为:
if cv.GetDeviceType() == logpb.LogEntry_DESKTOP {
新的不透明 API 可防止此错误:由于字段是隐藏的,所有访问都必须通过 getter。
动机:减少意外共享错误
让我们考虑一个稍微复杂一点的与指针相关的错误。假设您正在尝试稳定一个在高负载下会失败的 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")
}
您发现错误了吗?第一行意外地复制了指针(从而在 logEntry
和 req
消息之间共享了所指向的变量),而不是其值。它应该改为:
logEntry.IPAddress = proto.String(req.GetIPAddress())
新的不透明 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
包。
新的不透明 API 可防止此问题,因为消息结构体字段是隐藏的:意外使用 Go 反射将看到一个空消息。这足以引导开发人员转向 Protobuf 反射。
动机:实现理想的内存布局
更高效的内存表示部分中的基准测试结果已经表明,Protobuf 的性能在很大程度上取决于具体用法:消息如何定义?哪些字段已设置?
为了让 Go Protobuf 对所有人都尽可能快,我们不能实现仅对一个程序有帮助但会损害其他程序性能的优化。
Go 编译器以前也处于类似的情况,直到 Go 1.20 引入了基于配置文件的优化 (PGO)。通过记录生产行为(通过分析)并将该配置文件反馈给编译器,我们可以让编译器为特定程序或工作负载做出更好的权衡。
我们认为使用配置文件针对特定工作负载进行优化是进一步优化 Go Protobuf 的有前途的方法。不透明 API 使这些成为可能:程序代码使用访问器,并且在内存表示更改时不需要更新,因此我们可以例如将不常用的字段移到一个溢出结构体中。
迁移
您可以根据自己的进度进行迁移,甚至不必迁移——(现有)开放结构 API 不会被移除。但是,如果您不使用新的不透明 API,您将无法受益于其改进的性能,或针对它的未来优化。
我们建议您为新开发选择不透明 API。Protobuf Edition 2024(如果您还不熟悉,请参见Protobuf Editions Overview)将使不透明 API 成为默认选项。
混合 API
除了开放结构 API 和不透明 API 之外,还有混合 API,它通过保持结构体字段导出使现有代码保持正常工作,同时还通过添加新的访问器方法来支持迁移到不透明 API。
使用混合 API 时,Protobuf 编译器将在两个 API 级别上生成代码:.pb.go
文件位于混合 API 上,而 _protoopaque.pb.go
版本位于不透明 API 上,并且可以通过使用 protoopaque
构建标签进行选择。
重写代码以使用不透明 API
有关详细说明,请参阅迁移指南。高级步骤是:
- 启用混合 API。
- 使用
open2opaque
迁移工具更新现有代码。 - 切换到不透明 API。
已发布生成代码的建议:使用混合 API
Protobuf 的小型用法可以完全存在于同一个存储库中,但通常 .proto
文件会在由不同团队拥有的不同项目之间共享。一个明显的例子是涉及不同公司时:要调用 Google API(使用 Protobuf),请从您的项目中使用 Go 版 Google Cloud 客户端库。将 Cloud 客户端库切换到不透明 API 不是一个选项,因为那将是一个破坏性的 API 更改,但切换到混合 API 是安全的。
我们对发布生成代码(.pb.go
文件)的此类包的建议是,请切换到混合 API!请同时发布 .pb.go
和 _protoopaque.pb.go
文件。protoopaque
版本允许您的使用者根据自己的进度进行迁移。
启用延迟解码
迁移到不透明 API 后,即可使用(但尚未启用)延迟解码!🎉
要启用:在您的 .proto
文件中,使用 [lazy = true]
注释标记您的消息类型字段。
要选择退出延迟解码(尽管有 .proto
注释),protolazy
包文档描述了可用的选择退出方式,这些方式会影响单个 Unmarshal 操作或整个程序。
后续步骤
通过在过去几年中以自动化方式使用 open2opaque 工具,我们将 Google 的绝大多数 .proto
文件和 Go 代码转换为不透明 API。随着我们将越来越多的生产工作负载迁移到不透明 API,我们不断改进其实现。
因此,我们预计您在尝试不透明 API 时不会遇到问题。万一您确实遇到了任何问题,请在 Go Protobuf 问题跟踪器上告知我们。
Go Protobuf 的参考文档可以在 protobuf.dev → Go Reference 上找到。
下一篇文章: Go 开发者调查 2024 H2 结果
上一篇文章: Go 迎来 15 周年
博客索引