🐈

翻訳 Go Protobuf: The new Opaque API

2024/12/18に公開

2024.12.16 に Go Blog に投稿された Michael Stapelberg氏 [1] による Go Protobuf: The new Opaque API の翻訳です。

(訳注)

  • generated code を"生成コード"と訳しました。"被生成コード"でも良さそう
  • Open Struct API, Opaque API は対比がわかりやすいように英語のままとしました
  • field presence を"フィールド有無"と訳しました。個人の感想ですが、フィールドの存在、フィールド存在だとしっくりこなかったです

以下、翻訳


[Protocol Buffers (Protobuf) は、Google による、言語に依存しないデータ交換フォーマットです。 protobuf.dev をご参照ください。]

2020年3月、私たちはgoogle.golang.org/protobufモジュールをリリースしました。それは Go Protobuf API の大幅な見直しでした。 このパッケージでは、第一級としてのリフレクションがサポートされ、dynamicpb が実装され、そしてより簡単なテストのためのprotocmp パッケージが導入されました。

そのリリースで、新しいprotobufモジュールと新しい API が導入されました。そして本日、さらに新たな API を生成コードに追加するリリースを行います。生成コードとは、プロトコルコンパイラ(protoc)によって生成される.pb.goファイル内のGoコードを意味します。このブログ記事では、新しいAPIを作成した動機と、プロジェクトで使用する方法について説明します。

明言しておくと、私たちは何も取り除くつもりはありません。私たちは、google.golang.org/protobuf の実装をラップすることで、古いprotobufモジュールを現在もサポートしているように、生成コード上の既存の API もサポートし続けます。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: フィールド有無

この生成コードの重要な一面として、フィールド有無 (フィールドがセットされているかどうか; field presence) がどのようにモデル化されるかがあげられます。例えば、上記の例ではポインタを使用して存在をモデル化しています。つまり、BackendServerフィールドを以下のようにセットできます。

  1. proto.String("zrh01.prod"): フィールドがセットされ、"zrh01.prod"を含みます
  2. proto.String(""): フィールドはセットされていますが(nilではないポインタ)、値は空です
  3. nilポインタ: フィールドはセットされていません

生成コードにポインタがないことに慣れているなら、.protoファイルがsyntax = "proto3"で始まっている可能性が高いでしょう。フィールド有無の動作は、長年にわたって変更されてきました。

  • syntax = "proto2"は、デフォルトで_明示的な有無_が使用されます
  • syntax = "proto3"はデフォルトで_暗黙的な有無_(ケース2と3を区別できず、どちらも空文字列で表される)を使用していましたが、後に拡張され、optionalキーワード明示的な有無を指定できるようになりました。
  • edition = "2023"proto2とproto3の両方を継承し、デフォルトで明示的な有無を使用します。

新たな Opaque API

私たちは、生成コード API をその裏にあるメモリ内表現から切り離すために、新たな Opaque API を作成しました。 (既存の) Open Struct API にはそのような分離がありません。 例えば、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構造体は省メモリ

メモリレイアウトに加えた変更のひとつは、基本フィールドのフィールド有無をより効率的にモデル化することでした。

  • (既存の) Open Struct APIはポインタを使用しており、フィールドの空間コストに64ビットワードが加わります
  • Opaque APIはビットフィールドを使用しており、フィールドごとに1ビットが必要です(パディングオーバーヘッドは無視)

変数とポインタの使用を減らすことで、アロケーターとガベージコレクターの負荷も軽減されます。

パフォーマンスの向上は、プロトコルメッセージの形状に大きく依存します。今回の変更は、整数、bool、enum、floatなどの基本フィールドのみに影響し、文字列、repeatedフィールド、サブメッセージには影響しません。(これらのタイプでは、リターンが少ないため)

私たちのベンチマーク結果によると、基本フィールドが少ないメッセージでは以前と同等のパフォーマンスとなり、基本フィールドが多いメッセージでは、大幅に少ない割り当てでデコードされることが示されました。

             │ 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()アクセサは呼び出しのたびに新しい変数を割り当てるため、この条件が真になることはありません。この判定は次のようにすべきです。

if cv.GetDeviceType() == logpb.LogEntry_DESKTOP {

新しい Opaque API では、このようなミスを防ぐことができます。 フィールドが隠蔽されているため、すべてのアクセスはゲッターを経由する必要があります。

動機:誤った共有ミスを減らす

もう少し複雑なポインタ関連のバグを考えてみましょう。高負荷時にエラーが発生するRPCサービスを安定化させようとしていると仮定します。次に示すリクエストミドルウェアの一部は正しいように見えますが、実はたった1人の顧客が大量のリクエストを送信しただけで、サービス全体がダウンしてしまいます。

logEntry.IPAddress = req.IPAddress
logEntry.BackendServer = proto.String(hostname)
// redactIP() 関数は、logEntry について IPAddress を 127.0.0.1 に修正しますが、意外にも req についても修正します!
go auditlog(redactIP(logEntry))
if quotaExceeded(req) {
// バグ: 送信元にかかわらず、すべてのリクエストがここに行き着きます
return fmt.Errorf("server overloaded")
}

バグが見つかりましたか?最初の行は、値の代わりに誤ってポインタをコピーしています (それゆえ、logEntryreqメッセージ間でポインタ先の変数が共有されてしまいました)。本来は、以下とすべきでした。

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を すべての人 にとって可能な限り高速に保つため、1つのプログラムにのみ役立ち、他のプログラムのパフォーマンスを低下させるような最適化は実装できません。

Go 1.20でプロファイルガイド最適化(PGO)が導入されるまでは、Goコンパイラも同様の状況でした。 本番の動作を記録 (プロファイリング) し、そのプロファイルをコンパイラにフィードバックすることで、コンパイラは_特定のプログラムやワークロード_に対してより良いトレードオフをとることができます。

プロファイルを使用して特定のワークロードを最適化することは、Go Protobufのさらなる最適化に向けた有望なアプローチであると考えています。Opaque APIにより、次のことが可能になります。プログラムコードはアクセサを使用し、それを変更することなく、メモリ表現を変えることができます。例えば、セットされることがまれなフィールドをoverflow structに移動することができます。

マイグレーション

ご自身のスケジュールで移行することができます。あるいは、まったく移行しなくても構いません。(既存の) Open Struct API は削除されません。しかし、新しい Opaque API を使用していない場合、そのパフォーマンスの向上や、その API を対象とした将来の最適化の恩恵を受けることができません。

新しい開発には Opaque API を使用することをお勧めします。Protobuf Edition 2024 (まだご存知でない方は、Protobuf Edtions 概要を参照) では、Opaque APIがデフォルトになります。

ハイブリッドAPI

Open Struct APIとOpaque APIの他に、ハイブリッドAPIもあります。これは、構造体のフィールドをエクスポートしたまま既存のコードを動作させつつ、新しいアクセサメソッドを追加することでOpaque APIへの移行も可能にするものです。

ハイブリッドAPIでは、protobufコンパイラは2つのAPIレベルでコードを生成します。.pb.goはハイブリッドAPIで、_protoopaque.pb.goバージョンはオペークAPIで、protoopaqueビルドタグを使用してビルドすることで選択できます。

コードをOpaque APIに書き換える

詳細な手順については、移行ガイドを参照してください。大まかな手順は以下の通りです。

  1. ハイブリッドAPIを有効にします。
  2. open2opaque移行ツールを使用して既存のコードを更新します。
  3. Opaque APIに切り替えます。

公開された生成コードに関するアドバイス: ハイブリッドAPIを使おう

protobufの小規模な使用は、すべて同じリポジトリ内に収めることができますが、通常は.protoファイルは異なるチームが所有する異なるプロジェクト間で共有されます。明白な例としては、異なる企業が関与している場合が挙げられます。Google API (protobufを使用) を呼び出すには、プロジェクトからGoogle Cloud Client Libraries for Goを使用します。Cloud Client LibrariesをOpaque APIに切り替えることは、APIの変更が非互換になるため選択肢になりませんが、Hybrid APIに切り替えることは安全です。

生成コード (.pb.goファイル) を公開するパッケージに対する私たちのアドバイスは、ハイブリッド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の実装を継続的に改善してきました。

そのため、Opaque API を使用しても問題が発生することはないはずです。それでも問題が発生する場合は、Go Protobuf の issue trackerにご連絡ください。

Go Protobuf のリファレンス資料は、protobuf.dev → Go Reference でご覧いただけます。

脚注
  1. 本筋と関係ないですが、投稿者の Michael Stapelberg氏は、kinesisキーボードの改造記事を書いており、個人的にとても参考になったので謎の邂逅を感じました ↩︎

Discussion