The Go Blog
Go Protobuf: 新しいOpaque API
[Protocol Buffers (Protobuf)は、Googleの言語に依存しないデータ交換フォーマットです。protobuf.devを参照してください。]
2020年3月、私たちはgoogle.golang.org/protobufモジュール、つまりGo Protobuf APIのメジャーアップデートをリリースしました。このパッケージは、ファーストクラスのリフレクションサポート、dynamicpbの実装、およびテストを容易にするためのprotocmpパッケージを導入しました。
このリリースでは、新しいAPIを持つ新しいprotobufモジュールが導入されました。本日、私たちは生成コード、つまりプロトコルコンパイラ(protoc)によって作成される.pb.goファイル内のGoコード向けに、追加のAPIをリリースします。このブログ記事では、新しいAPIを作成した動機と、プロジェクトでそれを使用する方法を説明します。
明確にしておきます:何も削除するわけではありません。私たちは既存のAPIに対する生成コードのサポートを継続します。古いprotobufモジュールを(google.golang.org/protobufの実装をラップすることで)引き続きサポートしているのと同じです。Goは後方互換性を重視していますし、これはGo Protobufにも当てはまります!
背景: (既存の)Open Struct API
生成された構造体型が直接アクセス可能であるため、既存のAPIをOpen Struct APIと呼んでいます。次のセクションでは、それが新しいOpaque APIとどのように異なるかを見ていきます。
プロトコルバッファを操作するには、まず次のような.proto定義ファイルを作成します。
edition = "2023"; // successor to proto2 and proto3
package log;
message LogEntry {
string backend_server = 1;
uint32 request_size = 2;
string ip_address = 3;
}
次に、プロトコルコンパイラ(protoc)を実行して、次のようなコードを(.pb.goファイルに)生成します。
package logpb
type LogEntry struct {
BackendServer *string
RequestSize *uint32
IPAddress *string
// …internal fields elided…
}
func (l *LogEntry) GetBackendServer() string { … }
func (l *LogEntry) GetRequestSize() uint32 { … }
func (l *LogEntry) GetIPAddress() string { … }
これで、生成されたlogpbパッケージをGoコードからインポートし、proto.Marshalのような関数を呼び出して、logpb.LogEntryメッセージをprotobufワイヤー形式にエンコードできます。
詳細は生成コードAPIドキュメントで確認できます。
(既存の)Open Struct API: フィールドの有無
この生成コードの重要な側面は、*フィールドの有無* (フィールドが設定されているかどうか) がどのようにモデル化されているかです。例えば、上記の例ではポインタを使用して有無をモデル化しているため、`BackendServer`フィールドを次のように設定できます。
proto.String("zrh01.prod"): フィールドが設定されており、「zrh01.prod」が含まれている。proto.String(""): フィールドは設定されている(nilではないポインタ)が、空の値を含んでいる。nilポインタ: フィールドは設定されていません
ポインタを持たない生成コードに慣れている場合、syntax = "proto3"で始まる.protoファイルを使用している可能性があります。フィールドの有無の動作は長年にわたって変化してきました。
syntax = "proto2"はデフォルトで*明示的な存在*を使用しますsyntax = "proto3"はデフォルトで*暗黙的な存在*を使用していましたが(ケース2と3を区別できず、どちらも空文字列で表現される)、後に`optional`キーワードで明示的な存在を選択できるように拡張されました。edition = "2023"は、proto2とproto3の両方の後継であり、デフォルトで*明示的な存在*を使用します。
新しいOpaque API
私たちは、生成コードAPIと基盤となるインメモリ表現を切り離すために、新しい*Opaque API*を作成しました。(既存の)Open Struct APIにはそのような分離がなく、プログラムがprotobufメッセージメモリに直接アクセスできます。例えば、flagパッケージを使用してコマンドラインフラグ値をprotobufメッセージフィールドにパースできます。
var req logpb.LogEntry
flag.StringVar(&req.BackendServer, "backend", os.Getenv("HOST"), "…")
flag.Parse() // fills the BackendServer field from -backend flag
このような密結合の問題点は、protobufメッセージのメモリレイアウトを変更できないことです。この制限を解除することで、多くの実装改善が可能になります。これについては以下で説明します。
新しいOpaque APIでは何が変わるのでしょうか?上記の例の生成コードは次のように変更されます。
package logpb
type LogEntry struct {
xxx_hidden_BackendServer *string // no longer exported
xxx_hidden_RequestSize uint32 // no longer exported
xxx_hidden_IPAddress *string // no longer exported
// …internal fields elided…
}
func (l *LogEntry) GetBackendServer() string { … }
func (l *LogEntry) HasBackendServer() bool { … }
func (l *LogEntry) SetBackendServer(string) { … }
func (l *LogEntry) ClearBackendServer() { … }
// …
Opaque APIでは、構造体フィールドは隠蔽され、直接アクセスできなくなります。代わりに、新しいアクセサメソッドによって、フィールドの取得、設定、またはクリアが可能になります。
Opaque構造体はメモリ使用量が少ない
メモリレイアウトに加えられた変更の1つは、基本フィールドのフィールドの有無をより効率的にモデル化することです。
- (既存の)Open Struct APIはポインタを使用しており、フィールドのスペースコストに64ビットワードが追加されます。
- Opaque APIはビットフィールドを使用し、フィールドあたり1ビットを必要とします(パディングオーバーヘッドは無視)。
変数の数とポインタを減らすことで、アロケータとガベージコレクタへの負荷も軽減されます。
パフォーマンスの向上は、プロトコルメッセージの形状に大きく依存します。この変更は、整数、ブール値、列挙型、浮動小数点型などの基本フィールドにのみ影響し、文字列、繰り返しフィールド、またはサブメッセージには影響しません(これらの型では利益が少ないため)。
ベンチマーク結果によると、基本フィールドが少ないメッセージは以前と同等のパフォーマンスを示し、基本フィールドが多いメッセージはデコード時のアロケーションが大幅に削減されます。
│ Open Struct API │ Opaque API │
│ allocs/op │ allocs/op vs base │
Prod#1 360.3k ± 0% 360.3k ± 0% +0.00% (p=0.002 n=6)
Search#1 1413.7k ± 0% 762.3k ± 0% -46.08% (p=0.002 n=6)
Search#2 314.8k ± 0% 132.4k ± 0% -57.95% (p=0.002 n=6)
アロケーションを減らすことで、protobufメッセージのデコードも効率化されます。
│ Open Struct API │ Opaque API │
│ user-sec/op │ user-sec/op vs base │
Prod#1 55.55m ± 6% 55.28m ± 4% ~ (p=0.180 n=6)
Search#1 324.3m ± 22% 292.0m ± 6% -9.97% (p=0.015 n=6)
Search#2 67.53m ± 10% 45.04m ± 8% -33.29% (p=0.002 n=6)
(すべての測定はAMD Castle Peak Zen 2で行われました。ARMおよびIntel CPUでの結果も同様です。)
注:暗黙的なプレゼンスを持つproto3も同様にポインタを使用しないため、proto3から移行する場合はパフォーマンスの向上は見られません。パフォーマンス上の理由で暗黙的なプレゼンスを使用しており、空のフィールドと未設定のフィールドを区別できる利便性を放棄していた場合、Opaque APIを使用することで、パフォーマンスのペナルティなしに明示的なプレゼンスを使用できるようになります。
モチベーション: 遅延デコード
遅延デコードは、サブメッセージの内容がproto.Unmarshal中にデコードされるのではなく、最初にアクセスされたときにデコードされるパフォーマンス最適化です。遅延デコードは、決してアクセスされないフィールドの不必要なデコードを回避することで、パフォーマンスを向上させることができます。
(既存の)Open Struct APIでは、遅延デコードを安全にサポートできません。Open Struct APIはゲッターを提供しますが、(未デコードの)構造体フィールドを公開したままにすると、非常にエラーが発生しやすくなります。デコードロジックがフィールドに初めてアクセスされる直前に実行されるようにするためには、フィールドをプライベートにし、それへのすべてのアクセスをゲッター関数とセッター関数を介して仲介する必要があります。
このアプローチにより、Opaque APIで遅延デコードを実装することが可能になりました。もちろん、すべてのワークロードがこの最適化の恩恵を受けるわけではありませんが、恩恵を受けるワークロードにとっては、その結果は目覚ましいものになる可能性があります。例えば、トップレベルのメッセージ条件(例:backend_serverが新しいLinuxカーネルバージョンを実行しているマシンの1つであるかどうか)に基づいてメッセージを破棄するログ分析パイプラインでは、深くネストされたメッセージのサブツリーのデコードをスキップできることが確認されています。
例として、私たちが含めたマイクロベンチマークの結果を以下に示します。遅延デコードにより、作業が50%以上、アロケーションが87%以上削減されることが示されています!
│ nolazy │ lazy │
│ sec/op │ sec/op vs base │
Unmarshal/lazy-24 6.742µ ± 0% 2.816µ ± 0% -58.23% (p=0.002 n=6)
│ nolazy │ lazy │
│ B/op │ B/op vs base │
Unmarshal/lazy-24 3.666Ki ± 0% 1.814Ki ± 0% -50.51% (p=0.002 n=6)
│ nolazy │ lazy │
│ allocs/op │ allocs/op vs base │
Unmarshal/lazy-24 64.000 ± 0% 8.000 ± 0% -87.50% (p=0.002 n=6)
モチベーション: ポインタ比較の間違いを減らす
ポインタによるフィールドの有無のモデル化は、ポインタ関連のバグを引き起こします。
LogEntryメッセージ内で宣言されたenumを考えてみましょう。
message LogEntry {
enum DeviceType {
DESKTOP = 0;
MOBILE = 1;
VR = 2;
};
DeviceType device_type = 1;
}
よくある間違いは、device_type enumフィールドを次のように比較することです。
if cv.DeviceType == logpb.LogEntry_DESKTOP.Enum() { // incorrect!
バグに気づきましたか?この条件は値ではなくメモリアドレスを比較しています。Enum()アクセサーは呼び出しごとに新しい変数を割り当てるため、この条件がtrueになることはありません。正しいチェックは次のようになります。
if cv.GetDeviceType() == logpb.LogEntry_DESKTOP {
新しいOpaque APIはこの間違いを防ぎます。フィールドは隠蔽されているため、すべてのアクセスはゲッターを介して行われなければなりません。
モチベーション: 偶発的な共有ミスを減らす
もう少し複雑なポインタ関連のバグを考えてみましょう。高負荷時に失敗するRPCサービスを安定させようとしているとします。リクエストミドルウェアの以下の部分は正しく見えますが、たった1つの顧客が大量のリクエストを送ると、サービス全体がダウンしてしまいます。
logEntry.IPAddress = req.IPAddress
logEntry.BackendServer = proto.String(hostname)
// The redactIP() function redacts IPAddress to 127.0.0.1,
// unexpectedly not just in logEntry *but also* in req!
go auditlog(redactIP(logEntry))
if quotaExceeded(req) {
// BUG: All requests end up here, regardless of their source.
return fmt.Errorf("server overloaded")
}
バグに気づきましたか?最初の行で誤ってポインタをコピーしてしまい (その結果、指し示された変数が`logEntry`と`req`メッセージ間で共有されてしまい)、その値ではなくなっていました。正しくは次のように書くべきでした。
logEntry.IPAddress = proto.String(req.GetIPAddress())
新しいOpaque APIは、セッターがポインタではなく値(`string`)を受け取るため、この問題を防止します。
logEntry.SetIPAddress(req.GetIPAddress())
モチベーション: 鋭利な角を修正する: リフレクション
特定のメッセージ型 (例: `logpb.LogEntry`) だけでなく、あらゆるメッセージ型で機能するコードを書くには、何らかのリフレクションが必要です。前の例では、IPアドレスを編集する関数を使用しました。あらゆる種類のメッセージで機能するように、`func redactIP(proto.Message) proto.Message { … }` のように定義できたはずです。
何年も前は、redactIPのような関数を実装する唯一の選択肢はGoのreflectパッケージを使うことであり、その結果として非常に密結合が生じました。つまり、ジェネレーターの出力しかなく、入力のprotobufメッセージ定義がどのようなものだったかを逆コンパイルする必要がありました。google.golang.org/protobufモジュールのリリース(2020年3月)はProtobufリフレクションを導入しました。これは常に推奨されるべきです。Goのreflectパッケージはデータ構造の表現を走査しますが、これは実装の詳細であるべきです。Protobufリフレクションは、その表現に関係なく、プロトコルメッセージの論理ツリーを走査します。
残念ながら、protobufリフレクションを*提供する*だけでは不十分であり、依然としていくつかの鋭利な部分が露出しています。場合によっては、ユーザーが誤ってprotobufリフレクションの代わりにGoリフレクションを使用してしまう可能性があります。
例えば、encoding/jsonパッケージ(Goリフレクションを使用)でprotobufメッセージをエンコードすることは技術的には可能でしたが、結果は規範的なProtobuf JSONエンコーディングではありませんでした。protojsonパッケージを代わりに使用してください。
新しいOpaque APIは、メッセージ構造体フィールドが隠されているため、この問題を防止します。Goリフレクションを誤って使用すると、空のメッセージが表示されます。これは、開発者をprotobufリフレクションに向かわせるのに十分明確です。
モチベーション: 理想的なメモリレイアウトを可能にする
より効率的なメモリ表現セクションのベンチマーク結果は、protobufのパフォーマンスが特定の利用方法に大きく依存することを示しています。メッセージはどのように定義されているのか?どのフィールドが設定されているのか?
Go Protobufを*すべての人*にとって可能な限り高速に保つために、私たちは一つのプログラムに役立つが他のプログラムのパフォーマンスを低下させる最適化を実装することはできません。
Goコンパイラもかつては似たような状況にありましたが、Go 1.20でプロファイルガイド最適化 (PGO) が導入されるまではそうでした。プロファイリングを通じて本番環境での動作を記録し、そのプロファイルをコンパイラにフィードバックすることで、コンパイラは*特定のプログラムやワークロードに対して*より良いトレードオフを行うことができます。
特定のワークロード向けに最適化するためにプロファイルを使用することは、Go Protobufのさらなる最適化にとって有望なアプローチだと考えています。Opaque APIはそれを可能にします。プログラムコードはアクセサーを使用するため、メモリ表現が変更されても更新する必要がありません。そのため、例えば、めったに設定されないフィールドをオーバーフロース構造体に移動させることができます。
移行
独自のスケジュールで移行するか、まったく移行しないことも可能です。(既存の)Open Struct APIは削除されません。しかし、新しいOpaque APIを使用しない場合、その改善されたパフォーマンスや将来の最適化の恩恵を受けることはできません。
新規開発にはOpaque APIを選択することを推奨します。Protobuf Edition 2024(まだご存じない場合はProtobuf Editions Overviewを参照)では、Opaque APIがデフォルトになります。
ハイブリッドAPI
Open Struct APIとOpaque APIの他に、Hybrid APIもあります。これは、既存のコードを機能させるために構造体フィールドをエクスポートしたままにしますが、新しいアクセサメソッドを追加することでOpaque APIへの移行も可能にします。
Hybrid APIでは、protobufコンパイラは2つのAPIレベルでコードを生成します。.pb.goはHybrid APIですが、_protoopaque.pb.goバージョンはOpaque APIであり、protoopaqueビルドタグを使用してビルドすることで選択できます。
コードをOpaque APIに書き換える
詳細な手順については、移行ガイドを参照してください。大まかな手順は次のとおりです。
- ハイブリッドAPIを有効にする。
open2opaque移行ツールを使用して既存のコードを更新します。- Opaque APIに切り替える。
公開されている生成コードに関するアドバイス: ハイブリッドAPIを使用する
Protobufの小規模な利用は同じリポジトリ内で完結できますが、通常、.protoファイルは異なるチームが所有する異なるプロジェクト間で共有されます。明らかな例は、異なる企業が関与する場合です。Google API (protobufを使用) を呼び出すには、プロジェクトからGo用Google Cloudクライアントライブラリを使用します。CloudクライアントライブラリをOpaque APIに切り替えるのはAPIの破壊的変更となるため選択肢ではありませんが、Hybrid APIに切り替えるのは安全です。
生成されたコード(.pb.goファイル)を公開するパッケージについては、Hybrid APIに切り替えることをお勧めします!.pb.goファイルと_protoopaque.pb.goファイルの両方を公開してください。protoopaqueバージョンにより、コンシューマーは自分のスケジュールで移行できます。
遅延デコードの有効化
Opaque APIに移行すると、遅延デコードが利用可能になります(ただし有効ではありません)! 🎉
有効にするには、.protoファイルで、メッセージ型フィールドに[lazy = true]アノテーションを付けます。
遅延デコードをオプトアウトするには(.protoアノテーションにもかかわらず)、protolazyパッケージドキュメントで、個々のUnmarshal操作またはプログラム全体に影響する利用可能なオプトアウトについて説明されています。
次のステップ
過去数年間、open2opaqueツールを自動的に使用することで、Googleの膨大な数の.protoファイルとGoコードをOpaque APIに変換してきました。私たちは、ますます多くの本番ワークロードをそれに移行させるにつれて、Opaque APIの実装を継続的に改善してきました。
したがって、Opaque APIを試しても問題が発生することはないと予想しています。それでも問題が発生した場合は、Go Protobuf issue trackerでお知らせください。
Go Protobufのリファレンスドキュメントは、protobuf.dev → Go Referenceで参照できます。
次の記事: Go Developer Survey 2024年下半期の結果
前の記事: Go 15周年
ブログインデックス