Go Opaque API FAQ

Opaque API に関してよくある質問のリスト。

Opaque API は、Go プログラミング言語用の Protocol Buffers 実装の最新バージョンです。古いバージョンは現在 Open Struct API と呼ばれています。概要については、Go Protobuf: 新しい Opaque API のブログ投稿を参照してください。

この FAQ では、新しい API と移行プロセスに関する一般的な質問に答えます。

新しい .proto ファイルを作成する際にどの API を使用すべきですか?

新規開発には Opaque API を選択することをお勧めします。

Protobuf Edition 2024 (Protobuf Editions の概要を参照) で Opaque API がデフォルトになりました。

メッセージに対して新しい Opaque API を有効にするにはどうすればよいですか?

Protobuf Edition 2023 から、.proto ファイルで api_level エディション機能を API_OPAQUE に設定することで Opaque API を選択できます。これはファイルごと、またはメッセージごとに設定できます。

edition = "2023";

package log;

import "google/protobuf/go_features.proto";
option features.(pb.go).api_level = API_OPAQUE;

message LogEntry {  }

Protobuf Edition 2024 では Opaque API がデフォルトになるため、追加のインポートやオプションは不要になります。

edition = "2024";

package log;

message LogEntry {  }

便宜上、protocコマンドラインフラグでデフォルトのAPIレベルをオーバーライドすることもできます。

protoc […] --go_opt=default_api_level=API_HYBRID

特定のファイル(すべてのファイルではなく)のデフォルトAPIレベルをオーバーライドするには、apilevelMマッピングフラグを使用します(インポートパスのMフラグと同様)。

protoc […] --go_opt=apilevelMhello.proto=API_HYBRID

コマンドラインフラグは、まだproto2またはproto3構文を使用している.protoファイルでも機能しますが、.protoファイル内からAPIレベルを選択したい場合は、まずそのファイルをeditionsに移行する必要があります。

遅延デコードを有効にするにはどうすればよいですか?

  1. コードを Opaque 実装を使用するように移行します。
  2. 遅延デコードされるべき proto サブメッセージフィールドに [lazy = true] オプションを設定します。
  3. 単体テストと結合テストを実行し、ステージング環境にロールアウトします。

遅延デコードではエラーは無視されますか?

いいえ。proto.Marshal は、デコードが最初のアクセスまで遅延された場合でも、常にワイヤー形式のデータを検証します。

質問や問題を報告するにはどこに問い合わせればよいですか?

open2opaque 移行ツールに問題 (誤って書き換えられたコードなど) が見つかった場合は、open2opaque 課題トラッカーで報告してください。

Go Protobuf に問題が見つかった場合は、Go Protobuf 課題トラッカーで報告してください。

Opaque API の利点は何ですか?

Opaque API には数多くの利点があります。

  • より効率的なメモリ表現を使用するため、メモリとガベージコレクションのコストを削減します。
  • 遅延デコードを可能にし、パフォーマンスを大幅に向上させることができます。
  • 多くの鋭利な問題を修正します。ポインタアドレス比較、意図しない共有、Go リフレクションの望ましくない使用に起因するバグはすべて、Opaque API を使用することで防止されます。
  • プロファイル駆動型の最適化を可能にすることで、理想的なメモリレイアウトを実現します。

これらの点の詳細については、Go Protobuf: The new Opaque API ブログ投稿を参照してください。

ビルダーとセッターではどちらが高速ですか?

一般的に、ビルダーを使用するコードは

_ = pb.M_builder{
  F: &val,
}.Build()

以下の同等のものよりも遅くなります。

m := &pb.M{}
m.SetF(val)

以下の理由によります。

  1. Build() 呼び出しは、メッセージ内のすべてのフィールド (明示的に設定されていないものも含む) を反復処理し、その値 (存在する場合) を最終的なメッセージにコピーします。この線形パフォーマンスは、多くのフィールドを持つメッセージにとって重要です。
  2. 余分なヒープ割り当て (&val) が発生する可能性があります。
  3. ビルダーは、oneof フィールドが存在する場合、大幅に大きくなり、より多くのメモリを使用する可能性があります。ビルダーは oneof ユニオンメンバーごとにフィールドを持ちますが、メッセージは oneof 自体を単一のフィールドとして格納できます。

実行時パフォーマンスとは別に、バイナリサイズが懸念される場合は、ビルダーを避けることでコードが少なくなります。

ビルダーはどのように使用しますか?

ビルダーは、として、そして即時の Build() 呼び出しで使用されるように設計されています。ビルダーへのポインタを使用したり、ビルダーを変数に格納したりすることは避けてください。

m := pb.M_builder{
    // ...
}.Build()
// BAD: Avoid using a pointer
m := (&pb.M_builder{
    // ...
}).Build()
// BAD: avoid storing in a variable
b := pb.M_builder{
    // ...
}
m := b.Build()

一部の言語では Proto メッセージは不変であるため、ユーザーは proto メッセージを構築する際にビルダー型を関数呼び出しに渡す傾向があります。Go proto メッセージは可変であるため、ビルダーを関数呼び出しに渡す必要はありません。単純に proto メッセージを渡してください。

// BAD: avoid passing a builder around
func populate(mb *pb.M_builder) {
  mb.Field1 = proto.Int32(4711)
  //...
}
// ...
mb := pb.M_builder{}
populate(&mb)
m := mb.Build()
func populate(mb *pb.M) {
  mb.SetField1(4711)
  //...
}
// ...
m := &pb.M{}
populate(m)

ビルダーは、Proto メッセージの代替表現としてではなく、Open Struct API の複合リテラル構築を模倣するように設計されています。

推奨されるパターンは、よりパフォーマンスが高いです。ビルダー構造体リテラルで直接呼び出される Build() の意図された使用法は、十分に最適化できます。別の Build() の呼び出しは最適化がはるかに困難です。なぜなら、コンパイラがどのフィールドが設定されているかを容易に特定できない可能性があるためです。ビルダーが長く存続する場合、スカラーなどの小さなオブジェクトがヒープに割り当てられ、後でガベージコレクタによって解放される必要が生じる可能性も高くなります。

ビルダーとセッターのどちらを使用すべきですか?

空のプロトコルバッファを構築する際には、new または空の複合リテラルを使用する必要があります。どちらも Go でゼロ初期化された値を構築するためのイディオムとして同等であり、空のビルダーよりもパフォーマンスが優れています。

m1 := new(pb.M)
m2 := &pb.M{}
// BAD: avoid: unnecessarily complex
m1 := pb.M_builder{}.Build()

空でないプロトコルバッファを構築する必要がある場合は、セッターを使用するかビルダーを使用するかを選択できます。どちらでも構いませんが、ほとんどの人はビルダーの方が読みやすいと感じるでしょう。作成しているコードが優れたパフォーマンスを発揮する必要がある場合、セッターは一般的にビルダーよりもわずかにパフォーマンスが優れています

// Recommended: using builders
m1 := pb.M1_builder{
    Submessage: pb.M2_builder{
        Submessage: pb.M3_builder{
            String: proto.String("hello world"),
            Int:    proto.Int32(42),
        }.Build(),
        Bytes: []byte("hello"),
    }.Build(),
}.Build()
// Also okay: using setters
m3 := &pb.M3{}
m3.SetString("hello world")
m3.SetInt(42)
m2 := &pb.M2{}
m2.SetSubmessage(m3)
m2.SetBytes([]byte("hello"))
m1 := &pb.M1{}
m1.SetSubmessage(m2)

特定のフィールドが設定前に条件ロジックを必要とする場合、ビルダーとセッターの使用を組み合わせることができます。

m1 := pb.M1_builder{
    Field1: value1,
}.Build()
if someCondition() {
    m1.SetField2(value2)
    m1.SetField3(value3)
}

open2opaque のビルダーの動作に影響を与えるにはどうすればよいですか?

open2opaque ツールの --use_builders フラグには以下の値を設定できます。

  • --use_builders=everywhere: 例外なく常にビルダーを使用します。
  • --use_builders=tests: テストでのみビルダーを使用し、それ以外の場合はセッターを使用します。
  • --use_builders=nowhere: ビルダーは決して使用しません。

どのくらいのパフォーマンス上のメリットが期待できますか?

これはワークロードに大きく依存します。以下の質問は、パフォーマンスの探索をガイドするのに役立ちます。

  • Go Protobuf が CPU 使用率に占める割合はどのくらいですか?Protobuf 入力レコードに基づいて統計を計算するログ解析パイプラインのような一部のワークロードでは、Go Protobuf で CPU 使用率の約 50% を費やすことがあります。このようなワークロードではパフォーマンスの向上が明確に現れるでしょう。その反対に、Go Protobuf で CPU 使用率の 3-5% しか費やさないプログラムでは、パフォーマンスの向上は他の機会と比較して insignificant になることがよくあります。
  • あなたのプログラムは遅延デコードに適していますか?入力メッセージの大部分が一度もアクセスされない場合、遅延デコードは多くの作業を節約できます。このパターンは通常、プロキシサーバー (入力をそのまま通過させる)、または高い選択性を持つログ解析パイプライン (高レベルの述語に基づいて多くのレコードを破棄する) のようなジョブで発生します。
  • メッセージ定義には明示的な存在を持つ多くの基本フィールドが含まれていますか?Opaque API は、整数、ブール値、列挙型、浮動小数点数のような基本フィールドにはより効率的なメモリ表現を使用しますが、文字列、繰り返しフィールド、サブメッセージには使用しません。

Proto2、Proto3、および Editions は Opaque API とどのように関連していますか?

proto2 および proto3 という用語は、.proto ファイル内の異なる構文バージョンを指します。Protobuf Editions は、proto2 と proto3 の両方の後継です。

Opaque API は、.proto ファイルに記述する内容ではなく、.pb.go ファイルで生成されるコードのみに影響を与えます。

Opaque API は、.proto ファイルがどの構文またはエディションを使用しているかに関わらず、同じように機能します。ただし、ファイル単位で Opaque API を選択する場合 (protoc 実行時のコマンドラインフラグを使用するのではなく)、まずファイルをエディションに移行する必要があります。詳細については、メッセージの新しい Opaque API を有効にするにはどうすればよいですか?を参照してください。

なぜ基本フィールドのメモリレイアウトのみを変更するのですか?

発表ブログ投稿の「Opaque structs use less memory」セクションでは次のように説明されています。

このパフォーマンス向上 [フィールドの存在をより効率的にモデル化する] は、Protobuf メッセージの形状に大きく依存します。この変更は、整数、ブール値、列挙型、浮動小数点数などの基本フィールドにのみ影響し、文字列、繰り返しフィールド、またはサブメッセージには影響しません。

当然の次の質問は、Opaque API で文字列、繰り返しフィールド、およびサブメッセージがポインタのままである理由です。答えは2つあります。

考慮事項 1: メモリ使用量

サブメッセージをポインタではなく値として表現すると、メモリ使用量が増加します。各 Protobuf メッセージ型は内部状態を保持しており、サブメッセージが実際に設定されていなくてもメモリを消費することになります。

文字列と繰り返しフィールドの場合、状況はより微妙です。文字列値を使用する場合と文字列ポインタを使用する場合のメモリ使用量を比較してみましょう。

Go 変数型設定済み?ワードバイト数
stringはい2 (データ, 長さ)16
stringいいえ2 (データ, 長さ)16
*stringはい1 (データ) + 2 (データ, 長さ)24
*stringいいえ1 (データ)8

(スライスでも同様の状況ですが、スライスヘッダーには3ワードが必要です: データ、長さ、容量)

文字列フィールドが圧倒的に設定されていない場合、ポインタを使用すると RAM を節約できます。もちろん、この節約はプログラムにさらなる割り当てとポインタを導入するコストを伴い、ガベージコレクタへの負荷を増加させます。

Opaque API の利点は、ユーザーコードを変更することなく表現を変更できることです。現在のメモリレイアウトは導入時に私たちにとって最適でしたが、今日または5年後に測定した場合、異なるレイアウトを選択したかもしれません。

発表ブログ投稿の「Making the ideal memory layout possible」セクションで説明されているように、将来的にはこれらの最適化決定をワークロードごとに下すことを目指しています。

考慮事項 2: 遅延デコード

メモリ使用量に関する考慮事項とは別に、もう1つの制限があります。遅延デコードが有効になっているフィールドは、ポインタで表現する必要があります。

Protobuf メッセージは同時アクセスに対して安全ですが (同時変更に対しては安全ではありません)、2つの異なるゴルーチンが遅延デコードをトリガーする場合、何らかの形で調整する必要があります。この調整は、ポインタをアトミックに更新できるが、スライスヘッダー (1 ワードを超える) は更新できない sync/atomic パッケージを使用して実装されます。

現在、protoc は (繰り返しではない) サブメッセージに対してのみ遅延デコードを許可していますが、この推論はすべてのフィールド型に当てはまります。