Editionsサポートの実装

ランタイムとプラグインでEditionsサポートを実装するための手順。

このトピックでは、新しいランタイムとジェネレーターでeditionsを実装する方法について説明します。

概要

Edition 2023

最初にリリースされたエディションはEdition 2023であり、proto2とproto3の構文を統合するように設計されています。動作の違いをカバーするために追加した機能の詳細は、Editionsの機能設定で詳しく説明しています。

機能定義

エディションと定義済みのグローバル機能をサポートすることに加えて、インフラストラクチャを活用するために独自の機能を定義することもできます。これにより、ジェネレーターとランタイムが新しい動作を制御するために使用できる任意の機能を定義できます。最初のステップは、descriptor.protoのFeatureSetメッセージの拡張番号を9999より上で要求することです。GitHubでプルリクエストを送信すると、次回のリリースに含まれます(例:#15439を参照)。

拡張番号を取得したら、(cpp_features.protoと同様の)機能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" }
  ];
}

ここでは、新しいenum機能foo.feature_valueを定義しました(現在、boolean型とenum型のみがサポートされています)。取りうる値を定義するだけでなく、その使用方法も指定する必要があります。

  • ターゲット - この機能をアタッチできるproto記述子のタイプを指定します。これにより、ユーザーが機能を明示的に指定できる場所を制御します。すべてのタイプを明示的にリストする必要があります。
  • 機能サポート - エディションに関連するこの機能のライフタイムを指定します。導入されたエディションを指定する必要があります。それ以前は許可されません。オプションで、後のエディションで機能を非推奨または削除できます。
  • エディションのデフォルト - 機能のデフォルト値への変更を指定します。これは、サポートされているすべてのエディションを網羅する必要がありますが、デフォルトが変更されなかったエディションは省略できます。EDITION_PROTO2EDITION_PROTO3は、ここで指定して、「レガシー」エディションのデフォルトを提供できます(レガシーエディションを参照)。

機能とは?

機能は、エディションの境界を越えて、時間の経過とともに悪い動作を段階的に減らすメカニズムを提供するために設計されています。実際に機能を削除するまでのタイムラインは数年(または数十年)先になる可能性がありますが、すべての機能の望ましい目標は最終的な削除である必要があります。悪い動作が特定されたら、修正を保護する新しい機能を導入できます。次のエディション(またはそれ以降)で、デフォルト値を反転させますが、ユーザーはアップグレード時に古い動作を保持できるようにします。将来のある時点で、機能を非推奨としてマークし、それをオーバーライドするユーザーにカスタム警告をトリガーします。後のエディションでは、削除済みとしてマークし、ユーザーがそれ以上オーバーライドできないようにします(ただし、デフォルト値は引き続き適用されます)。その最後のエディションのサポートが破壊的リリースで削除されるまで、機能は古いエディションでスタックしたprotoで使用可能であり続け、移行する時間を与えます。

削除する予定のないオプションの動作を制御するフラグは、カスタムオプションとして実装する方が適切です。これは、機能をboolean型またはenum型に制限した理由に関連しています。(比較的)無制限の数の値によって制御される動作は、おそらくエディションフレームワークには適していません。最終的にそれほど多くの異なる動作を減らすことは現実的ではないためです。

これに対する注意点の1つは、ワイヤ境界に関連する動作です。言語固有の機能を使用してシリアライゼーションまたは解析の動作を制御すると、危険な場合があります。反対側に別の言語が存在する可能性があるためです。ワイヤ形式の変更は、常にdescriptor.protoのグローバル機能によって制御する必要があります。これは、すべてのランタイムで均一に尊重できます。

ジェネレーター

C++で記述されたジェネレーターは、C++ランタイムを使用するため、多くのものが無料で利用できます。機能解決を自分で処理する必要はありません。機能拡張が必要な場合は、CodeGeneratorのGetFeatureExtensionsに登録できます。一般に、コード生成で記述子の解決済み機能にアクセスするにはGetResolvedSourceFeaturesを使用し、独自の未解決機能にアクセスするにはGetUnresolvedSourceFeaturesを使用できます。

コードを生成するランタイムと同じ言語で記述されたプラグインは、機能定義のためにカスタムブートストラップが必要になる場合があります。

明示的なサポート

ジェネレーターは、サポートするエディションを正確に指定する必要があります。これにより、リリース後に独自スケジュールでエディションのサポートを安全に追加できます。Protocは、CodeGeneratorResponsesupported_featuresフィールドにFEATURE_SUPPORTS_EDITIONSが含まれていないジェネレーターに送信されたエディションprotoを拒否します。さらに、正確なサポートウィンドウを指定するためのminimum_editionフィールドとmaximum_editionフィールドがあります。新しいエディションのすべてのコードと機能の変更を定義したら、maximum_editionを上げてこのサポートを宣伝できます。

コード生成テスト

Edition 2023が予期しない機能変更を生成しないことをロックダウンするために使用できるコード生成テストのセットがあります。これらは、機能の大部分がgencodeにあるC++やJavaなどの言語で非常に役立ちました。一方、gencodeが基本的にシリアライズされた記述子のコレクションにすぎないPythonなどの言語では、これらはそれほど役立ちません。

このインフラストラクチャはまだ再利用可能ではありませんが、将来のリリースで計画されています。その時点で、エディションへの移行が予期しないコード生成の変更をもたらさないことを検証するために使用できるようになります。

ランタイム

リフレクションまたは動的メッセージのないランタイムは、Editionsを実装するために何もする必要はありません。そのロジックはすべてコードジェネレーターによって処理される必要があります。

リフレクションあり、動的メッセージなしの言語は、解決済みの機能が必要ですが、ジェネレーターでのみ処理することを選択することもできます。これは、コード生成中に解決済みおよび未解決の機能セットの両方をランタイムに渡すことで実現できます。これにより、ランタイムで機能解決を再実装することを回避できます。主な欠点は効率性であり、すべての記述子に対して一意の機能セットが作成されるためです。

動的メッセージを持つ言語は、ランタイムで記述子を構築できる必要があるため、Editionsを完全に実装する必要があります。

構文リフレクション

リフレクションを備えたランタイムでEditionsを実装する最初のステップは、syntaxキーワードのすべての直接チェックを削除することです。これらはすべて、必要に応じてsyntaxを引き続き使用できる、よりきめ細かい機能ヘルパーに移動する必要があります。

次の機能ヘルパーは、言語に適した名前で記述子に実装する必要があります。

  • FieldDescriptor::has_presence - フィールドに明示的な存在があるかどうか
    • 繰り返しフィールドは決して存在を持ちません
    • メッセージ、拡張機能、およびoneofフィールドは常に明示的な存在を持ちます
    • その他すべては、field_presenceIMPLICITでない場合に存在を持ちます
  • FieldDescriptor::is_required - フィールドが必須かどうか
  • FieldDescriptor::requires_utf8_validation - フィールドをutf8の有効性についてチェックする必要があるかどうか
  • FieldDescriptor::is_packed - 繰り返しフィールドにパックエンコーディングがあるかどうか
  • FieldDescriptor::is_delimited - メッセージフィールドに区切りエンコーディングがあるかどうか
  • EnumDescriptor::is_closed - フィールドがクローズドかどうか

ダウンストリームユーザーは、構文を直接使用する代わりに、これらの新しいヘルパーに移行する必要があります。次のクラスの既存の記述子APIは、構文情報をリークするため、理想的には非推奨になり、最終的には削除される必要があります。

  • FileDescriptor構文
  • Proto3 optional API
    • FieldDescriptor::has_optional_keyword
    • OneofDescriptor::is_synthetic
    • Descriptor::*real_oneof* - 単に「oneof」に名前変更する必要があり、既存の「oneof」ヘルパーは削除する必要があります。これらは、合成oneof(editionsには存在しない)に関する情報をリークするためです。
  • Group type
    • TYPE_GROUP enum値は削除し、is_delimitedヘルパーに置き換える必要があります。
  • Required label
    • LABEL_REQUIRED enum値は削除し、is_requiredヘルパーに置き換える必要があります。

これらのチェックが存在するが、editionsに敵対的ではないユーザーコードの多くのクラスがあります。たとえば、proto3 optionalを合成oneof実装のために特別に処理する必要があるコードは、極性がsyntax == "proto3"syntax != "proto2"をチェックするのではなく)のようなものであれば、editionsに敵対的ではありません。

これらのAPIを完全に削除できない場合は、非推奨とし、推奨しないようにする必要があります。

機能の可視性

editions-feature-visibilityで説明したように、機能protoは、すべてのProtobuf実装の内部詳細のままにする必要があります。制御する動作は記述子メソッドを介して公開する必要がありますが、proto自体は公開しないでください。特に、ユーザーに公開されるすべてのオプションからfeaturesフィールドを削除する必要があることを意味します。

機能のリークが許可される1つのケースは、記述子をシリアライズする場合です。結果の記述子protoは、元のprotoファイルの忠実な表現である必要があり、オプション内に未解決の機能が含まれている必要があります。

レガシーエディション

legacy-syntax-editionsで詳しく説明されているように、エディション実装の早期カバレッジを取得する優れた方法は、proto2、proto3、およびeditionsを統合することです。これにより、proto2とproto3が水面下でeditionsに効果的に移行し、構文リフレクションで実装されたすべてのヘルパーが(構文で分岐するのではなく)機能を排他的に使用するようになります。これは、protoファイルのさまざまな側面が適切な機能を通知できる機能推論フェーズを機能解決に挿入することで実行できます。これらの機能は、親の機能にマージして、解決済みの機能セットを取得できます。

proto2/proto3にはすでに妥当なデフォルトが提供されていますが、エディション2023では、次の追加の推論が必要です。

  • required - フィールドにLABEL_REQUIREDがある場合、LEGACY_REQUIREDの存在を推論します
  • groups - フィールドにTYPE_GROUPがある場合、DELIMITEDメッセージエンコーディングを推論します
  • packed - packedオプションがtrueの場合、PACKEDエンコーディングを推論します
  • expanded - proto3フィールドでpackedオプションが明示的にfalseに設定されている場合、EXPANDEDエンコーディングを推論します

適合性テスト

Editions固有の適合性テストが追加されましたが、オプトインする必要があります。--maximum_edition 2023フラグをランナーに渡して、これらを有効にできます。テスティーバイナリを構成して、次の新しいメッセージタイプを処理する必要があります。

  • protobuf_test_messages.editions.proto2.TestAllTypesProto2 - 古いproto2メッセージと同一ですが、edition 2023に変換されています
  • protobuf_test_messages.editions.proto3.TestAllTypesProto3 - 古いproto3メッセージと同一ですが、edition 2023に変換されています
  • protobuf_test_messages.editions.TestAllTypesEdition2023 - edition 2023固有のテストケースをカバーするために使用されます

機能解決

Editionsはレキシカルスコープを使用して機能を定義します。つまり、Editionsサポートを実装する必要があるC++以外のコードは、機能解決アルゴリズムを再実装する必要があります。ただし、作業の大部分はprotoc自体によって処理されます。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_editionmaximum_edition]内にあることを検証します
  2. 順序付けられたdefaultsフィールドをバイナリ検索して、エディション以下の最大エントリを探します
  3. 選択したデフォルトからoverridable_featuresfixed_featuresにマージします
  4. 記述子に設定された明示的な機能(ファイルオプションのfeaturesフィールド)をマージします

そこから、他のすべての記述子の機能を再帰的に解決できます

  1. 親記述子の機能セットに初期化します
  2. 記述子に設定された明示的な機能(オプションのfeaturesフィールド)をマージします

「親」記述子を決定するために、C++実装を参照できます。これはほとんどの場合簡単ですが、拡張機能は、親が拡張されるスコープではなく、囲みスコープであるため、少し驚きです。Oneofも、そのフィールドの親として考慮する必要があります。

適合性テスト

将来のリリースでは、機能解決をクロス言語で検証するための適合性テストを追加する予定です。それまでは、通常の適合性テストが部分的なカバレッジを提供し、継承ユニットテストの例を移植して、より包括的なカバレッジを提供できます。

以下は、ランタイムとプラグインにeditionsサポートを実装した実際の例です。

Java

  • #14138 - Java機能proto用のC++ gencodeによるブートストラップコンパイラー
  • #14377 - コード生成テストを含む、Java、Kotlin、およびJava Liteコードジェネレーターでの機能の使用
  • #15210 - Java機能ブートストラップ、機能解決、およびレガシーエディションをカバーするJavaフルランタイムでの機能の使用。ユニットテストと適合性テストを含む

Pure Python

  • #14546 - 事前にコード生成テストをセットアップ
  • #14547 - ユニットテストと適合性テストとともに、Editionsを一度に完全に実装

𝛍pb

  • #14638 - 機能解決とレガシーエディションをカバーするEditions実装の最初のパス
  • #14667 - フィールドラベル/タイプのより完全な処理、upbのコードジェネレーターのサポート、およびいくつかのテストを追加
  • #14678 - upbをPythonランタイムにフックアップ。より多くのユニットテストと適合性テストを含む

Ruby

  • #16132 - upb/Javaを4つのRubyランタイムすべてにフックアップして、完全なeditionsサポートを実現