實現 Editions 支援

關於在執行時和外掛中實現版本支援的說明。

本主題解釋瞭如何在新的執行時和生成器中實現版本。

概覽

2023 年版本

釋出的第一個版本是 2023 年版本,旨在統一 proto2 和 proto3 語法。我們為彌合行為差異而新增的功能在版本的功能設定中進行了詳細說明。

功能定義

除了支援版本和我們定義的全域性功能之外,您可能還希望定義自己的功能以利用其基礎架構。這將允許您定義任意功能,供您的生成器和執行時用於控制新行為。第一步是在 descriptor.proto 中為 FeatureSet 訊息申請一個大於 9999 的擴充套件號。您可以在 GitHub 中向我們傳送拉取請求,它將包含在我們的下一次釋出中(例如,參見 #15439)。

獲得擴充套件號後,您可以建立功能 proto(類似於 cpp_features.proto)。這些通常看起來像

edition = "2023";

package foo;

import "google/protobuf/descriptor.proto";

extend google.protobuf.FeatureSet {
  MyFeatures features = <extension #>;
}

message MyFeatures {
  enum FeatureValue {
    FEATURE_VALUE_UNKNOWN = 0;
    VALUE1 = 1;
    VALUE2 = 2;
  }

  FeatureValue feature_value = 1 [
    targets = TARGET_TYPE_FIELD,
    targets = TARGET_TYPE_FILE,
    feature_support = {
      edition_introduced: EDITION_2023,
      edition_deprecated: EDITION_2024,
      deprecation_warning: "Feature will be removed in 2025",
      edition_removed: EDITION_2025,
    },
    edition_defaults = { edition: EDITION_LEGACY, value: "VALUE1" },
    edition_defaults = { edition: EDITION_2024, value: "VALUE2" }
  ];
}

這裡我們定義了一個新的列舉功能 foo.feature_value(目前只支援布林和列舉型別)。除了定義它可以取的值之外,您還需要指定如何使用它

  • 目標 - 指定此功能可以附加到的 proto 描述符的型別。這控制了使用者可以顯式指定功能的位置。每種型別都必須顯式列出。
  • 功能支援 - 指定此功能相對於版本的生命週期。您必須指定它是在哪個版本中引入的,並且在此之前不允許使用它。您可以選擇在以後的版本中棄用或移除該功能。
  • 版本預設值 - 指定功能預設值的任何更改。這必須涵蓋每個受支援的版本,但您可以省略預設值未更改的任何版本。請注意,這裡可以指定 EDITION_PROTO2EDITION_PROTO3 以提供“舊版”版本的預設值(參見 舊版)。

什麼是功能?

功能旨在提供一種機制,用於在版本邊界上隨著時間的推移逐漸減少不良行為。雖然實際移除功能的時間可能在幾年(或幾十年)之後,但任何功能的期望目標都應該是最終移除。當識別出不良行為時,您可以引入一個新功能來保護修復。在下一個版本(或可能更晚的版本)中,您將翻轉預設值,同時仍然允許使用者在升級時保留其舊行為。在將來的某個時候,您會將該功能標記為已棄用,這將觸發對任何覆蓋它的使用者的自定義警告。在以後的版本中,您將將其標記為已移除,從而阻止使用者再覆蓋它(但預設值仍然適用)。直到在破壞性發布中放棄對該最後一個版本的支援之前,該功能將對停留在舊版本上的 proto 保持可用,從而給它們時間進行遷移。

控制可選行為且無意移除的標誌最好實現為自定義選項。這與我們將功能限制為布林或列舉型別的原因有關。任何由(相對)無界數量的值控制的行為可能都不適合版本框架,因為它不切實際地最終關閉那麼多不同的行為。

一個需要注意的例外是與線路邊界相關的行為。使用特定於語言的功能來控制序列化或解析行為可能很危險,因為任何其他語言都可能在另一側。線路格式更改應始終由 descriptor.proto 中的全域性功能控制,所有執行時都可以統一遵循這些功能。

生成器

用 C++ 編寫的生成器可以免費獲得很多東西,因為它們使用 C++ 執行時。它們不需要自己處理功能解析,如果它們需要任何功能擴充套件,它們可以在其 CodeGenerator 中的 GetFeatureExtensions 中註冊它們。它們通常可以使用 GetResolvedSourceFeatures 來訪問 codegen 中描述符的已解析功能,並使用 GetUnresolvedSourceFeatures 來訪問它們自己的未解析功能。

與它們生成程式碼的執行時使用相同語言編寫的外掛可能需要為其功能定義進行一些自定義引導。

明確支援

生成器必須明確指定它們支援哪些版本。這允許您根據自己的時間表在釋出後安全地新增對某個版本的支援。如果傳送給生成器的任何版本 proto 不包含其 CodeGeneratorResponsesupported_features 欄位中的 FEATURE_SUPPORTS_EDITIONS,Protoc 將拒絕它們。此外,我們還有 minimum_editionmaximum_edition 欄位用於指定您的精確支援視窗。一旦您定義了新版本的所有程式碼和功能更改,您就可以提高 maximum_edition 以宣傳此支援。

程式碼生成測試

我們有一套程式碼生成測試,可用於確保 2023 年版本不會產生意外的功能更改。這些在 C++ 和 Java 等語言中非常有用,其中大部分功能都在 gencode 中。另一方面,在 Python 等語言中,gencode 基本上只是一系列序列化描述符,這些測試就沒有那麼有用了。

此基礎架構尚不可重用,但計劃在未來版本中實現。屆時,您將能夠使用它們來驗證遷移到版本不會有任何意外的程式碼生成更改。

執行時

不帶反射或動態訊息的執行時無需執行任何操作即可實現版本。所有這些邏輯都應由程式碼生成器處理。

反射但不帶動態訊息的語言需要解析後的功能,但可以選擇只在生成器中處理。這可以透過在程式碼生成期間將解析和未解析的功能集都傳遞給執行時來完成。這避免了在執行時中重新實現功能解析,主要缺點是效率問題,因為它會為每個描述符建立一個唯一的功能集。

帶有動態訊息的語言必須完全實現版本,因為它們需要在執行時構建描述符。

語法反射

在帶有反射的執行時中實現版本的第一步是刪除所有對 syntax 關鍵字的直接檢查。所有這些都應移至更細粒度的功能幫助器,這些幫助器在必要時可以繼續使用 syntax

以下功能幫助器應在描述符上實現,並使用適合語言的命名

  • FieldDescriptor::has_presence - 欄位是否具有顯式存在
    • 重複欄位從不具有存在
    • 訊息、擴充套件和 oneof 欄位總是具有顯式存在
    • 其他所有欄位都具有存在,當且僅當 field_presence 不是 IMPLICIT
  • FieldDescriptor::is_required - 欄位是否必需
  • FieldDescriptor::requires_utf8_validation - 欄位是否應檢查 utf8 有效性
  • FieldDescriptor::is_packed - 重複欄位是否具有打包編碼
  • FieldDescriptor::is_delimited - 訊息欄位是否具有分隔編碼
  • EnumDescriptor::is_closed - 欄位是否關閉

下游使用者應遷移到這些新的幫助器,而不是直接使用語法。以下現有描述符 API 類應理想地棄用並最終移除,因為它們洩露了語法資訊

  • FileDescriptor 語法
  • Proto3 可選 API
    • FieldDescriptor::has_optional_keyword
    • OneofDescriptor::is_synthetic
    • Descriptor::*real_oneof* - 應重新命名為“oneof”,並應移除現有的“oneof”幫助器,因為它們洩露了有關合成 oneof 的資訊(在版本中不存在)。
  • 組型別
    • 應移除 TYPE_GROUP 列舉值,替換為 is_delimited 幫助器。
  • 必需標籤
    • 應移除 LABEL_REQUIRED 列舉值,替換為 is_required 幫助器。

有許多使用者程式碼中存在這些檢查但與版本衝突。例如,由於其合成 oneof 實現而需要特殊處理 proto3 optional 的程式碼,只要極性是 syntax == "proto3"(而不是檢查 syntax != "proto2"),就不會與版本衝突。

如果無法完全移除這些 API,則應將其棄用和不鼓勵使用。

功能可見性

版本功能可見性中所述,功能 proto 應該仍然是任何 Protobuf 實現的內部細節。它們控制的行為應該透過描述符方法公開,但 proto 本身不應該。值得注意的是,這意味著任何向用戶公開的選項都需要將其 features 欄位剝離。

我們允許功能洩露的一種情況是序列化描述符時。結果描述符 proto 應該是原始 proto 檔案的忠實表示,並且應該在選項中包含未解析的功能

舊版

舊版語法版本中更詳細地討論,儘早覆蓋版本實現的一個好方法是統一 proto2、proto3 和版本。這有效地在底層將 proto2 和 proto3 遷移到版本,並使語法反射中實現的所有幫助器都完全使用功能(而不是根據語法進行分支)。這可以透過在功能解析中插入一個功能推斷階段來完成,其中 proto 檔案的各個方面可以告知哪些功能是合適的。然後可以將這些功能合併到父級的功能中以獲得解析後的功能集。

雖然我們已經為 proto2/proto3 提供了合理的預設值,但對於 2023 年版本,需要以下額外的推斷

  • required - 當欄位具有 LABEL_REQUIRED 時,我們推斷 LEGACY_REQUIRED 存在
  • groups - 當欄位具有 TYPE_GROUP 時,我們推斷 DELIMITED 訊息編碼
  • packed - 當 packed 選項為 true 時,我們推斷 PACKED 編碼
  • expanded - 當 proto3 欄位顯式設定為 false 時,我們推斷 EXPANDED 編碼

一致性測試

已新增版本特定的一致性測試,但需要選擇啟用。可以向執行器傳遞 --maximum_edition 2023 標誌以啟用這些測試。您需要配置您的被測二進位制檔案以處理以下新的訊息型別

  • protobuf_test_messages.editions.proto2.TestAllTypesProto2 - 與舊的 proto2 訊息相同,但轉換為 2023 年版本
  • protobuf_test_messages.editions.proto3.TestAllTypesProto3 - 與舊的 proto3 訊息相同,但轉換為 2023 年版本
  • protobuf_test_messages.editions.TestAllTypesEdition2023 - 用於覆蓋 2023 年版本特定的測試用例

功能解析

版本使用詞法範圍來定義功能,這意味著任何需要實現版本支援的非 C++ 程式碼都需要重新實現我們的功能解析演算法。但是,大部分工作由 protoc 本身處理,它可以配置為輸出中間的 FeatureSetDefaults 訊息。此訊息包含一組功能定義檔案的“編譯”,其中列出了每個版本中的預設功能值。

例如,上面定義的功能將在 proto2 和 2025 年版本之間編譯為以下預設值(以文字格式表示)

defaults {
  edition: EDITION_PROTO2
  overridable_features { [foo.features] {} }
  fixed_features {
    // Global feature defaults…
    [foo.features] { feature_value: VALUE1 }
  }
}
defaults {
  edition: EDITION_PROTO3
  overridable_features { [foo.features] {} }
  fixed_features {
    // Global feature defaults…
    [foo.features] { feature_value: VALUE1 }
  }
}
defaults {
  edition: EDITION_2023
  overridable_features {
    // Global feature defaults…
    [foo.features] { feature_value: VALUE1 }
  }
}
defaults {
  edition: EDITION_2024
  overridable_features {
    // Global feature defaults…
    [foo.features] { feature_value: VALUE2 }
  }
}
defaults {
  edition: EDITION_2025
  overridable_features {
    // Global feature defaults…
  }
  fixed_features { [foo.features] { feature_value: VALUE2 } }
}
minimum_edition: EDITION_PROTO2
maximum_edition: EDITION_2025

為了簡潔起見,省略了全域性功能預設值,但它們也將存在。此物件包含一個有序列表,其中包含每個具有唯一預設值集合的版本(某些版本可能最終不存在)在指定範圍內。每組預設值都分為可覆蓋固定功能。前者是該版本支援的、使用者可以自由覆蓋的功能。固定功能是尚未引入或已移除的功能,使用者無法覆蓋。

我們提供了一個 Bazel 規則來編譯這些中間物件

load("@com_google_protobuf//editions:defaults.bzl", "compile_edition_defaults")

compile_edition_defaults(
    name = "my_defaults",
    srcs = ["//some/path:lang_features_proto"],
    maximum_edition = "PROTO2",
    minimum_edition = "2024",
)

輸出的 FeatureSetDefaults 可以嵌入到您需要進行功能解析的任何語言的原始字串文字中。我們還提供了一個 embed_edition_defaults 宏來完成此操作

embed_edition_defaults(
    name = "embed_my_defaults",
    defaults = ":my_defaults",
    output = "my_defaults.h",
    placeholder = "DEFAULTS_DATA",
    template = "my_defaults.h.template",
)

或者,您可以直接(在 Bazel 之外)呼叫 protoc 來生成此資料

protoc --edition_defaults_out=defaults.binpb --edition_defaults_minimum=PROTO2 --edition_defaults_maximum=2023 <feature files...>

一旦預設訊息被您的程式碼掛鉤並解析,給定版本的檔案描述符的功能解析遵循一個簡單的演算法

  1. 驗證版本是否在適當的範圍 [minimum_edition, maximum_edition] 內
  2. 對有序的 defaults 欄位進行二分查詢,查詢小於或等於該版本的最高條目
  3. 將選定預設值中的 overridable_features 合併到 fixed_features
  4. 合併在描述符上設定的任何顯式功能(檔案選項中的 features 欄位)

從那裡,您可以遞迴地解析所有其他描述符的功能

  1. 初始化為父描述符的功能集
  2. 合併在描述符上設定的任何顯式功能(選項中的 features 欄位)

要確定“父”描述符,您可以參考我們的 C++ 實現。這在大多數情況下都很簡單,但擴充套件有點令人驚訝,因為它們的父級是封閉範圍而不是被擴充套件者。Oneof 也需要被視為其欄位的父級。

一致性測試

在未來版本中,我們計劃新增一致性測試以驗證跨語言的功能解析。在此之前,我們的常規一致性測試確實提供了部分覆蓋,我們的示例繼承單元測試可以移植以提供更全面的覆蓋。

示例

下面是我們如何在執行時和外掛中實現版本支援的一些真實示例。

Java

  • #14138 - 使用 C++ gencode 引導編譯器以支援 Java 功能 proto
  • #14377 - 在 Java、Kotlin 和 Java Lite 程式碼生成器中使用功能,包括程式碼生成測試
  • #15210 - 在 Java 完整執行時中使用功能,涵蓋 Java 功能引導、功能解析和舊版,以及單元測試和一致性測試

純 Python

  • #14546 - 提前設定程式碼生成測試
  • #14547 - 一次性完全實現版本,以及單元測試和一致性測試

𝛍pb

  • #14638 - 版本實現的初次嘗試,涵蓋功能解析和舊版
  • #14667 - 添加了更完整的欄位標籤/型別處理,支援 upb 的程式碼生成器和一些測試
  • #14678 - 將 upb 連線到 Python 執行時,包含更多單元測試和一致性測試

Ruby

  • #16132 - 將 upb/Java 連線到所有四個 Ruby 執行時以實現完整版本支援