Go Opaque API 常见问题解答
Opaque API 是 Go 编程语言 Protocol Buffers 实现的最新版本。旧版本现在称为 Open Struct API。有关介绍,请参阅 Go Protobuf: The new Opaque API 博客文章。
本 FAQ 解答了有关新 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 { … }
Protobuf Edition 2024 的预计发布日期是 2025 年初。
为方便起见,您还可以使用 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 带来了诸多好处:
- 它使用更高效的内存表示,从而降低内存和垃圾回收成本。
- 它使延迟解码成为可能,这可以显著提高性能。
- 它修复了许多棘手的问题。使用 Opaque API 可以防止因指针地址比较、意外共享或不期望的 Go 反射使用而导致的错误。
- 它通过启用基于配置文件的优化,使理想的内存布局成为可能。
有关这些要点的更多详细信息,请参阅 Go Protobuf: The new Opaque API 博客文章。
构建器和设置器哪个更快?
通常,使用构建器的代码
_ = pb.M_builder{
F: &val,
}.Build()
比以下等效代码要慢:
m := &pb.M{}
m.SetF(val)
原因如下:
Build()
调用会迭代消息中的所有字段(即使是未明确设置的字段),并将其值(如果有)复制到最终消息中。对于字段众多的消息,这种线性性能很重要。- 存在潜在的额外堆分配 (
&val
)。 - 在存在 oneof 字段的情况下,构建器可能会大得多并使用更多内存。构建器为每个 oneof 联合成员都有一个字段,而消息本身可以将 oneof 存储为单个字段。
除了运行时性能外,如果您关心二进制文件的大小,避免使用构建器将产生更少的代码。
我应该如何使用构建器?
构建器被设计为用作*值*并立即调用 Build()
。避免使用指向构建器的指针或将构建器存储在变量中。
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 消息时倾向于将构建器类型传递给函数调用。Go proto 消息是可变的,因此无需将构建器传递给函数调用。只需传递 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)
构建器旨在模仿 Open Struct API 的复合字面量构造,而不是作为 proto 消息的替代表示。
推荐的模式也更高效。在构建器结构字面量上直接调用 Build()
的预期用法可以得到很好的优化。单独调用 Build()
则更难优化,因为编译器可能不容易识别哪些字段被填充。如果构建器生命周期更长,那么像标量这样的小对象很可能需要进行堆分配,并随后由垃圾回收器释放。
我应该使用构建器还是设置器?
当构造一个空的 protocol buffer 时,您应该使用 new
或空的复合字面量。在 Go 中,这两种方式都是构造零初始化值的惯用方法,并且比空的构建器性能更高。
m1 := new(pb.M)
m2 := &pb.M{}
// BAD: avoid: unnecessarily complex
m1 := pb.M_builder{}.Build()
在需要构造非空 protocol buffer 的情况下,您可以选择使用设置器或构建器。两者都可以,但大多数人会觉得构建器更具可读性。如果您编写的代码需要高性能,设置器通常比构建器性能略高。
// 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)
如果某些字段在设置前需要条件逻辑,您可以结合使用构建器和设置器。
m1 := pb.M1_builder{
Field1: value1,
}.Build()
if someCondition() {
m1.SetField2(value2)
m1.SetField3(value3)
}
我如何影响 open2opaque 的构建器行为?
open2opaque
工具的 --use_builders
标志可以有以下值:
--use_builders=everywhere
:始终使用构建器,无一例外。--use_builders=tests
:仅在测试中使用构建器,其他情况使用设置器。--use_builders=nowhere
:从不使用构建器。
我可以期待多大的性能提升?
这在很大程度上取决于您的工作负载。以下问题可以指导您的性能探索:
- 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?。
为什么只改变基本字段的内存布局?
公告博客文章的“Opaque 结构使用更少的内存”部分解释道:
这种性能改进 [更有效地建模字段存在性] 在很大程度上取决于您的 protobuf 消息形状:此更改仅影响整数、布尔值、枚举和浮点数等基本字段,而不影响字符串、重复字段或子消息。
一个自然的后续问题是,为什么字符串、重复字段和子消息在 Opaque API 中仍然是指针。答案是双重的。
考虑因素 1:内存使用
将子消息表示为值而不是指针会增加内存使用量:每个 Protobuf 消息类型都带有内部状态,即使子消息实际上未设置,这也会消耗内存。
对于字符串和重复字段,情况更为微妙。让我们比较一下使用字符串值与字符串指针的内存使用情况:
Go 变量类型 | 已设置? | 字数 | 字节数 |
---|---|---|---|
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
目前只允许对(非重复的)子消息进行延迟解码,但这个推理对所有字段类型都成立。