应用说明:字段存在性

解释了 protobuf 字段的各种存在性跟踪原则。它还解释了基本类型的单数 proto3 字段的显式存在性跟踪行为。

背景

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

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

存在性原则

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

标签-值流(Wire Format)序列化中的存在性

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

proto 消息的生成 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✔️
Maps

单数字段(所有类型)在生成的 API 中显式跟踪存在性。生成的消息接口包括查询字段存在性的方法。例如,字段 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

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

Proto3 API 中的存在性

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

字段类型可选显式存在性
单数数值型(整数或浮点数)
单数数值型(整数或浮点数)✔️
单数枚举
单数枚举✔️
单数字符串或字节
单数字符串或字节✔️
单数消息✔️
单数消息✔️
重复不适用
Oneof不适用✔️
Maps不适用

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

在没有 optional 标签的情况下不跟踪存在性的这种默认行为与 proto2 行为不同。我们在 2023 版本中重新引入了 显式存在性 作为默认设置。我们建议在 proto3 中使用 optional 字段,除非您有不使用的特定理由。

隐式存在性原则下,对于序列化而言,默认值与“不存在”是同义的。要概念上“清除”字段(使其不会被序列化),API 用户会将其设置为默认值。

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

语义差异

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

  • 隐式存在性原则
    • 默认值不会被序列化。
    • 默认值不会从合并来源合并。
    • 要“清除”字段,请将其设置为其默认值。
    • 默认值可能意味着
      • 该字段已显式设置为其默认值,这在特定于应用程序的值域中是有效的;
      • 该字段通过设置其默认值而在概念上被“清除”;或
      • 该字段从未设置。
    • 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,或 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 调用

proto3 消息的存在性跟踪默认情况下已启用 自 v3.15.0 版本 起,直到 v3.12.0 版本,使用带有 protoc 的存在性跟踪时需要 --experimental_allow_proto3_optional 标志。

使用生成的代码

具有显式存在性的 proto3 字段(optional 标签)的生成代码将与 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,则其他单数字段
所有其他字段