應用程式注意事項:欄位存在

說明 protobuf 欄位的各種存在追蹤規範。它也說明了具有基本類型的單數 proto3 欄位的明確存在追蹤行為。

背景

欄位存在是指 protobuf 欄位是否具有值的概念。protobuf 的存在有兩種不同的表現形式:隱含存在,其中產生的訊息 API 僅儲存欄位值;以及明確存在,其中 API 也儲存欄位是否已設定。

從歷史上看,proto2 主要遵循明確存在,而 proto3 僅公開隱含存在語意。使用 optional 標籤定義的基本類型(數值、字串、位元組和列舉)的單數 proto3 欄位具有明確存在,就像 proto2 一樣(此功能預設為啟用,自 3.15 版發行以來)。

存在規範

存在規範定義了在API 表示法序列化表示法之間轉換的語意。隱含存在規範依賴於欄位值本身來在(反)序列化時做出決策,而明確存在規範則依賴於明確追蹤狀態。

標籤值串流 (Wire Format) 序列化中的存在

Wire Format 是一種標記的、自我分隔值的串流。根據定義,Wire Format 表示一系列存在的值。換句話說,在序列化中找到的每個值都表示一個存在的欄位;此外,序列化不包含有關不存在的值的任何資訊。

proto 訊息的產生 API 包含(反)序列化定義,這些定義在 API 類型和定義上存在的 (標籤、值) 對串流之間進行轉換。此轉換旨在向前和向後相容訊息定義的變更;但是,這種相容性在還原序列化 Wire Format 訊息時引入了一些(可能令人驚訝的)考量。

  • 在序列化時,如果具有隱含存在的欄位包含其預設值,則不會序列化這些欄位。
    • 對於數值類型,預設值為 0。
    • 對於列舉,預設值為零值列舉值。
    • 對於字串、位元組和重複欄位,預設值為零長度值。
  • 「空」長度分隔值(例如空字串)可以在序列化值中有效表示:該欄位是「存在的」,就表示它出現在 Wire Format 中。但是,如果產生的 API 不追蹤存在,則這些值可能不會重新序列化;即,空欄位在序列化往返之後可能「不存在」。
  • 在還原序列化時,重複的欄位值可能會根據欄位定義以不同的方式處理。
    • 重複的 repeated 欄位通常會附加到欄位的 API 表示法。(請注意,序列化封裝的重複欄位只會在標籤串流中產生一個長度分隔值。)
    • 重複的 optional 欄位值遵循「最後一個獲勝」的規則。
  • oneof 欄位公開了 API 層級的不變性,即一次只設定一個欄位。但是,Wire Format 可能包含多個 (標籤、值) 對,這些對概念上屬於 oneof。與 optional 欄位類似,產生的 API 遵循「最後一個獲勝」的規則。
  • 對於產生的 proto2 API 中的列舉欄位,不會傳回超出範圍的值。但是,超出範圍的值可以作為 API 中的未知欄位儲存,即使 Wire Format 標籤已被識別。

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

Protobuf 可以以人類可讀的文字形式表示。兩種值得注意的格式是 TextFormat(由產生的訊息 DebugString 方法產生的輸出格式)和 JSON。

這些格式有自己的正確性要求,並且通常比標籤值串流格式更嚴格。但是,TextFormat 更接近於模仿 Wire Format 的語意,並且在某些情況下,確實提供了類似的語意(例如,將重複的名稱-值映射附加到重複欄位)。特別是,與 Wire Format 類似,TextFormat 僅包含存在的欄位。

但是,JSON 是一種嚴格得多的格式,並且無法有效表示 Wire Format 或 TextFormat 的某些語意。

  • 值得注意的是,JSON 元素在語意上是無序的,並且每個成員都必須具有唯一的名稱。這與 TextFormat 對於重複欄位的規則不同。
  • JSON 可能包含「不存在」的欄位,這與其他格式的隱含存在規範不同
    • JSON 定義了一個 null 值,可用於表示已定義但不存在的欄位
    • 重複的欄位值可以包含在格式化輸出中,即使它們等於預設值(空清單)。
  • 由於 JSON 元素是無序的,因此無法明確地解釋「最後一個獲勝」規則。
    • 在大多數情況下,這沒問題:JSON 元素必須具有唯一的名稱:重複的欄位值不是有效的 JSON,因此它們不需要像 TextFormat 那樣被解析。
    • 但是,這表示可能無法明確地解釋 oneof 欄位:如果存在多個案例,它們是無序的。

從理論上講,JSON 可以以語意保留的方式表示存在。但是,在實務中,存在的正確性可能會因實作選擇而異,尤其是在選擇 JSON 作為與未使用 protobuf 的用戶端互通的方式時。

Proto2 API 中的存在

此表概述了在 proto2 API 中是否追蹤欄位的存在(對於產生的 API 和使用動態反射兩者)

欄位類型明確存在
單數數值(整數或浮點數)✔️
單數列舉✔️
單數字串或位元組✔️
單數訊息✔️
重複
Oneofs✔️
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 和使用動態反射兩者)

欄位類型optional明確存在
單數數值(整數或浮點數)
單數數值(整數或浮點數)✔️
單數列舉
單數列舉✔️
單數字串或位元組
單數字串或位元組✔️
單數訊息✔️
單數訊息✔️
重複不適用
Oneofs不適用✔️
Maps不適用

與 proto2 API 類似,proto3 不會為重複欄位明確追蹤存在。如果沒有 optional 標籤,proto3 API 也不會為基本類型(數值、字串、位元組和列舉)追蹤存在。Oneof 欄位肯定會公開存在,儘管可能不會產生與 proto2 API 中相同的一組 hazzer 方法。

這種在沒有 optional 標籤的情況下不追蹤存在的預設行為與 proto2 行為不同。我們重新引入了明確存在作為 2023 版中的預設值。我們建議將 optional 欄位與 proto3 一起使用,除非您有特定的理由不這樣做。

隱含存在規範下,預設值在序列化方面與「不存在」同義。為了概念上「清除」欄位(使其不會被序列化),API 使用者會將其設定為預設值。

隱含存在下的列舉類型欄位的預設值是相應的 0 值列舉值。在 proto3 語法規則下,所有列舉類型都必須具有映射到 0 的列舉值。按照慣例,這是一個 UNKNOWN 或類似命名的列舉值。如果零值在概念上超出應用程式的有效值範圍,則此行為可以被認為等同於明確存在

語意差異

當設定預設值時,隱含存在序列化規範會導致與明確存在追蹤規範的明顯差異。對於具有數值、列舉或字串類型的單數欄位

  • 隱含存在規範
    • 預設值不會序列化。
    • 預設值不會從中合併。
    • 若要「清除」欄位,請將其設定為預設值。
    • 預設值可能表示
      • 欄位已明確設定為其預設值,這在應用程式特定值範圍內是有效的;
      • 欄位透過設定其預設值而在概念上「清除」;或
      • 欄位從未設定。
    • has_ 方法未產生(但請參閱此清單後的注意事項)
  • 明確存在規範
    • 明確設定的值始終會序列化,包括預設值。
    • 未設定的欄位永遠不會從中合併。
    • 明確設定的欄位(包括預設值)從中合併。
    • 產生的 has_foo 方法指示欄位 foo 是否已設定(且未清除)。
    • 必須使用產生的 clear_foo 方法來清除(即,取消設定)值。

合併考量

隱含存在規則下,目標欄位實際上不可能從其預設值合併(使用 protobuf 的 API 合併功能)。這是因為預設值會被跳過,類似於隱含存在序列化規範。合併只會使用來自更新(從中合併)訊息的未跳過值來更新目標(合併到)訊息。

合併行為的差異對依賴部分「修補程式」更新的協定具有進一步的影響。如果未追蹤欄位存在,則僅更新修補程式無法表示更新為預設值,因為僅會從中合併非預設值。

在這種情況下,更新以設定預設值需要一些外部機制,例如 FieldMask。但是,如果追蹤存在,則所有明確設定的值(即使是預設值)都將合併到目標中。

變更相容性考量

明確存在隱含存在之間變更欄位對於 Wire Format 中序列化的值來說是二進位相容的變更。但是,訊息的序列化表示法可能會有所不同,具體取決於用於序列化的訊息定義版本。具體來說,當「傳送者」明確將欄位設定為其預設值時

  • 遵循隱含存在規範的序列化值不包含預設值,即使它已明確設定。
  • 遵循明確存在規範的序列化值包含每個「存在」的欄位,即使它包含預設值。

此變更可能是安全或不安全的,具體取決於應用程式的語意。例如,考慮兩個具有不同訊息定義版本的用戶端。

用戶端 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 旗標。

使用產生的程式碼

具有明確存在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,則其他單數欄位
所有其他欄位