应用笔记:字段存在

解释了 protobuf 字段的各种存在跟踪规则。它还解释了使用基本类型的单一 proto3 字段的显式存在跟踪的行为。

背景

字段存在是指 protobuf 字段是否具有值的概念。protobuf 的存在有两种不同的表现形式:隐式存在,其中生成的 message API 仅存储字段值,以及显式存在,其中 API 还存储字段是否已设置。

从历史上看,proto2 大多遵循显式存在,而 proto3 仅公开隐式存在语义。定义了 optional 标签的基本类型(数字、字符串、字节和枚举)的单一 proto3 字段具有显式存在,就像 proto2 一样(此功能在 3.15 版中默认启用)。

注意:我们建议始终为 proto3 基本类型添加 optional 标签。这为版本提供了更平滑的路径,版本默认使用显式存在。

存在规则

存在规则定义了在API 表示序列化表示之间进行转换的语义。隐式存在规则依赖于字段值本身在(反)序列化时做出决策,而显式存在规则则依赖于显式跟踪状态。

标签-值流(线格式)序列化中的存在

线格式是带标签的自定界值的流。根据定义,线格式表示一系列存在的值。换句话说,序列化中找到的每个值都表示一个存在的字段;此外,序列化不包含任何关于不存在的值的信息。

proto 消息的生成 API 包括(反)序列化定义,这些定义在 API 类型和定义上存在的(标签、值)对流之间进行转换。这种转换旨在在消息定义更改时保持向前和向后兼容;但是,这种兼容性在反序列化线格式消息时引入了一些(可能令人惊讶的)注意事项

  • 序列化时,如果字段包含其默认值,则不会序列化具有隐式存在的字段。
    • 对于数字类型,默认值为 0。
    • 对于枚举,默认值为零值枚举器。
    • 对于字符串、字节和重复字段,默认值为零长度值。
    • 对于消息,默认值为特定于语言的 null 值。
  • “空”长度限定值(例如空字符串)可以在序列化值中有效表示:字段是“存在”的,因为它出现在线格式中。但是,如果生成的 API 不跟踪存在,则这些值可能不会被重新序列化;即,空字段在序列化往返后可能“不存在”。
  • 反序列化时,重复字段值可能根据字段定义以不同的方式处理。
    • 重复的 repeated 字段通常会附加到字段的 API 表示中。(请注意,序列化打包的重复字段仅在标签流中生成一个长度限定的值。)
    • 重复的 optional 字段值遵循“最后一个获胜”规则。
  • oneof 字段公开 API 级别的不变式,即一次只能设置一个字段。但是,线格式可能包含多个(标签、值)对,这些对从概念上属于 oneof。类似于 optional 字段,生成的 API 遵循“最后一个获胜”规则。
  • 生成的 proto2 API 中不会为枚举字段返回超出范围的值。但是,超出范围的值可能会存储为 API 中的未知字段,即使已识别线格式标签。

命名字段映射格式中的存在

Protobuf 可以以人类可读的文本形式表示。两种著名的格式是 TextFormat(由生成的 message DebugString 方法生成的输出格式)和 JSON。

这些格式本身具有正确性要求,并且通常比带标签的值流格式更严格。但是,TextFormat 更接近于模拟线格式的语义,并且在某些情况下确实提供了类似的语义(例如,将重复的名称-值映射追加到重复字段)。特别是,与线格式类似,TextFormat 仅包含存在的字段。

但是,JSON 是一种更严格的格式,无法有效地表示线格式或 TextFormat 的某些语义。

  • 值得注意的是,JSON元素在语义上是无序的,并且每个成员必须具有唯一的名称。这与重复字段的 TextFormat 规则不同。
  • JSON 可能包含“不存在”的字段,这与其他格式的隐式存在规则不同
    • JSON 定义了一个 null 值,该值可用于表示已定义但不存在的字段
    • 即使重复字段值等于默认值(空列表),也可以将其包含在格式化输出中。
  • 由于 JSON 元素是无序的,因此无法明确地解释“最后一个获胜”规则。
    • 在大多数情况下,这没问题:JSON 元素必须具有唯一的名称:重复字段值不是有效的 JSON,因此不需要像 TextFormat 那样解析它们。
    • 但是,这意味着可能无法明确地解释 oneof 字段:如果存在多个情况,则它们是无序的。

理论上,JSON可以以保留语义的方式表示存在。但是,在实践中,存在正确性可能会因实现选择而异,尤其是在选择 JSON 作为与不使用 protobuf 的客户端进行互操作的方法时。

Proto2 API 中的存在

此表概述了 proto2 API 中字段是否跟踪存在(对于生成的 API 和使用动态反射的情况)。

字段类型显式存在
单一数字(整数或浮点数)✔️
单一枚举✔️
单一字符串或字节✔️
单一消息✔️
重复
Oneofs✔️
映射

单一字段(所有类型)在生成的 API 中显式跟踪存在。生成的 message 接口包括查询字段存在的方法。例如,字段 foo 具有相应的方法 has_foo。(具体名称遵循与字段访问器相同的特定于语言的命名约定。)这些方法有时在 protobuf 实现中被称为“hazzers”。

类似于单一字段,oneof 字段显式跟踪成员(如果有)中哪个包含值。例如,考虑此示例 oneof

oneof foo {
  int32 a = 1;
  float b = 2;
}

根据目标语言,生成的 API 通常会包含几个方法

  • oneof 的 hazzer:has_foo
  • oneof case 方法:foo
  • 成员的 hazzer:has_ahas_b
  • 成员的 Getter:ab

重复字段和映射不跟踪存在:不存在的重复字段之间没有区别。

Proto3 API 中的存在

此表概述了 proto3 API 中字段是否跟踪存在(对于生成的 API 和使用动态反射的情况)。

字段类型可选显式存在
单一数字(整数或浮点数)
单一数字(整数或浮点数)✔️
单一枚举
单一枚举✔️
单一字符串或字节
单一字符串或字节✔️
单一消息✔️
单一消息✔️
重复N/A

OneofsN/A

✔️
映射N/A

与 proto2 API 类似,proto3 不会显式跟踪重复字段的存在性。如果没有 `optional` 标签,proto3 API 也不会跟踪基本类型(数值、字符串、字节和枚举)的存在性。Oneof 字段明确地暴露了存在性,尽管可能不会像 proto2 API 中那样生成相同的 haszer 方法。

在 *隐式存在性* 规则下,默认值在序列化方面等同于“不存在”。为了概念上“清除”一个字段(以便它不会被序列化),API 用户会将其设置为默认值。

在 *隐式存在性* 下,枚举类型字段的默认值为相应的 0 值枚举器。根据 proto3 语法规则,所有枚举类型都必须具有映射到 0 的枚举器值。按照惯例,这是一个 `UNKNOWN` 或类似名称的枚举器。如果零值在应用程序的有效值域之外,则此行为可以被认为等同于 *显式存在性*。

语义差异

当设置默认值时,*隐式存在性* 序列化规则会导致与 *显式存在性* 跟踪规则之间出现明显的差异。对于具有数值、枚举或字符串类型的单个字段

  • *隐式存在性* 规则
    • 默认值不会被序列化。
    • 默认值 *不会* 被合并。
    • 要“清除”一个字段,将其设置为其默认值。
    • 默认值可能表示
      • 该字段被显式设置为其默认值,这在特定于应用程序的值域中是有效的;
      • 该字段通过设置其默认值被概念上“清除”;或
      • 该字段从未被设置。
    • `has_` 方法不会生成(但请参阅此列表之后的注释)
  • *显式存在性* 规则
    • 显式设置的值始终被序列化,包括默认值。
    • 未设置的字段永远不会被合并。
    • 显式设置的字段 – 包括默认值 – *会* 被合并。
    • 生成的 `has_foo` 方法指示字段 `foo` 是否已设置(并且未清除)。
    • 必须使用生成的 `clear_foo` 方法来清除(即,取消设置)该值。

**注意:**在大多数情况下,不会为隐式成员生成 `Has_` 方法。此行为的例外情况是 Dart,它使用 proto3 proto 模式文件生成 `has_` 方法。

合并注意事项

在 *隐式存在性* 规则下,目标字段实际上无法从其默认值中合并(使用 protobuf 的 API 合并函数)。这是因为默认值会被跳过,类似于 *隐式存在性* 序列化规则。合并仅使用更新(合并自)消息中未跳过的值来更新目标(合并至)消息。

合并行为的差异对依赖于部分“补丁”更新的协议有进一步的影响。如果未跟踪字段的存在性,则单独的更新补丁无法表示对默认值的更新,因为仅合并非默认值。

在这种情况下,更新以设置默认值需要某些外部机制,例如 `FieldMask`。但是,如果 *跟踪* 存在性,则所有显式设置的值 – 甚至默认值 – 都将合并到目标中。

更改兼容性注意事项

在 *显式存在性* 和 *隐式存在性* 之间更改字段对于线格式中的序列化值是二进制兼容的更改。但是,消息的序列化表示可能会有所不同,具体取决于用于序列化的消息定义的哪个版本。具体来说,当“发送方”显式地将字段设置为其默认值时

  • 遵循 *隐式存在性* 规则的序列化值不包含默认值,即使它已被显式设置。
  • 遵循 *显式存在性* 规则的序列化值包含每个“存在”字段,即使它包含默认值。

此更改可能是安全的,也可能是不安全的,具体取决于应用程序的语义。例如,考虑两个具有不同消息定义版本的客户端。

客户端 A 使用此消息定义,该定义遵循字段 `foo` 的 *显式存在性* 序列化规则

syntax = "proto3";
message Msg {
  optional int32 foo = 1;
}

客户端 B 使用相同消息的定义,但它遵循 *不存在性* 规则

syntax = "proto3";
message Msg {
  int32 foo = 1;
}

现在,考虑客户端 A 观察 `foo` 的存在性,因为客户端反复通过反序列化和重新序列化交换“相同”消息的场景

// Client A:
Msg m_a;
m_a.set_foo(1);                  // non-default value
assert(m_a.has_foo());           // OK
Send(m_a.SerializeAsString());   // to client B

// Client B:
Msg m_b;
m_b.ParseFromString(Receive());  // from client A
assert(m_b.foo() == 1);          // OK
Send(m_b.SerializeAsString());   // to client A

// Client A:
m_a.ParseFromString(Receive());  // from client B
assert(m_a.foo() == 1);          // OK
assert(m_a.has_foo());           // OK
m_a.set_foo(0);                  // default value
Send(m_a.SerializeAsString());   // to client B

// Client B:
Msg m_b;
m_b.ParseFromString(Receive());  // from client A
assert(m_b.foo() == 0);          // OK
Send(m_b.SerializeAsString());   // to client A

// Client A:
m_a.ParseFromString(Receive());  // from client B
assert(m_a.foo() == 0);          // OK
assert(m_a.has_foo());           // FAIL

如果客户端 A 依赖于 `foo` 的 *显式存在性*,则从客户端 A 的角度来看,通过客户端 B 的“往返”过程将是有损的。在这个例子中,这不是一个安全的更改:客户端 A 要求(通过 `assert`)该字段存在;即使没有通过 API 进行任何修改,该要求在值和对等依赖的情况下也会失败。

如何在 Proto3 中启用显式存在

以下是使用 proto3 字段跟踪支持的一般步骤

  1. 向 `.proto` 文件添加 `optional` 字段。
  2. 运行 `protoc`(至少 v3.15 或使用 `--experimental_allow_proto3_optional` 标志的 v3.12)。
  3. 在应用程序代码中使用生成的“haszer”方法和“clear”方法,而不是比较或设置默认值。

.proto 文件更改

这是一个 proto3 消息的示例,其中字段遵循 *不存在性* 和 *显式存在性* 语义

syntax = "proto3";
package example;

message MyMessage {
  // implicit presence:
  int32 not_tracked = 1;

  // Explicit presence:
  optional int32 tracked = 2;
}

protoc 调用

proto3 消息的存在性跟踪默认启用 自 v3.15.0 版本起,以前直到 v3.12.0 版本,在使用 protoc 进行存在性跟踪时需要 `--experimental_allow_proto3_optional` 标志。

使用生成的代码

具有 *显式存在性* ( `optional` 标签) 的 proto3 字段的生成代码将与 proto2 文件中的代码相同。

这是下面“隐式存在性”示例中使用的定义

syntax = "proto3";
package example;
message Msg {
  int32 foo = 1;
}

这是下面“显式存在性”示例中使用的定义

syntax = "proto3";
package example;
message Msg {
  optional int32 foo = 1;
}

在示例中,函数 `GetProto` 构造并返回类型为 `Msg` 的消息,其内容未指定。

C++ 示例

隐式存在性

Msg m = GetProto();
if (m.foo() != 0) {
  // "Clear" the field:
  m.set_foo(0);
} else {
  // Default value: field may not have been present.
  m.set_foo(1);
}

显式存在性

Msg m = GetProto();
if (m.has_foo()) {
  // Clear the field:
  m.clear_foo();
} else {
  // Field is not present, so set it.
  m.set_foo(1);
}

C# 示例

隐式存在性

var m = GetProto();
if (m.Foo != 0) {
  // "Clear" the field:
  m.Foo = 0;
} else {
  // Default value: field may not have been present.
  m.Foo = 1;
}

显式存在性

var m = GetProto();
if (m.HasFoo) {
  // Clear the field:
  m.ClearFoo();
} else {
  // Field is not present, so set it.
  m.Foo = 1;
}

Go 示例

隐式存在性

m := GetProto()
if m.Foo != 0 {
  // "Clear" the field:
  m.Foo = 0
} else {
  // Default value: field may not have been present.
  m.Foo = 1
}

显式存在性

m := GetProto()
if m.Foo != nil {
  // Clear the field:
  m.Foo = nil
} else {
  // Field is not present, so set it.
  m.Foo = proto.Int32(1)
}

Java 示例

这些示例使用 `Builder` 来演示清除。只需检查存在性并从 `Builder` 获取值即可遵循与消息类型相同的 API。

隐式存在性

Msg.Builder m = GetProto().toBuilder();
if (m.getFoo() != 0) {
  // "Clear" the field:
  m.setFoo(0);
} else {
  // Default value: field may not have been present.
  m.setFoo(1);
}

显式存在性

Msg.Builder m = GetProto().toBuilder();
if (m.hasFoo()) {
  // Clear the field:
  m.clearFoo()
} else {
  // Field is not present, so set it.
  m.setFoo(1);
}

Python 示例

隐式存在性

m = example.Msg()
if m.foo != 0:
  # "Clear" the field:
  m.foo = 0
else:
  # Default value: field may not have been present.
  m.foo = 1

显式存在性

m = example.Msg()
if m.HasField('foo'):
  # Clear the field:
  m.ClearField('foo')
else:
  # Field is not present, so set it.
  m.foo = 1

Ruby 示例

隐式存在性

m = Msg.new
if m.foo != 0
  # "Clear" the field:
  m.foo = 0
else
  # Default value: field may not have been present.
  m.foo = 1
end

显式存在性

m = Msg.new
if m.has_foo?
  # Clear the field:
  m.clear_foo
else
  # Field is not present, so set it.
  m.foo = 1
end

Javascript 示例

隐式存在性

var m = new Msg();
if (m.getFoo() != 0) {
  // "Clear" the field:
  m.setFoo(0);
} else {
  // Default value: field may not have been present.
  m.setFoo(1);
}

显式存在性

var m = new Msg();
if (m.hasFoo()) {
  // Clear the field:
  m.clearFoo()
} else {
  // Field is not present, so set it.
  m.setFoo(1);
}

Objective-C 示例

隐式存在性

Msg *m = [[Msg alloc] init];
if (m.foo != 0) {
  // "Clear" the field:
  m.foo = 0;
} else {
  // Default value: field may not have been present.
  m.foo = 1;
}

显式存在性

Msg *m = [[Msg alloc] init];
if (m.hasFoo()) {
  // Clear the field:
  [m clearFoo];
} else {
  // Field is not present, so set it.
  [m setFoo:1];
}

备忘单

Proto2

是否跟踪字段存在性?

字段类型跟踪?
单个字段
单个消息字段
oneof 中的字段
重复字段和映射

Proto3

是否跟踪字段存在性?

字段类型跟踪?
*其他* 单个字段如果定义为 `optional`
单个消息字段
oneof 中的字段
重复字段和映射

2023 版

是否跟踪字段存在性?

字段类型跟踪?
默认
`features.field_presence` 设置为 `LEGACY_REQUIRED`
`features.field_presence` 设置为 `IMPLICIT`
重复字段和映射