Go Opaque API 常见问题解答

关于 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。

如何启用延迟解码?

  1. 迁移您的代码以使用 Opaque 实现。
  2. 在应该延迟解码的 proto 子消息字段上设置 [lazy = true] 选项。
  3. 运行您的单元测试和集成测试,然后部署到预发布环境。

使用延迟解码时错误会被忽略吗?

不会。即使解码被推迟到首次访问时,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)

原因如下:

  1. Build() 调用会迭代消息中的所有字段(即使是未明确设置的字段),并将其值(如果有)复制到最终消息中。对于字段众多的消息,这种线性性能很重要。
  2. 存在潜在的额外堆分配 (&val)。
  3. 在存在 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 变量类型已设置?字节数
string2 (data, len)16
string2 (data, len)16
*string1 (data) + 2 (data, len)24
*string1 (data)8

(切片的情况类似,但切片头需要 3 个字:data, len, cap。)

如果您的字符串字段绝大多数都未设置,使用指针可以节省 RAM。当然,这种节省是以向程序中引入更多分配和指针为代价的,这会增加垃圾回收器的负载。

Opaque API 的优势在于我们可以更改表示方式而无需对用户代码进行任何更改。当前的内存布局在我们引入它时是最佳的,但如果我们在今天或未来 5 年进行测量,也许我们会选择不同的布局。

公告博客文章的“实现理想的内存布局”部分所述,我们的目标是在未来根据每个工作负载来做出这些优化决策。

考虑因素 2:延迟解码

除了内存使用方面的考虑外,还有另一个限制:启用了延迟解码的字段必须由指针表示。

Protobuf 消息对于并发访问是安全的(但不是并发修改),因此如果两个不同的 goroutine 触发延迟解码,它们需要以某种方式进行协调。这种协调是通过使用 sync/atomic 实现的,该包可以原子地更新指针,但不能更新切片头(因为其大小超过一个)。

虽然 protoc 目前只允许对(非重复的)子消息进行延迟解码,但这个推理对所有字段类型都成立。