Go 不透明 API 常见问题解答

关于不透明 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 级别,则需要先将所述文件迁移到版本。

如何启用延迟解码?

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

延迟解码会忽略错误吗?

否。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)

原因如下

  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)

构建器旨在模仿开放结构 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 变量类型已设置?#字节
string2(数据,长度)16
string2(数据,长度)16
*string1(数据)+ 2(数据,长度)24
*string1(数据)8

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

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

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

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

考虑因素 2:延迟解码

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

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

虽然 protoc 目前仅允许对(非重复)子消息进行延迟解码,但这种推理适用于所有字段类型。