应用笔记:字段存在
背景
字段存在是指 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 元素必须具有唯一的名称:重复字段值不是有效的 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_a
、has_b
- 成员的 Getter:
a
、b
重复字段和映射不跟踪存在:空和不存在的重复字段之间没有区别。
Proto3 API 中的存在
此表概述了 proto3 API 中字段是否跟踪存在(对于生成的 API 和使用动态反射的情况)。
字段类型 | 可选 | 显式存在 |
---|---|---|
单一数字(整数或浮点数) | 否 | |
单一数字(整数或浮点数) | 是 | ✔️ |
单一枚举 | 否 | |
单一枚举 | 是 | ✔️ |
单一字符串或字节 | 否 | |
单一字符串或字节 | 是 | ✔️ |
单一消息 | 否 | ✔️ |
单一消息 | 是 | ✔️ |
重复 | N/A | |
Oneofs | N/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 字段跟踪支持的一般步骤
- 向 `.proto` 文件添加 `optional` 字段。
- 运行 `protoc`(至少 v3.15 或使用 `--experimental_allow_proto3_optional` 标志的 v3.12)。
- 在应用程序代码中使用生成的“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` | 否 |
重复字段和映射 | 否 |