應用筆記:欄位存在性

解釋了protobuf欄位的各種存在性跟蹤規範。它還解釋了帶有基本型別的單個proto3欄位的顯式存在性跟蹤行為。

背景

欄位存在性是protobuf欄位是否具有值的概念。protobufs的存在性有兩種不同的表現形式:隱式存在性,其中生成的message API(僅)儲存欄位值;和顯式存在性,其中API還儲存欄位是否已設定。

存在性規範

存在性規範定義了在API表示序列化表示之間進行轉換的語義。隱式存在性規範依賴於欄位值本身在(反)序列化時做出決策,而顯式存在性規範則依賴於顯式跟蹤狀態。

標籤-值流(線格式)序列化中的存在性

線格式是標記化的、自定界值的流。根據定義,線格式表示一系列存在的值。換句話說,序列化中找到的每個值都表示一個存在的欄位;此外,序列化不包含有關不存在值的資訊。

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欄位:如果存在多個情況,它們是無序的。

理論上,JSON可以以語義保留的方式表示存在性。然而,在實踐中,存在性的正確性可能會因實現選擇而異,特別是如果選擇JSON作為與不使用protobuf的客戶端互操作的方式。

在Proto2 API中的存在性

此表概述了proto2 API中欄位(包括生成的API和使用動態反射)是否跟蹤存在性。

欄位型別顯式存在性
單個數字(整數或浮點)✔️
單個列舉✔️
單個字串或位元組✔️
單個訊息✔️
重複欄位
Oneof✔️
對映

單個欄位(所有型別)在生成的API中顯式跟蹤存在性。生成的message介面包含查詢欄位存在性的方法。例如,欄位foo有一個對應的has_foo方法。(特定名稱遵循與欄位訪問器相同的語言特定命名約定。)這些方法有時在protobuf實現中被稱為“hazzers”。

類似於單個欄位,oneof欄位顯式跟蹤其中哪個成員(如果有)包含值。例如,考慮這個oneof示例:

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

根據目標語言,生成的API通常會包含幾種方法:

  • 一個用於oneof的has_方法:has_foo
  • 一個oneof case方法:foo
  • 成員的has_方法:has_a, has_b
  • 成員的getter方法:a, b

重複欄位和對映不跟蹤存在性:空和不存在的重複欄位之間沒有區別。

在Proto3 API中的存在性

此表概述了proto3 API中欄位(包括生成的API和使用動態反射)是否跟蹤存在性。

欄位型別可選顯式存在性
單個數字(整數或浮點)
單個數字(整數或浮點)✔️
單個列舉
單個列舉✔️
單個字串或位元組
單個字串或位元組✔️
單個訊息✔️
單個訊息✔️
重複欄位不適用
Oneof不適用✔️
對映不適用

類似於proto2 API,proto3不顯式跟蹤重複欄位的存在性。如果沒有optional標籤,proto3 API也不跟蹤基本型別(數字、字串、位元組和列舉)的存在性。Oneof欄位明確公開存在性,儘管可能不會像proto2 API那樣生成相同的has_方法集。

這種預設行為(不帶optional標籤不跟蹤存在性)與proto2的行為不同。除非您有特殊原因,我們建議在proto3中使用optional標籤。

隱式存在性規範下,預設值與序列化目的的“不存在”同義。為了在概念上“清除”一個欄位(使其不會被序列化),API使用者會將其設定為預設值。

隱式存在性下,列舉型別欄位的預設值是相應的0值列舉器。根據proto3語法規則,所有列舉型別都要求有一個對映到0的列舉器值。按照慣例,這是一個UNKNOWN或類似名稱的列舉器。如果零值在概念上超出了應用程式的有效值域,則此行為可以被認為是等同於顯式存在性

在Editions API中的存在性

此表概述了Editions API中欄位(包括生成的API和使用動態反射)是否跟蹤存在性。

欄位型別顯式存在性
單個數字(整數或浮點)✔️
單個列舉✔️
單個字串或位元組✔️
單個訊息†✔️
重複欄位
Oneofs†✔️
對映

† 訊息和oneofs從未有過隱式存在性,Editions不允許您設定field_presence = IMPLICIT

基於Editions的API顯式跟蹤欄位存在性,類似於proto2,除非features.field_presence設定為IMPLICIT。類似於proto2 API,基於Editions的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. .proto檔案中新增一個optional欄位。
  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 呼叫

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 中的欄位
重複欄位和對映

Proto3

是否跟蹤欄位存在性?

欄位型別是否跟蹤?
其他單個欄位如果定義為optional
單個訊息欄位
oneof 中的欄位
重複欄位和對映

Edition 2023

是否跟蹤欄位存在性?

欄位型別(按優先順序降序)是否跟蹤?
重複欄位和對映
訊息和Oneof欄位
如果features.field_presence設定為IMPLICIT,則為其他單個欄位
所有其他欄位