Go Opaque API 常见问题

关于 Opaque API 的常见问题列表。

Opaque API 是 Protocol Buffers 在 Go 编程语言中的最新实现版本。旧版本现在称为 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 {  }

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:新的 Opaque API 博文

Builders 和 Setters 哪个更快?

通常,使用 builders 的代码

_ = pb.M_builder{
  F: &val,
}.Build()

比以下等效代码更慢

m := &pb.M{}
m.SetF(val)

原因如下

  1. Build() 调用会遍历消息中的所有字段(即使是未显式设置的字段),并将其值(如果存在)复制到最终消息中。对于字段数量多的消息,这种线性性能影响很大。
  2. 可能存在额外的堆分配 (&val)。
  3. 在存在 oneof 字段的情况下,builder 可能明显更大并使用更多内存。Builders 为每个 oneof 联合成员都有一个字段,而消息本身可以将 oneof 存储为单个字段。

除了运行时性能,如果您关注二进制大小,避免使用 builders 会减少代码量。

如何使用 Builders?

Builders 被设计为作为使用,并立即调用 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)

Builders 被设计为模仿 Open Struct API 的复合字面量构造,而不是作为 proto 消息的替代表示。

推荐的模式也更具性能。在 builder 结构体字面量上直接调用 Build() 的预期用法可以很好地优化。单独调用 Build() 则难以优化,因为编译器可能不容易识别哪些字段被填充。如果 builder 存活时间较长,像标量这样的小对象很可能必须进行堆分配,稍后需要由垃圾收集器释放。

我应该使用 Builders 还是 Setters?

构建空的 protocol buffer 时,您应该使用 new 或空的复合字面量。这两种方式在 Go 中构造零初始值都是等效的惯用法,并且比空的 builder 性能更好。

m1 := new(pb.M)
m2 := &pb.M{}
// BAD: avoid: unnecessarily complex
m1 := pb.M_builder{}.Build()

在需要构建非空 protocol buffer 的情况下,您可以在使用 setters 或 builders 之间进行选择。两者都可以,但大多数人会觉得 builders 更具可读性。如果您编写的代码需要高性能,setters 通常比 builders 性能稍好

// 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 和 setters。

m1 := pb.M1_builder{
    Field1: value1,
}.Build()
if someCondition() {
    m1.SetField2(value2)
    m1.SetField3(value3)
}

如何影响 open2opaque 的 Builder 行为?

open2opaque 工具的 --use_builders 标志可以有以下值

  • --use_builders=everywhere:始终使用 builders,无例外。
  • --use_builders=tests:仅在测试中使用 builders,其他情况下使用 setters。
  • --use_builders=nowhere:从不使用 builders。

我能期待多大的性能提升?

这很大程度上取决于您的工作负载。以下问题可以指导您进行性能探索

  • Go Protobuf 在您的 CPU 使用中占多大比例?某些工作负载,例如根据 Protobuf 输入记录计算统计信息的日志分析流水线,其 CPU 使用率约有 50% 花在 Go Protobuf 上。在这样的工作负载中,性能改进可能会非常明显。另一方面,在 Go Protobuf 只占其 CPU 使用率 3-5% 的程序中,性能改进与其他机会相比通常微不足道。
  • 您的程序对惰性解码的适应性如何?如果输入消息的很大一部分从未被访问,惰性解码可以节省大量工作。这种模式通常在代理服务器(按原样传递输入)或具有高选择性(根据高级谓词丢弃许多记录)的日志分析流水线等工作中遇到。
  • 您的消息定义是否包含许多带有显式存在的初级字段?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 (数据, 长度)16
string2 (数据, 长度)16
*string1 (数据) + 2 (数据, 长度)24
*string1 (数据)8

(slices 的情况类似,但 slice 头部需要 3 个字:数据、长度、容量。)

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

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

正如公告博文中的“使理想的内存布局成为可能”部分所述,我们未来旨在根据每个工作负载进行这些优化决策。

考量 2:惰性解码

除了内存使用的考量,还有另一个限制:启用了惰性解码的字段必须由指针表示。

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

虽然 protoc 目前仅允许对(非重复)子消息进行惰性解码,但此推论适用于所有字段类型。