> Source: https://www.ymotongpoo.com/works/goblog-ja/protobuf-opaque/


# Go Protobuf: 新しいOpaque API

[Go Protobuf: The new Opaque API](https://go.dev/blog/protobuf-opaque) by Michael Stapelberg

[Protocol Buffers (Protobuf)](https://en.wikipedia.org/wiki/Protocol_Buffers) はGoogleの言語非依存なデータ交換フォーマットです。詳しくは [protobuf.dev](https://protobuf.dev/) を参照してください。

2020年3月、私たちは `google.golang.org/protobuf` モジュールをリリースしました。これは[Go Protobuf APIの大規模な刷新](https://go.dev/blog/protobuf-apiv2)でした。このパッケージでは、[リフレクションのファーストクラスサポート](https://pkg.go.dev/google.golang.org/protobuf/reflect/protoreflect)、[`dynamicpb`](https://pkg.go.dev/google.golang.org/protobuf/types/dynamicpb) 実装、そしてテストを容易にする [`protocmp`](https://pkg.go.dev/google.golang.org/protobuf/testing/protocmp) パッケージが導入されました。

そのリリースでは、新しいAPIを持つ新しいprotobufモジュールが導入されました。そして今日、私たちは生成コード向けの追加APIをリリースします。これはつまり、プロトコルコンパイラ (`protoc`) によって作られる `.pb.go` ファイル内のGoコードのことです。この記事では、新しいAPIを作った動機を説明し、それをプロジェクトでどう使うかを紹介します。

はっきりさせておきたいのですが、私たちは何かを削除するわけではありません。既存の生成コード向けAPIは引き続きサポートします。ちょうど、(`google.golang.org/protobuf` の実装をラップする形で)古いほうのprotobufモジュールを今も変わらずサポートしているのと同じです。Goは[後方互換性の維持にコミット](https://go.dev/blog/compat)しており、これはGo Protobufにも当てはまります!

## 既存のOpen Struct API

私たちは既存のAPIを Open Struct API と呼ぶことにしました。なぜなら、生成される構造体型が直接アクセスに対して開かれているからです。次のセクションでは、これが新しいOpaque APIとどう違うのかを見ていきます。

プロトコルバッファを使うには、まず次のような `.proto` 定義ファイルを作成します。

```proto
edition = "2023";  // proto2とproto3の後継

package log;

message LogEntry {
  string backend_server = 1;
  uint32 request_size = 2;
  string ip_address = 3;
}
```

次に、[プロトコルコンパイラ (`protoc`) を実行して](https://protobuf.dev/getting-started/gotutorial/)、(`.pb.go` ファイルの中に)次のようなコードを生成します。

```go
package logpb

type LogEntry struct {
  BackendServer *string
  RequestSize   *uint32
  IPAddress     *string
  // …内部フィールドは省略…
}

func (l *LogEntry) GetBackendServer() string { … }
func (l *LogEntry) GetRequestSize() uint32   { … }
func (l *LogEntry) GetIPAddress() string     { … }
```

これで生成された `logpb` パッケージをGoコードからインポートし、`logpb.LogEntry` メッセージをprotobufのワイヤーフォーマットにエンコードするために [`proto.Marshal`](https://pkg.go.dev/google.golang.org/protobuf/proto#Marshal) のような関数を呼び出せるようになります。

詳細は[生成コードAPIのドキュメント](https://protobuf.dev/reference/go/go-generated/)を参照してください。

### フィールドの有無 (Field Presence)

この生成コードの重要な側面のひとつが、_フィールドの有無_ (フィールドがセットされているかどうか) をどうモデル化するかです。たとえば、上の例では有無をポインタでモデル化しているため、`BackendServer` フィールドには次のいずれかを設定できます。

1. `proto.String("zrh01.prod")`：フィールドはセットされていて、"zrh01.prod" が入っている
2. `proto.String("")`：フィールドはセットされている(nilでないポインタ)が、空の値が入っている
3. `nil` ポインタ：フィールドはセットされていない

生成コードにポインタがないことに慣れているとしたら、おそらく `syntax = "proto3"` から始まる `.proto` ファイルを使っているのでしょう。フィールドの有無の挙動は、年を経るごとに変わってきました。

* `syntax = "proto2"` はデフォルトで _明示的な有無 (explicit presence)_ を使う
* `syntax = "proto3"` はデフォルトで _暗黙的な有無 (implicit presence)_ を使っていた(このケースでは上記の2番と3番を区別できず、どちらも空文字列として表現される)が、後に `optional` キーワードで[明示的な有無を選択できるように拡張された](https://protobuf.dev/programming-guides/proto3/#field-labels)
* proto2とproto3の両方の後継である `edition = "2023"` は、デフォルトで[_明示的な有無_](https://protobuf.dev/programming-guides/field_presence/)を使う

## 新しいOpaque API

私たちが新しい _Opaque API_ を作ったのは、[生成コードAPI](https://protobuf.dev/reference/go/go-generated/) をメモリ上の内部表現から切り離すためです。(既存の) Open Struct APIにはこのような分離がありません。プログラムがprotobufメッセージのメモリに直接アクセスできてしまいます。たとえば、`flag` パッケージを使ってコマンドラインフラグの値をprotobufメッセージのフィールドにパースすることもできてしまいます。

```go
var req logpb.LogEntry
flag.StringVar(&req.BackendServer, "backend", os.Getenv("HOST"), "…")
flag.Parse() // -backend フラグから BackendServer フィールドを埋める
```

このような密結合の問題点は、protobufメッセージのメモリ上のレイアウトを二度と変更できなくなってしまうことです。この制約を取り除くことで、以下で見るような多くの実装上の改善が可能になります。

新しいOpaque APIで何が変わるのでしょうか。上の例で生成されるコードは次のように変わります。

```go
package logpb

type LogEntry struct {
  xxx_hidden_BackendServer *string // もうエクスポートされない
  xxx_hidden_RequestSize   uint32  // もうエクスポートされない
  xxx_hidden_IPAddress     *string // もうエクスポートされない
  // …内部フィールドは省略…
}

func (l *LogEntry) GetBackendServer() string { … }
func (l *LogEntry) HasBackendServer() bool   { … }
func (l *LogEntry) SetBackendServer(string)  { … }
func (l *LogEntry) ClearBackendServer()      { … }
// …
```

Opaque APIでは、構造体のフィールドは隠され、直接アクセスできなくなります。代わりに、新しいアクセサメソッドによってフィールドの取得、設定、クリアができるようになります。

### Opaque構造体の省メモリ化

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

* (既存の) Open Struct API はポインタを使うため、フィールドの容量コストに64ビットワード分が加算されます。
* Opaque API は[ビットフィールド](https://en.wikipedia.org/wiki/Bit_field)を使うため、(パディングのオーバーヘッドを無視すれば)フィールド1つにつき1ビットで済みます。

変数とポインタの数を減らすことは、アロケータとガベージコレクタへの負荷も下げます。

パフォーマンスの改善幅はプロトコルメッセージの形状に大きく依存します。この変更が影響するのは整数、真偽値、enum、浮動小数点数といった要素的なフィールドのみで、文字列、repeatedフィールド、サブメッセージには影響しません(これらの型に対しては[利益が小さい](https://protobuf.dev/reference/go/opaque-faq/#memorylayout)ためです)。

私たちのベンチマーク結果によると、要素的なフィールドが少ないメッセージはこれまでと同程度のパフォーマンスを示す一方、要素的なフィールドが多いメッセージは、デコード時のアロケーション数が大幅に少なくなります。

```text
             │ 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メッセージのデコードもより効率的にします。

```text
             │ 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によって、パフォーマンスを犠牲にすることなく明示的な有無を使えるようになります。

### 遅延デコーディング (Lazy Decoding)

遅延デコーディングは、サブメッセージの内容を [`proto.Unmarshal`](https://pkg.go.dev/google.golang.org/protobuf/proto#Unmarshal) の実行中ではなく、最初にアクセスされたときにデコードするパフォーマンス最適化です。一度もアクセスされないフィールドの不要なデコードを避けることで、パフォーマンスを改善できます。

遅延デコーディングは、(既存の) Open Struct APIでは安全にサポートできません。Open Struct APIはゲッターを提供していますが、(デコードされていない)構造体フィールドを露出したままにするのは非常にエラーの温床になります。フィールドが最初にアクセスされる直前にデコード処理が確実に走るようにするには、フィールドを非公開にし、そのすべてのアクセスをゲッターとセッター関数経由にする必要があります。

このアプローチによって、Opaque APIで遅延デコーディングを実装することが可能になりました。もちろん、すべてのワークロードがこの最適化から恩恵を受けるわけではありませんが、恩恵を受けるワークロードにとっては、その結果は劇的なものになりえます。私たちは、トップレベルのメッセージの条件(たとえば `backend_server` が新しいLinuxカーネルバージョンを実行しているマシンのいずれかかどうか)に基づいてメッセージを破棄し、メッセージの深くネストしたサブツリーのデコードをスキップできるログ解析パイプラインを見てきました。

一例として、私たちが含めたマイクロベンチマークの結果を示します。遅延デコーディングによって処理量が50%以上、アロケーション数が87%以上削減されていることがわかります!

```text
                  │   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を考えてみましょう。

```proto
message LogEntry {
  enum DeviceType {
    DESKTOP = 0;
    MOBILE = 1;
    VR = 2;
  };
  DeviceType device_type = 1;
}
```

`device_type` のenumフィールドを次のように比較するのはよくある間違いです。

```go
if cv.DeviceType == logpb.LogEntry_DESKTOP.Enum() { // 間違い!
```

バグに気づいたでしょうか。この条件は値ではなくメモリアドレスを比較しています。`Enum()` アクセサは呼び出しのたびに新しい変数をアロケートするため、この条件が真になることは決してありません。正しくは次のように書くべきでした。

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

新しいOpaque APIはこのミスを防ぎます。フィールドが隠されているため、すべてのアクセスはゲッターを経由しなければならないからです。

### 意図しない共有によるミスを減らす

もう少し込み入ったポインタ関連のバグを考えてみましょう。高負荷時に失敗するRPCサービスを安定化させようとしていると仮定します。リクエストミドルウェアの次の部分は正しく見えますが、それでも1人の顧客が大量のリクエストを送るたびにサービス全体がダウンしてしまいます。

```go
logEntry.IPAddress = req.IPAddress
logEntry.BackendServer = proto.String(hostname)
// redactIP() 関数はIPAddressを127.0.0.1にマスクしますが、
// 予期せず logEntry だけでなく req でも同様にマスクしてしまいます!
go auditlog(redactIP(logEntry))
if quotaExceeded(req) {
    // バグ: 送信元にかかわらず、すべてのリクエストがここに来てしまう。
    return fmt.Errorf("server overloaded")
}
```

バグに気づいたでしょうか。1行目はうっかりポインタをコピーしてしまい(それによって指し示す先の変数が `logEntry` と `req` の間で共有されてしまい)、値をコピーしたわけではありませんでした。正しくは次のように書くべきでした。

```go
logEntry.IPAddress = proto.String(req.GetIPAddress())
```

新しいOpaque APIは、セッターがポインタではなく値 (`string`) を受け取るため、この問題を防ぎます。

```go
logEntry.SetIPAddress(req.GetIPAddress())
```

### リフレクションの危険な角を丸める

特定のメッセージ型(たとえば `logpb.LogEntry`)だけでなく、どんなメッセージ型に対しても動作するコードを書くには、何らかのリフレクションが必要です。先の例ではIPアドレスをマスクする関数を使いましたが、どんな型のメッセージにも対応させるには `func redactIP(proto.Message) proto.Message { … }` のように定義できたでしょう。

何年も前は、`redactIP` のような関数を実装する唯一の選択肢は[Goの `reflect` パッケージ](https://go.dev/blog/laws-of-reflection)に頼ることでした。しかしこれは非常にきつい結合を生み出します。手元にあるのはジェネレータの出力だけで、入力となるprotobufメッセージの定義がどのようなものだったかを逆算しなければなりませんでした。2020年3月にリリースされた[`google.golang.org/protobuf` モジュール](https://go.dev/blog/protobuf-apiv2)は、常に優先して使うべき[Protobufリフレクション](https://pkg.go.dev/google.golang.org/protobuf/reflect/protoreflect)を導入しました。Goの `reflect` パッケージはデータ構造の(本来は実装の詳細であるはずの)表現をたどりますが、Protobufリフレクションは表現方法に関係なくプロトコルメッセージの論理的な木構造をたどります。

残念ながら、単にprotobufリフレクションを _提供する_ だけでは十分ではなく、いくつかの危険な角がまだ露出したままになっています。場合によっては、ユーザーが誤ってprotobufリフレクションの代わりにGoのリフレクションを使ってしまうことがあります。

たとえば、(Goのリフレクションを使う) `encoding/json` パッケージでprotobufメッセージをエンコードすることは技術的には可能ですが、その結果は[正規のProtobuf JSONエンコーディング](https://protobuf.dev/programming-guides/proto3/#json)にはなりません。代わりに[`protojson`](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson)パッケージを使ってください。

新しいOpaque APIは、メッセージ構造体のフィールドが隠されているため、この問題を防ぎます。Goのリフレクションをうっかり使ってしまっても、空のメッセージが見えるだけです。これは開発者をprotobufリフレクションへと導くのに十分明確です。

### 理想的なメモリレイアウトを可能にする

前述の「Opaque構造体の省メモリ化」セクションで見たベンチマーク結果は、protobufのパフォーマンスが具体的な使われ方に大きく依存することをすでに示しています。メッセージはどう定義されているか。どのフィールドがセットされているか。

Go Protobufを _誰にとっても_ できる限り高速に保つために、私たちは、ある1つのプログラムだけを助けて他のプログラムのパフォーマンスを損なうような最適化を実装するわけにはいきません。

Goコンパイラも、[Go 1.20がプロファイルガイド最適化 (PGO) を導入する](https://go.dev/blog/go1.20)までは同様の状況にありました。本番環境での挙動を([プロファイリング](https://go.dev/blog/pprof)によって)記録し、そのプロファイルをコンパイラにフィードバックすることで、コンパイラは _特定のプログラムやワークロードに対して_ よりよいトレードオフを選べるようになります。

私たちは、プロファイルを使って特定のワークロード向けに最適化するというアプローチが、さらなるGo Protobufの最適化にとって有望だと考えています。Opaque APIはそれを可能にします。プログラムのコードはアクセサを使うため、メモリ上の表現が変わってもコード自体を更新する必要がありません。そのため、たとえば、めったにセットされないフィールドをオーバーフロー用の構造体に移す、といったことも可能になります。

## 移行

移行は自分のペースで進めてよいですし、まったく移行しなくても構いません。(既存の) Open Struct APIが削除されることはないためです。ただし、Opaque APIに移行していないと、その改善されたパフォーマンスや、今後Opaque APIを対象とした最適化の恩恵を受けられません。

新規開発にはOpaque APIを選ぶことをお勧めします。Protobuf Edition 2024(まだよく知らない方は[Protobuf Editions Overview](https://protobuf.dev/editions/overview/)を参照してください)では、Opaque APIがデフォルトになります。

### Hybrid 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に書き換える

詳しい手順は[移行ガイド](https://protobuf.dev/reference/go/opaque-migration/)を参照してください。大まかな手順は次のとおりです。

1. Hybrid APIを有効にする。
2. `open2opaque` 移行ツールを使って既存のコードを更新する。
3. Opaque APIに切り替える。

### 公開する生成コードにはHybrid APIを使おう

protobufの小規模な使用であれば同じリポジトリの中だけで完結することもありますが、通常、`.proto` ファイルは異なるチームが所有する異なるプロジェクトの間で共有されます。わかりやすい例は、複数の会社がかかわる場合です。(protobufを使った) Google APIを呼び出すには、自分のプロジェクトから[Google Cloud Client Libraries for Go](https://github.com/googleapis/google-cloud-go)を使います。Cloud Client LibrariesをOpaque APIに切り替えることは破壊的なAPI変更になるため選択肢にはなりませんが、Hybrid APIへの切り替えなら安全です。

このような、生成コード(`.pb.go` ファイル)を公開しているパッケージへの私たちからのアドバイスは、ぜひHybrid APIに切り替えることです。`.pb.go` と `_protoopaque.pb.go` の両方のファイルを公開してください。`protoopaque` 版があれば、パッケージの利用者は自分のペースで移行できます。

### 遅延デコーディングを有効にする

Opaque APIに移行すると、遅延デコーディングが(有効にはなっていませんが)使えるようになります! 🎉

有効にするには、`.proto` ファイル内のメッセージ型のフィールドに `[lazy = true]` アノテーションを付けます。

(`.proto` のアノテーションにかかわらず)遅延デコーディングを無効にしたい場合は、[`protolazy` パッケージのドキュメント](https://pkg.go.dev/google.golang.org/protobuf/runtime/protolazy)に、個々のUnmarshal操作またはプログラム全体に影響を与えるオプトアウト方法が説明されています。

## 次のステップ

open2opaqueツールを自動的な形で運用することで、私たちはここ数年でGoogleの `.proto` ファイルとGoコードの大部分をOpaque APIに変換してきました。より多くの本番ワークロードをOpaque APIに移すにつれて、私たちはその実装を継続的に改善してきました。

したがって、Opaque APIを試す際に問題に遭遇することはまずないと考えています。それでも万が一問題に遭遇した場合は、[Go Protobufのissueトラッカー](https://github.com/golang/protobuf/issues/)で私たちに知らせてください。

Go Protobufのリファレンスドキュメントは [protobuf.dev → Go Reference](https://protobuf.dev/reference/go/) にあります。

By Michael Stapelberg

