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 エディションの概要」を参照) では、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 レベルを選択したい場合は、まずそのファイルをエディションに移行する必要があります。

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

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

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

いいえ。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)

理由は以下の通りです。

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

Proto2、Proto3、およびエディションは 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 で文字列、繰り返しフィールド、サブメッセージがポインタのままなのか、という点が挙げられます。その答えは二重です。

考慮事項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 は現在、(繰り返されない)サブメッセージに対してのみ遅延デコードを許可していますが、この推論はすべてのフィールド型に当てはまります。