应用说明:字段存在

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

背景

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

存在规则

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

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

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

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

  • 序列化时,具有隐式存在的字段如果包含其默认值则不会被序列化。
    • 对于数字类型,默认值为 0。
    • 对于枚举,默认值是值为零的枚举器。
    • 对于字符串、字节和重复字段,默认值是零长度值。
  • “空”的长度定界值(例如空字符串)可以在序列化值中有效表示:该字段是“存在”的,因为它出现在线格式中。但是,如果生成的 API 不跟踪存在,则这些值可能不会被重新序列化;即,空字段在序列化往返后可能“不存在”。
  • 反序列化时,重复的字段值可能会根据字段定义以不同的方式处理。
    • 重复的 repeated 字段通常被附加到字段的 API 表示中。(请注意,序列化packed重复字段在线格式中只产生一个长度定界值。)
    • 重复的 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 字段:如果存在多个 case,它们是无序的。

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

Proto2 API 中的存在

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

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

单一字段(所有类型)在生成的 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
  • 成员的 Hazzers: has_a, has_b
  • 成员的 Getters: a, b

重复字段和 map 字段不跟踪存在:空重复字段与不存在的重复字段之间没有区别。

Proto3 API 中的存在

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

字段类型optional显式存在
单一数字(整数或浮点数)
单一数字(整数或浮点数)✔️
单一枚举
单一枚举✔️
单一字符串或字节
单一字符串或字节✔️
单一消息✔️
单一消息✔️
重复字段不适用
Oneof 字段不适用✔️
Map 字段不适用

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

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

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

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

版本 API 中的存在

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

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

† 消息和 oneof 字段从未有过隐式存在,并且版本不允许将 field_presence 设置为 IMPLICIT

基于版本的 API 显式跟踪字段存在,类似于 proto2,除非将 features.field_presence 设置为 IMPLICIT。与 proto2 API 类似,基于版本的 API 不会显式跟踪重复字段的存在。

语义差异

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

  • 隐式存在规则
    • 默认值不被序列化。
    • 默认值被合并进来。
    • 要“清除”一个字段,将其设置为默认值。
    • 默认值可能意味着
      • 该字段被显式设置为其默认值,这在应用程序特定值域中是有效的;
      • 该字段通过设置其默认值而概念上被“清除”;或
      • 该字段从未被设置。
    • 不生成 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. optional 字段添加到 .proto 文件中。
  2. 运行 protoc(至少 v3.15 版本,或使用 --experimental_allow_proto3_optional 标志的 v3.12 版本)。
  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

是否跟踪字段存在?

字段类型是否跟踪?
单一字段
单一消息字段
oneof 中的字段
重复字段和 map

Proto3

是否跟踪字段存在?

字段类型是否跟踪?
其他单一字段如果定义为 optional
单一消息字段
oneof 中的字段
重复字段和 map

2023 版本

是否跟踪字段存在?

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