エディションサポートの実装

ランタイムとプラグインにエディションサポートを実装するための手順です。

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

概要

Edition 2023

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

機能の定義

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

拡張番号を取得したら、独自の機能プロト(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" }
  ];
}

ここでは、新しいenum機能foo.feature_valueを定義しました(現在サポートされているのはbooleanとenum型のみです)。それが取りうる値を定義することに加えて、それがどのように使用できるかを指定する必要もあります。

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

機能とは何か?

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

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

これに対する1つの注意点は、ワイヤー境界に関連する動作です。シリアライゼーションやパースの動作を制御するために言語固有の機能を使用することは危険な場合があります。なぜなら、反対側には他のどんな言語が存在するかわからないからです。ワイヤーフォーマットの変更は、常にdescriptor.proto内のグローバル機能によって制御されるべきであり、これはすべてのランタイムで一様に尊重できます。

ジェネレータ

C++で書かれたジェネレータは、C++ランタイムを使用するため、多くのことを無料で手に入れることができます。それらは機能解決を自分たちで処理する必要はなく、機能拡張が必要な場合はCodeGeneratorのGetFeatureExtensionsに登録できます。一般的に、コード生成でディスクリプタの解決済み機能にアクセスするにはGetResolvedSourceFeaturesを使用し、自身の未解決機能にアクセスするにはGetUnresolvedSourceFeaturesを使用できます。

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

明示的なサポート

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

コード生成テスト

Edition 2023が予期せぬ機能変更を生じさせないことを確実にするためのコード生成テストのセットがあります。これらはC++やJavaのような、機能の大部分が生成コードにある言語で非常に役立っています。一方、Pythonのように生成コードが基本的にシリアライズされたディスクリプタのコレクションである言語では、これらはそれほど有用ではありません。

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

ランタイム

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

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

動的メッセージを持つ言語は、ランタイムでディスクリプタを構築できる必要があるため、エディションを完全に実装する必要があります。

構文リフレクション

リフレクションを持つランタイムでエディションを実装する最初のステップは、syntaxキーワードの直接的なチェックをすべて削除することです。これらはすべて、より細かい粒度の機能ヘルパーに移動されるべきであり、必要であれば引き続きsyntaxを使用できます。

以下の機能ヘルパーは、言語に適した名前でディスクリプタに実装されるべきです。

  • FieldDescriptor::has_presence - フィールドが明示的なプレゼンスを持つかどうか
    • Repeatedフィールドはプレゼンスを持ちません
    • Message、extension、およびoneofフィールドは常に明示的なプレゼンスを持ちます
    • その他すべては、field_presenceIMPLICITない場合にのみプレゼンスを持ちます
  • FieldDescriptor::is_required - フィールドがrequiredかどうか
  • FieldDescriptor::requires_utf8_validation - フィールドがUTF-8の妥当性チェックを必要とするかどうか
  • FieldDescriptor::is_packed - repeatedフィールドがpackedエンコーディングを持つかどうか
  • FieldDescriptor::is_delimited - messageフィールドがdelimitedエンコーディングを持つかどうか
  • EnumDescriptor::is_closed - enumがclosedかどうか

下流のユーザーは、構文を直接使用する代わりに、これらの新しいヘルパーに移行すべきです。既存のディスクリプタAPIの以下のクラスは、構文情報を漏洩させるため、理想的には非推奨にし、最終的に削除すべきです。

  • FileDescriptorの構文
  • Proto3のoptional API
    • FieldDescriptor::has_optional_keyword
    • OneofDescriptor::is_synthetic
    • Descriptor::*real_oneof* - 単に「oneof」に名前を変更し、既存の「oneof」ヘルパーは合成oneof(エディションには存在しない)に関する情報を漏洩するため削除すべきです。
  • Group型
    • TYPE_GROUP enum値は削除し、is_delimitedヘルパーに置き換えるべきです。
  • Requiredラベル
    • LABEL_REQUIRED enum値は削除し、is_requiredヘルパーに置き換えるべきです。

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

これらのAPIを完全に削除することが不可能な場合は、非推奨とし、使用を推奨しないようにすべきです。

機能の可視性

editions-feature-visibilityで議論されているように、機能プロトはProtobuf実装の内部詳細であり続けるべきです。それらが制御する動作はディスクリプタメソッドを介して公開されるべきですが、プロト自体は公開されるべきではありません。特に、これはユーザーに公開されるすべてのオプションからfeaturesフィールドを取り除く必要があることを意味します。

機能の漏洩を許可する唯一のケースは、ディスクリプタをシリアライズする場合です。結果として得られるディスクリプタプロトは、元のプロトファイルの忠実な表現であるべきであり、オプション内に未解決の機能を含むべきです。

レガシーエディション

legacy-syntax-editionsでさらに詳しく説明されているように、エディション実装の早期カバレッジを得るための素晴らしい方法は、proto2、proto3、およびエディションを統一することです。これは事実上、proto2とproto3を内部的にエディションに移行し、構文リフレクションで実装されたすべてのヘルパーを(構文で分岐する代わりに)機能のみを使用するようにします。これは、機能解決機能推論フェーズを挿入することで行うことができ、プロトファイルのさまざまな側面から適切な機能が何かを知ることができます。これらの機能は、親の機能にマージされて、解決済みの機能セットを得ることができます。

私たちは既にproto2/proto3のための合理的なデフォルトを提供していますが、edition 2023については以下の追加の推論が必要です。

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

適合性テスト

エディション固有の適合性テストが追加されましたが、オプトインする必要があります。ランナーに--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固有のテストケースをカバーするために使用されます

機能解決

エディションはレキシカルスコープを使用して機能を定義します。つまり、エディションサポートを実装する必要があるC++以外のコードは、私たちの機能解決アルゴリズムを再実装する必要があります。ただし、作業の大部分はprotoc自体によって処理され、中間的なFeatureSetDefaultsメッセージを出力するように設定できます。このメッセージには、一連の機能定義ファイルの「コンパイル」が含まれており、すべてのエディションにおけるデフォルトの機能値がレイアウトされています。

例えば、上記の機能定義は、proto2とedition 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_featuresfixed_featuresにマージします
  4. ディスクリプタに設定された明示的な機能(ファイルオプションのfeaturesフィールド)をマージします

そこから、他のすべてのディスクリプタの機能を再帰的に解決できます。

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

「親」ディスクリプタを決定するには、私たちのC++実装を参照できます。これはほとんどの場合簡単ですが、拡張機能は少し驚くべきことに、その親は拡張対象ではなく囲んでいるスコープです。Oneofもそのフィールドの親として考慮する必要があります。

適合性テスト

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

以下は、私たちのランタイムとプラグインでエディションサポートを実装した実際の例です。

Java

  • #14138 - Java機能プロトのためのC++生成コードでコンパイラをブートストラップ
  • #14377 - Java、Kotlin、Java Liteコードジェネレータで機能を使用(コード生成テストを含む)
  • #15210 - Javaのフルランタイムで機能を使用(Java機能のブートストラップ、機能解決、レガシーエディションをカバー)、単体テストと適合性テストも含む

Pure Python

  • #14546 - 事前にコード生成テストをセットアップ
  • #14547 - エディションを一度に完全に実装し、単体テストと適合性テストも実施

𝛍pb

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

Ruby

  • #16132 - 完全なエディションサポートのために、4つのRubyランタイムすべてにupb/Javaを接続