Go Opaque API FAQ
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
editions 機能を 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 レベルを選択する場合は、最初にファイルをエディションに移行する必要があります。
遅延デコードを有効にするにはどうすればよいですか?
- コードを移行して、オペーク実装を使用するようにします。
- 遅延デコードする必要がある proto サブメッセージフィールドに
[lazy = true]
オプションを設定します。 - ユニットテストと統合テストを実行し、ステージング環境にロールアウトします。
遅延デコードでエラーは無視されますか?
いいえ。proto.Marshal
は、デコードが最初へのアクセスまで延期されていても、常にワイヤーフォーマットデータを検証します。
質問や問題はどこで問い合わせることができますか?
open2opaque
移行ツール (コードの書き換えが正しくないなど) に問題が見つかった場合は、open2opaque issue tracker で報告してください。
Go Protobuf に問題が見つかった場合は、Go Protobuf issue tracker で報告してください。
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)
次の理由により
Build()
呼び出しは、メッセージ内のすべてのフィールド (明示的に設定されていないフィールドも含む) を反復処理し、それらの値 (存在する場合) を最終メッセージにコピーします。この線形パフォーマンスは、フィールド数の多いメッセージにとって重要です。- 潜在的な追加のヒープ割り当て (
&val
) があります。 - ビルダーは、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)
ビルダーは、オープン構造体 API の複合リテラル構築を模倣するように設計されており、proto メッセージの代替表現としてではありません。
推奨されるパターンは、パフォーマンスも優れています。ビルダー構造体リテラルで直接呼び出される 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 入力レコードに基づいて統計を計算する一部のワークロードでは、CPU 使用率の約 50% を Go Protobuf で費やす可能性があります。このようなワークロードでは、パフォーマンスの向上が明確に目に見える可能性があります。一方、CPU 使用率の 3 ~ 5% しか Go Protobuf で費やさないプログラムでは、パフォーマンスの向上は、他の機会と比較して多くの場合、取るに足らないものになります。
- プログラムは遅延デコードにどれだけ適していますか? 入力メッセージの大部分にアクセスしない場合、遅延デコードは多くの作業を節約できます。このパターンは通常、プロキシサーバー (入力をそのままパススルーする) や、選択性の高いログ分析パイプライン (高レベルの述語に基づいて多くのレコードを破棄する) などのジョブで発生します。
- メッセージ定義に、明示的な存在を持つ多くの基本フィールドが含まれていますか? Opaque API は、整数、ブール値、列挙型、浮動小数点数などの基本フィールドに対してより効率的なメモリ表現を使用しますが、文字列、繰り返しフィールド、またはサブメッセージには使用しません。
Proto2、Proto3、および Editions は、Opaque API とどのように関係していますか?
proto2 および proto3 という用語は、.proto ファイルの異なる構文バージョンを指します。Protobuf Editions は、proto2 と proto3 の後継です。
Opaque API は、.pb.go
ファイルで生成されたコードにのみ影響し、.proto
ファイルに記述する内容には影響しません。
Opaque API は、.proto
ファイルで使用する構文またはエディションに関係なく、同じように機能します。ただし、(protoc
を実行するときにコマンドラインフラグを使用するのではなく) ファイルごとに Opaque API を選択する場合は、最初にファイルをエディションに移行する必要があります。詳細については、メッセージに対して新しい Opaque API を有効にするにはどうすればよいですか?を参照してください。
なぜ基本フィールドのメモリレイアウトのみを変更するのですか?
発表ブログ投稿の「オペーク構造体はメモリ使用量が少ない」セクション で説明されています。
このパフォーマンスの向上 [フィールドの存在をより効率的にモデル化] は、protobuf メッセージの形状に大きく依存します。変更は、整数、ブール値、列挙型、浮動小数点数などの基本フィールドにのみ影響し、文字列、繰り返しフィールド、またはサブメッセージには影響しません。
当然の質問として、文字列、繰り返しフィールド、およびサブメッセージがオペーク API でポインターのままになっているのはなぜですか? 答えは 2 つあります。
考慮事項 1: メモリ使用量
サブメッセージをポインターではなく値として表現すると、メモリ使用量が増加します。各 Protobuf メッセージタイプは内部状態を持ち、サブメッセージが実際に設定されていない場合でもメモリを消費します。
文字列と繰り返しフィールドの場合、状況はより微妙です。文字列ポインターと比較して文字列値を使用した場合のメモリ使用量を比較してみましょう。
Go 変数型 | 設定済み? | ワード数 | #バイト数 |
---|---|---|---|
string | はい | 2 (データ、長さ) | 16 |
string | いいえ | 2 (データ、長さ) | 16 |
*string | はい | 1 (データ) + 2 (データ、長さ) | 24 |
*string | いいえ | 1 (データ) | 8 |
(スライスの状況も同様ですが、スライスヘッダーには 3 ワード (データ、長さ、容量) が必要です。)
文字列フィールドが圧倒的に設定されていない場合、ポインターを使用すると RAM を節約できます。もちろん、この節約は、プログラムにより多くの割り当てとポインターを導入し、ガベージコレクターへの負荷を増やすという代償を伴います。
Opaque API の利点は、ユーザーコードを変更せずに表現を変更できることです。現在のメモリレイアウトは、導入した当時は最適でしたが、今日または 5 年後に測定した場合、おそらく異なるレイアウトを選択していたでしょう。
発表ブログ投稿の「理想的なメモリレイアウトを可能にする」セクションで説明されているように、将来的にワークロードごとにこれらの最適化の決定を行うことを目指しています。
考慮事項 2: 遅延デコード
メモリ使用量の考慮事項に加えて、もう 1 つの制限事項があります。遅延デコード が有効になっているフィールドは、ポインターで表現する必要があります。
Protobuf メッセージは、同時アクセス (同時変更は不可) に対して安全であるため、2 つの異なるゴルーチンが遅延デコードをトリガーする場合、何らかの方法で調整する必要があります。この調整は、sync/atomic
パッケージ を使用して実装されます。このパッケージは、ポインターをアトミックに更新できますが、スライスヘッダー (ワードを超える) は更新できません。
protoc
は現在、(繰り返しではない) サブメッセージに対してのみ遅延デコードを許可していますが、この推論はすべてのフィールドタイプに当てはまります。