Go Opaque API 常见问题解答
Opaque API 是 Go 编程语言的 Protocol Buffers 实现的最新版本。旧版本现在称为 Open Struct API。有关介绍,请参阅 Go Protobuf:新的 Opaque API 这篇博文。
本常见问题解答回答了有关新 API 和迁移过程的常见问题。
在创建新的 .proto 文件时,我应该使用哪个 API?
我们建议您在新的开发中选择 Opaque API。
Protobuf Edition 2024 (参见 Protobuf Editions 概述) 已将 Opaque API 设为默认。
如何为我的消息启用新的 Opaque API?
从 Protobuf Edition 2023 开始,您可以在 .proto 文件中将 api_level editions 特性设置为 API_OPAQUE 来选择 Opaque API。这可以按文件或按消息设置。
edition = "2023";
package log;
import "google/protobuf/go_features.proto";
option features.(pb.go).api_level = API_OPAQUE;
message LogEntry { … }
Protobuf Edition 2024 默认为 Opaque API,这意味着您不再需要额外的导入或选项。
edition = "2024";
package log;
message LogEntry { … }
为方便起见,您还可以使用 protoc 命令行标志覆盖默认的 API 级别:
protoc […] --go_opt=default_api_level=API_HYBRID
要为特定文件 (而不是所有文件) 覆盖默认 API 级别,请使用 apilevelM 映射标志 (类似于用于导入路径的 M 标志):
protoc […] --go_opt=apilevelMhello.proto=API_HYBRID
命令行标志也适用于仍使用 proto2 或 proto3 语法的 .proto 文件,但如果您想在 .proto 文件中选择 API 级别,则需要先将该文件迁移到 editions。
如何启用延迟解码?
- 将您的代码迁移到使用 opaque 实现。
- 在应该延迟解码的 proto 子消息字段上设置
[lazy = true]选项。 - 运行您的单元测试和集成测试,然后部署到预发布环境。
延迟解码会忽略错误吗?
不会。 proto.Marshal 总是会验证线路格式数据,即使解码被推迟到首次访问时也是如此。
我可以在哪里提问或报告问题?
如果您发现 open2opaque 迁移工具有问题(例如代码重写不正确),请在 open2opaque 问题跟踪器中报告。
如果您发现 Go Protobuf 有问题,请在 Go Protobuf 问题跟踪器中报告。
Opaque API 有什么好处?
Opaque API 带来了许多好处:
- 它使用更高效的内存表示,从而减少了内存和垃圾回收(Garbage Collection)的成本。
- 它使延迟解码成为可能,这可以显著提高性能。
- 它修复了许多棘手的问题。使用 Opaque API 可以防止因指针地址比较、意外共享或不希望的 Go 反射使用而导致的错误。
- 它通过启用基于性能分析的优化,使理想的内存布局成为可能。
有关这些要点的更多详细信息,请参阅 Go Protobuf:新的 Opaque API 博客文章。
Builder 和 Setter 哪个更快?
通常,使用 builder 的代码
_ = pb.M_builder{
F: &val,
}.Build()
比以下等效代码慢
m := &pb.M{}
m.SetF(val)
原因如下:
Build()调用会遍历消息中的所有字段(即使是那些未显式设置的字段),并将其值(如果有)复制到最终的消息中。对于字段众多的消息,这种线性性能很重要。- 存在一个潜在的额外堆分配 (
&val)。 - 在存在 oneof 字段的情况下,builder 可能会大得多并使用更多内存。Builder 为每个 oneof 联合成员都有一个字段,而消息本身可以将 oneof 存储为单个字段。
除了运行时性能,如果您关心二进制文件的大小,避免使用 builder 将会减少代码量。
我该如何使用 Builder?
Builder 被设计为作为*值*使用,并立即调用 Build()。避免使用指向 builder 的指针或将 builder 存储在变量中。
m := pb.M_builder{
// ...
}.Build()
// BAD: Avoid using a pointer
m := (&pb.M_builder{
// ...
}).Build()
// BAD: avoid storing in a variable
b := pb.M_builder{
// ...
}
m := b.Build()
在其他一些语言中,Proto 消息是不可变的,因此用户在构建 proto 消息时倾向于将 builder 类型传入函数调用。Go proto 消息是可变的,因此无需将 builder 传入函数调用。只需传递 proto 消息即可。
// BAD: avoid passing a builder around
func populate(mb *pb.M_builder) {
mb.Field1 = proto.Int32(4711)
//...
}
// ...
mb := pb.M_builder{}
populate(&mb)
m := mb.Build()
func populate(mb *pb.M) {
mb.SetField1(4711)
//...
}
// ...
m := &pb.M{}
populate(m)
Builder 旨在模仿 Open Struct API 的复合字面量构造,而不是作为 proto 消息的替代表示。
推荐的模式也更高效。在 builder 结构体字面量上直接调用 Build() 的预期用法可以得到很好的优化。单独调用 Build() 则更难优化,因为编译器可能不容易识别哪些字段被填充。如果 builder 的生命周期较长,标量等小对象也很有可能需要进行堆分配,并随后由垃圾回收器释放。
我应该使用 Builder 还是 Setter?
当构造一个空的 protocol buffer 时,您应该使用 new 或空的复合字面量。在 Go 中,两者都是构造零初始化值的同样惯用的方式,并且比空的 builder 更高效。
m1 := new(pb.M)
m2 := &pb.M{}
// BAD: avoid: unnecessarily complex
m1 := pb.M_builder{}.Build()
在需要构造非空 protocol buffer 的情况下,您可以选择使用 setter 或 builder。两者都可以,但大多数人会觉得 builder 更具可读性。如果您编写的代码需要高性能,setter 通常比 builder 性能稍好。
// Recommended: using builders
m1 := pb.M1_builder{
Submessage: pb.M2_builder{
Submessage: pb.M3_builder{
String: proto.String("hello world"),
Int: proto.Int32(42),
}.Build(),
Bytes: []byte("hello"),
}.Build(),
}.Build()
// Also okay: using setters
m3 := &pb.M3{}
m3.SetString("hello world")
m3.SetInt(42)
m2 := &pb.M2{}
m2.SetSubmessage(m3)
m2.SetBytes([]byte("hello"))
m1 := &pb.M1{}
m1.SetSubmessage(m2)
如果某些字段在设置前需要条件逻辑,您可以结合使用 builder 和 setter。
m1 := pb.M1_builder{
Field1: value1,
}.Build()
if someCondition() {
m1.SetField2(value2)
m1.SetField3(value3)
}
我如何影响 open2opaque 的 Builder 行为?
open2opaque 工具的 --use_builders 标志可以有以下值:
--use_builders=everywhere:总是使用 builder,没有例外。--use_builders=tests:仅在测试中使用 builder,其他情况使用 setter。--use_builders=nowhere:从不使用 builder。
我可以期望获得多大的性能提升?
这在很大程度上取决于您的工作负载。以下问题可以指导您的性能探索:
- Go Protobuf 占您 CPU 使用率的百分比有多大?某些工作负载,例如基于 Protobuf 输入记录计算统计信息的日志分析管道,可能会将大约 50% 的 CPU 使用率花费在 Go Protobuf 上。在这样的工作负载中,性能改进可能会非常明显。而在另一个极端,在仅花费 3-5% CPU 使用率在 Go Protobuf 上的程序中,性能改进与其他机会相比通常微不足道。
- 您的程序对延迟解码的适应性如何?如果输入消息的大部分从未被访问,延迟解码可以节省大量工作。这种模式通常出现在像代理服务器(直接传递输入)或具有高选择性的日志分析管道(根据高层谓词丢弃许多记录)这样的作业中。
- 您的消息定义是否包含许多具有显式存在性的基础字段?Opaque API 对整数、布尔值、枚举和浮点数等基础字段使用更高效的内存表示,但对字符串、重复字段或子消息则不然。
Proto2、Proto3 和 Editions 与 Opaque API 有什么关系?
术语 proto2 和 proto3 指的是您 .proto 文件中的不同语法版本。Protobuf Editions 是 proto2 和 proto3 的继任者。
Opaque API 只影响 .pb.go 文件中生成的代码,不影响您在 .proto 文件中编写的内容。
无论您的 .proto 文件使用哪种语法或版本,Opaque API 的工作方式都相同。但是,如果您希望按文件选择 Opaque API(而不是在运行 protoc 时使用命令行标志),您必须首先将该文件迁移到 editions。详情请参阅如何为我的消息启用新的 Opaque API?。
为什么只改变基础字段的内存布局?
这种性能改进[更有效地建模字段存在性]在很大程度上取决于您的 protobuf 消息形状:这种变化只影响基础字段,如整数、布尔值、枚举和浮点数,而不影响字符串、重复字段或子消息。
一个自然的后续问题是,为什么在 Opaque API 中字符串、重复字段和子消息仍然是指针。答案是双重的。
考虑因素 1:内存使用
将子消息表示为值而不是指针会增加内存使用:每个 Protobuf 消息类型都带有内部状态,即使子消息实际上没有被设置,这些状态也会消耗内存。
对于字符串和重复字段,情况更为微妙。让我们比较一下使用字符串值与字符串指针的内存使用情况:
| Go 变量类型 | 已设置? | 字 (word) | #字节 |
|---|---|---|---|
string | 是 | 2 (data, len) | 16 |
string | 否 | 2 (data, len) | 16 |
*string | 是 | 1 (data) + 2 (data, len) | 24 |
*string | 否 | 1 (data) | 8 |
(对于切片,情况类似,但切片头需要 3 个字:data, len, cap。)
如果您的字符串字段绝大多数都未设置,使用指针可以节省 RAM。当然,这种节省是以在程序中引入更多分配和指针为代价的,这会增加垃圾回收器的负载。
Opaque API 的优势在于我们可以更改表示而无需对用户代码进行任何更改。当前的内存布局在我们引入它时是最佳的,但如果我们在今天或未来 5 年进行测量,我们可能会选择不同的布局。
如公告博文的“使理想的内存布局成为可能”部分所述,我们的目标是在未来能够根据每个工作负载来做出这些优化决策。
考虑因素 2:延迟解码
除了内存使用的考虑,还有另一个限制:启用了延迟解码的字段必须由指针表示。
Protobuf 消息对于并发访问是安全的(但不是并发修改),因此如果两个不同的 goroutine 触发延迟解码,它们需要以某种方式进行协调。这种协调是通过使用 sync/atomic 包实现的,该包可以原子地更新指针,但不能更新切片头(因为它超过一个字)。
虽然 protoc 目前只允许对(非重复的)子消息进行延迟解码,但这个理由对所有字段类型都成立。