Go Opaque API FAQ

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

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

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

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

新しい開発では Opaque API を選択することをお勧めします。Protobuf Edition 2024 (Protobuf Editions Overview を参照) では、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 {  }

Protobuf Edition 2024 のリリース予定日は 2025 年初頭です。

便宜上、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. 遅延デコードが必要なプロトサブメッセージフィールドに [lazy = true] オプションを設定します。
  3. 単体テストと結合テストを実行し、ステージング環境にロールアウトします。

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

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

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

open2opaque 移行ツールに問題 (不正確に書き換えられたコードなど) が見つかった場合は、open2opaque issue tracker で報告してください。

Go Protobuf に問題が見つかった場合は、Go Protobuf issue tracker で報告してください。

Opaque API の利点は何ですか?

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

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

これらの詳細については、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()

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

// 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)

ビルダーは、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、および Edition は Opaque API とどのように関連していますか?

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

Opaque API は .pb.go ファイル内の生成されたコードのみに影響し、.proto ファイルに記述する内容には影響しません。

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

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

発表ブログ記事の「Opaque struct はメモリ使用量を削減する」セクションでは次のように説明されています。

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

自然な次の疑問は、なぜ文字列、繰り返しフィールド、およびサブメッセージが Opaque API でポインタのままなのかということです。これには2つの理由があります。

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

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

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

Go 変数型設定されていますか?ワードバイト数
文字列はい2 (データ、長さ)16
文字列いいえ2 (データ、長さ)16
*文字列はい1 (データ) + 2 (データ、長さ)24
*文字列いいえ1 (データ)8

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

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

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

発表ブログ記事の「理想的なメモリレイアウトを可能にする」セクションで説明されているように、将来的にはこれらの最適化の決定をワークロードごとに下すことを目指しています。

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

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

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

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