应用说明:字段存在

解释 Protobuf 字段的各种存在性跟踪规则。它还解释了具有基本类型的 singular proto3 字段的显式存在性跟踪的行为。

背景

字段存在性(Field presence)是指 Protobuf 字段是否具有值的概念。Protobuf 的存在性有两种不同的表现形式:隐式存在性,其中生成的消息 API 仅存储字段值;以及显式存在性,其中 API 还存储字段是否已设置。

存在性规则

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

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

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

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

  • 序列化时,具有隐式存在性的字段如果包含其默认值,则不会被序列化。
    • 对于数值类型,默认值为 0。
    • 对于枚举,默认值是值为零的枚举器。
    • 对于字符串、字节和 repeated 字段,默认值是零长度值。
  • “空”的长度分隔值(例如空字符串)可以在序列化值中有效表示:该字段是“存在”的,因为它出现在有线格式中。但是,如果生成的 API 不跟踪存在性,那么这些值可能不会被重新序列化;也就是说,空字段在序列化往返后可能变为“不存在”。
  • 反序列化时,重复的字段值可能会根据字段定义以不同的方式处理。
    • 重复的 repeated 字段通常会附加到该字段的 API 表示中。(请注意,序列化一个 packed 的 repeated 字段在标签流中只产生一个长度分隔的值。)
    • 重复的 optional 字段值遵循“后者为准”的规则。
  • oneof 字段在 API 级别公开了不变量,即一次只有一个字段被设置。然而,有线格式可能包含多个名义上属于该 oneof 的(标签,值)对。与 optional 字段类似,生成的 API 遵循“后者为准”的规则。
  • 在生成的 proto2 API 中,不会为枚举字段返回超出范围的值。然而,超出范围的值可能会作为未知字段存储在 API 中,即使有线格式的标签被识别了。

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

Protobuf 可以用人类可读的文本形式表示。两种值得注意的格式是 TextFormat(由生成的消息 DebugString 方法产生的输出格式)和 JSON。

这些格式有其自身的正确性要求,并且通常比标签-值流格式更严格。然而,TextFormat 更紧密地模仿了有线格式的语义,并且在某些情况下提供类似的语义(例如,将重复的名称-值映射附加到 repeated 字段)。特别是,与有线格式类似,TextFormat 仅包含存在的字段。

然而,JSON 是一种更严格的格式,不能有效表示有线格式或 TextFormat 的某些语义。

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

理论上,JSON 可以以保留语义的方式表示存在性。然而在实践中,存在性的正确性可能因实现选择而异,特别是如果选择 JSON 是为了与不使用 Protobuf 的客户端进行互操作。

Proto2 API 中的存在性

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

字段类型显式存在性
Singular 数值(整数或浮点数)✔️
Singular 枚举✔️
Singular 字符串或字节✔️
Singular 消息✔️
Repeated
Oneof✔️
映射

Singular 字段(所有类型)在生成的 API 中明确跟踪存在性。生成的消息接口包括查询字段存在性的方法。例如,字段 foo 有一个对应的 has_foo 方法。(具体名称遵循与字段访问器相同的特定语言命名约定。)这些方法在 Protobuf 实现内部有时被称为“hazzers”。

与 singular 字段类似,oneof 字段明确跟踪其成员中哪一个(如果有的话)包含值。例如,考虑这个 oneof 示例:

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

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

  • 一个用于 oneof 的 hazzer:has_foo
  • 一个 oneof case 方法:foo
  • 成员的 hazzers:has_a, has_b
  • 成员的 getters:a, b

Repeated 字段和 map 不跟踪存在性:不存在的 repeated 字段之间没有区别。

Proto3 API 中的存在性

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

字段类型optional显式存在性
Singular 数值(整数或浮点数)
Singular 数值(整数或浮点数)✔️
Singular 枚举
Singular 枚举✔️
Singular 字符串或字节
Singular 字符串或字节✔️
Singular 消息✔️
Singular 消息✔️
Repeated不适用
Oneof不适用✔️
映射不适用

与 proto2 API 类似,proto3 不对 repeated 字段显式跟踪存在性。如果没有 optional 标签,proto3 API 也不对基本类型(数值、字符串、字节和枚举)跟踪存在性。Oneof 字段确实公开存在性,尽管可能不会生成与 proto2 API 中相同的 hazzer 方法集。

这种在没有 optional 标签时不跟踪存在性的默认行为与 proto2 的行为不同。我们建议在 proto3 中使用 optional 标签,除非您有特殊的理由不这样做。

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

隐式存在性下,枚举类型字段的默认值是其对应的 0 值枚举器。根据 proto3 语法规则,所有枚举类型都必须有一个映射到 0 的枚举器值。按照惯例,这是一个 UNKNOWN 或类似命名的枚举器。如果零值在概念上超出了应用程序有效值的范围,则此行为可以被认为等同于显式存在性

Editions API 中的存在性

此表概述了在 editions API 中是否跟踪字段的存在性(包括生成的 API 和使用动态反射)。

字段类型显式存在性
Singular 数值(整数或浮点数)✔️
Singular 枚举✔️
Singular 字符串或字节✔️
Singular 消息†✔️
Repeated
Oneofs†✔️
映射

† 消息和 oneofs 从未有过隐式存在性,并且 editions 不允许您设置 field_presence = IMPLICIT

基于 Editions 的 API 默认显式跟踪字段存在性,类似于 proto2,除非将 features.field_presence 设置为 IMPLICIT。与 proto2 API 类似,基于 editions 的 API 不对 repeated 字段显式跟踪存在性。

语义差异

当设置了默认值时,隐式存在性序列化规则与显式存在性跟踪规则会产生可见的差异。对于一个 singular 的数值、枚举或字符串类型的字段:

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

合并的注意事项

隐式存在性规则下,目标字段实际上不可能从其默认值合并(使用 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显式存在性,那么通过客户端 B 的“往返”将从客户端 A 的角度来看是有损的。在该示例中,这不是一个安全的更改:客户端 A 要求(通过 assert)该字段存在;即使没有通过 API 进行任何修改,该要求在一个依赖于值和对等方的案例中也会失败。

如何在 Proto3 中启用显式存在性

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

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

.proto 文件变更

这是一个 proto3 消息的示例,其字段同时遵循无存在性显式存在性语义:

syntax = "proto3";
package example;

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

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

protoc 调用

v3.15.0 版本起,默认启用 proto3 消息的存在性跟踪,而在此之前,直到 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 message];
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 message];
if ([m hasFoo]) {
  // Clear the field:
  [m clearFoo];
} else {
  // Field is not present, so set it.
  m.foo = 1;
}

速查表

Proto2

字段存在性是否被跟踪?

字段类型是否跟踪?
Singular 字段
Singular 消息字段
oneof 中的字段
Repeated 字段和 map

Proto3

字段存在性是否被跟踪?

字段类型是否跟踪?
其他 singular 字段如果定义为 optional
Singular 消息字段
oneof 中的字段
Repeated 字段和 map

Edition 2023

字段存在性是否被跟踪?

字段类型(按优先级降序排列)是否跟踪?
Repeated 字段和 map
消息和 Oneof 字段
如果 features.field_presence 设置为 IMPLICIT 的其他 singular 字段
所有其他字段