Go 不透明 API 常见问题解答
不透明 API 是 Go 编程语言的 Protocol Buffers 实现的最新版本。旧版本现在称为开放结构 API。请参阅Go Protobuf:新的不透明 API博文以获取介绍。
此常见问题解答回答了有关新 API 和迁移过程的常见问题。
创建新的 .proto 文件时,我应该使用哪个 API?
我们建议您为新开发选择不透明 API。Protobuf 2024 版本(请参阅Protobuf 版本概述)将使不透明 API 成为默认设置。
如何为我的消息启用新的不透明 API?
使用 Protobuf 2023 版本(在撰写本文时为当前版本),您可以通过在 .proto
文件中将 api_level
版本特性设置为 API_OPAQUE
来选择不透明 API。这可以按文件或按消息设置
edition = "2023";
package log;
import "google/protobuf/go_features.proto";
option features.(pb.go).api_level = API_OPAQUE;
message LogEntry { … }
Protobuf 2024 版本将默认使用不透明 API,这意味着您不再需要额外的导入或选项
edition = "2024";
package log;
message LogEntry { … }
Protobuf 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 级别,则需要先将所述文件迁移到版本。
如何启用延迟解码?
- 迁移您的代码以使用不透明实现。
- 在应延迟解码的 proto 子消息字段上设置
[lazy = true]
选项。 - 运行您的单元测试和集成测试,然后部署到暂存环境。
延迟解码会忽略错误吗?
否。proto.Marshal
将始终验证线路格式数据,即使解码推迟到首次访问也是如此。
我在哪里可以提问或报告问题?
如果您发现 open2opaque
迁移工具存在问题(例如代码重写不正确),请在open2opaque 问题跟踪器中报告。
如果您发现 Go Protobuf 存在问题,请在Go Protobuf 问题跟踪器中报告。
不透明 API 有哪些优势?
不透明 API 具有许多优点
- 它使用更高效的内存表示形式,从而降低内存和垃圾回收成本。
- 它使延迟解码成为可能,这可以显着提高性能。
- 它修复了许多尖锐的问题。使用不透明 API 时,可以防止由指针地址比较、意外共享或不希望使用的 Go 反射引起错误。
- 它通过启用配置文件驱动的优化,使理想的内存布局成为可能。
有关这些点的更多详细信息,请参阅Go Protobuf:新的不透明 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)
构建器旨在模仿开放结构 API 的复合字面量构造,而不是作为 proto 消息的替代表示形式。
推荐的模式也更高效。Build()
的预期用途(直接在构建器结构字面量上调用)可以很好地优化。单独调用 Build()
更难优化,因为编译器可能不容易识别哪些字段已填充。如果构建器的生命周期更长,则标量等小对象也很有可能必须在堆上分配,然后需要由垃圾回收器释放。
我应该使用构建器还是设置器?
在构造空协议缓冲区时,您应该使用 new
或空复合字面量。两者在 Go 中都是构造零初始化值的等效习惯用法,并且比空构建器更高效。
m1 := new(pb.M)
m2 := &pb.M{}
// BAD: avoid: unnecessarily complex
m1 := pb.M_builder{}.Build()
在您需要构造非空协议缓冲区的情况下,您可以选择使用设置器或构建器。两者都可以,但大多数人会发现构建器更易读。如果您正在编写的代码需要表现良好,设置器通常比构建器稍微高效。
// 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 输入记录计算统计信息)可能会在 Go Protobuf 中花费大约 50% 的 CPU 使用率。性能改进在这种工作负载中可能会很明显。另一方面,在仅在 Go Protobuf 中花费 3-5% CPU 使用率的程序中,与其他机会相比,性能改进通常微不足道。
- 您的程序对延迟解码的适应性如何?如果永远不会访问输入消息的很大一部分,则延迟解码可以节省大量工作。这种模式通常在代理服务器(按原样传递输入)或具有高选择性的日志分析管道(根据高级谓词丢弃许多记录)等作业中遇到。
- 您的消息定义是否包含许多具有显式存在的基本字段?不透明 API 对整数、布尔值、枚举和浮点数等基本字段使用更高效的内存表示形式,但不包括字符串、重复字段或子消息。
Proto2、Proto3 和版本与不透明 API 有什么关系?
术语 proto2 和 proto3 指的是您的 .proto
文件中不同的语法版本。Protobuf 版本是 proto2 和 proto3 的后继者。
不透明 API 仅影响 .pb.go
文件中生成的代码,而不影响您在 .proto
文件中编写的内容。
不透明 API 的工作方式相同,与您的 .proto
文件使用的语法或版本无关。但是,如果您想按文件选择不透明 API(而不是在运行 protoc
时使用命令行标志),您必须首先将文件迁移到版本。有关详细信息,请参阅如何为我的消息启用新的不透明 API?。
为什么只更改基本字段的内存布局?
这种性能改进[更有效地建模字段存在]很大程度上取决于您的 protobuf 消息形状:更改仅影响整数、布尔值、枚举和浮点数等基本字段,但不影响字符串、重复字段或子消息。
一个自然而然的后续问题是,为什么字符串、重复字段和子消息在不透明 API 中仍然是指针。答案是双重的。
考虑因素 1:内存使用
将子消息表示为值而不是指针会增加内存使用量:每个 Protobuf 消息类型都带有内部状态,即使子消息实际上未设置,也会消耗内存。
对于字符串和重复字段,情况更为微妙。让我们比较一下使用字符串值与字符串指针的内存使用情况
Go 变量类型 | 已设置? | 字 | #字节 |
---|---|---|---|
string | 是 | 2(数据,长度) | 16 |
string | 否 | 2(数据,长度) | 16 |
*string | 是 | 1(数据)+ 2(数据,长度) | 24 |
*string | 否 | 1(数据) | 8 |
(切片的情况类似,但切片标头需要 3 个字:数据、长度、容量。)
如果您的字符串字段绝大多数未设置,则使用指针可以节省 RAM。当然,这种节省是以在程序中引入更多分配和指针为代价的,这会增加垃圾回收器的负载。
不透明 API 的优势在于,我们可以在不更改用户代码的情况下更改表示形式。当我们引入它时,当前的内存布局对我们来说是最佳的,但如果我们今天或 5 年后进行测量,也许我们会选择不同的布局。
正如公告博文的“使理想的内存布局成为可能”部分中所述,我们的目标是在未来根据每个工作负载做出这些优化决策。
考虑因素 2:延迟解码
除了内存使用方面的考虑,还有另一个限制:启用延迟解码的字段必须由指针表示。
Protobuf 消息对于并发访问是安全的(但不是并发修改),因此如果两个不同的 goroutine 触发延迟解码,它们需要以某种方式协调。这种协调是通过使用sync/atomic
包来实现的,该包可以原子地更新指针,但不能原子地更新切片标头(这超出了一个字)。
虽然 protoc
目前仅允许对(非重复)子消息进行延迟解码,但这种推理适用于所有字段类型。