12月17日、Goが「Go Protobuf: The new Opaque API」と題した記事を公開した。この記事では、Go Protobufの新しいOpaque APIに関する変更点とその活用方法について詳しく紹介されている。
以下に、その内容を紹介する。
プロトコルバッファとは
プロトコルバッファ(Protocol Buffers、通称Protobuf)は、Googleが開発した言語に依存しないデータ交換フォーマットで、特にシリアライズ(データ構造をバイト列に変換する)とデシリアライズ(バイト列から元のデータ構造を復元する)を効率的に行うためのツールである。Protobufは、データの構造を記述するためのインターフェース定義言語(IDL)を使用し、通常は複数の言語間でデータをやり取りする際に役立つ。特にネットワーク越しの通信やストレージなどで、データのサイズを最小限に抑え、かつ高速に処理を行いたい場合に利用される。
プロトコルバッファの特徴
1.軽量かつ高速: バイナリフォーマットを使用するため、XMLやJSONに比べてデータのサイズが小さく、シリアライズ・デシリアライズの速度も非常に速い。
2. 言語独立: Protobufは、さまざまなプログラミング言語に対応しており、データを複数の異なるシステムやサービス間でやり取りする際に便利です。サポートされている言語には、C++, Java, Python, Go, JavaScriptなどがあります。
3. スキーマ駆動: データの構造(スキーマ)は、.protoファイルというテキスト形式で定義されます。このスキーマを元に、特定のプログラミング言語用のコード(クラスや構造体)を自動生成します。
Protobufの使い方
- スキーマ定義: まず、データの構造を.protoというファイルで定義する。
- コード生成: protocというコンパイラを使って、この.protoファイルから特定のプログラミング言語用のコードを生成する。
- データのシリアライズ・デシリアライズ: プログラム内で生成されたクラスや構造体を使ってデータをシリアライズしたり、バイト列からデータをデシリアライズしたりできる。
.protoファイルの例
syntax = "proto3";
message LogEntry {
string backend_server = 1;
uint32 request_size = 2;
string ip_address = 3;
}
上記の例では、LogEntryというメッセージ(データ構造)を定義している。backend_server、request_size、ip_addressはそれぞれフィールドであり、これらはProtobufによってシリアライズされる際に、バイナリ形式に変換される。
Goにおけるプロトコルバッファ
Goでは、従来より「Open Struct API」と呼ばれる方式でプロトコルバッファに対応していた。
以下が、Open Struct APIによって生成されるコードの例である。
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 { /* 実装 */ }
上記のようにOpenStruct APIでは、生成された構造体のフィールドに対して、ポインタを介して直接アクセス可能であった。このアプローチはシンプルであったが、メモリレイアウトの変更やパフォーマンスの最適化に制約があった。
新しいOpaque APIとは
新しいOpaque APIは、生成されたコードにAPIを追加し、従来のOpen Struct APIの問題点からの脱却を図っている。
Opaque APIの特徴を以下に示す。
- フィールドの非公開化 : 生成された構造体のフィールドが非公開となり、直接アクセスができなくなる。
- アクセサーメソッドの導入 : フィールドへのアクセスは専用のゲッター、セッター、クリアメソッドを通じて行う。
- メモリ使用量の削減 : フィールド存在管理にビットフィールドを使用することで、メモリ使用量を削減。
新しいOpaque APIを使用すると、先程の構造体定義はフィールドが非公開化され、専用のメソッドを通じてのみアクセスが可能となる。
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の利点
1. メモリ使用量の削減
従来のOpen Struct APIでは、フィールドごとにポインタを使用していたため、メモリコストが高かった。Opaque APIではビットフィールドを使用することで、メモリ使用量を大幅に削減している。
│ 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)
特に、基本フィールドが多いメッセージでは、Opaque APIの方がメモリ使用量が少なくなる傾向が見られる。
2. パフォーマンスの向上
フィールド存在管理の効率化により、デコード処理が高速化されている。以下は、デコード処理のパフォーマンス改善を示すベンチマーク結果である。
│ 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)
3. バグの削減
ポインタの使用を排除することで、ポインタ関連のバグを防止できる。
例えば従来のOpen Struct APIでは、フィールドがポインタで管理されていたため、値の比較に誤りが生じる可能性があった。例えば、以下のようなコードは誤った比較を行っている。
if cv.DeviceType == logpb.LogEntry_DESKTOP.Enum() { // incorrect!
// 処理
}
この条件式は、DeviceType フィールドのメモリアドレスを比較しているため、常に真になることはなく、意図した比較が行われていない。
正しくは以下のように値を比較する必要がある。
if cv.GetDeviceType() == logpb.LogEntry_DESKTOP {
// 正しい処理
}
Opaque APIでは、フィールドが非公開化され、ゲッターを通じて値を取得するため、このような比較ミスが防止される。
また、Open Struct APIではポインタの共有により、意図せずにデータが共有され、予期しない副作用が発生することがある。
logEntry.IPAddress = req.IPAddress
// redactIP() 関数が logEntry と req の両方を変更する
go auditlog(redactIP(logEntry))
if quotaExceeded(req) {
return fmt.Errorf("server overloaded")
}
この場合、logEntry.IPAddress に req.IPAddress のポインタを代入しているため、redactIP 関数が logEntry の IPAddress を変更すると、req.IPAddress も変更されてしまう。
正しくは以下のように値をコピーする必要がある。
logEntry.IPAddress = proto.String(req.GetIPAddress())
Opaque APIでは、セッターが値を受け取るため、ポインタの共有が起こらず、このようなバグを防止できる。
遅延デコード(Lazy Decoding)の導入
遅延デコード(Lazy Decoding)は、サブメッセージの内容を初めてアクセスされたときにデコードするというパフォーマンス最適化手法である。Opaque APIの導入により、安全かつ効率的にLazy Decodingを実現できるようになった。
例として、Lazy Decodingに関するマイクロベンチマークの結果を示す。これは、遅延デコードによって、処理時間が 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)
移行方法
APIの移行をスムーズに行えるよう、新旧のAPIをどちらも利用可能なハイブリッドAPIも用意されている。ハイブリッドAPIでは、従来の構造体フィールドを公開しつつ、新しいアクセサーメソッドも提供することで、既存コードの互換性を維持しながらOpaque APIへの移行を容易にする。
既存のOpen Struct APIからOpaque APIへの移行は以下の手順で行うことが推奨されている。
1. Hybrid APIの有効化: Open Struct APIとOpaque APIの両方をサポートするハイブリッドAPIを有効化する。
2. open2opaque移行ツールの使用: 既存コードをハイブリッドAPIに対応させるため、open2opaqueツールを使用してコードを更新する。
3. Opaque APIへの切り替え: 最終的にOpaque APIに完全に移行する。
まとめ
Go Protobufの新しいOpaque APIは、メモリ使用量の削減、パフォーマンスの向上、バグの削減といった多くの利点を提供する。特に大規模なシステムにおけるデータ管理において、その効果は顕著である。既存のOpen Struct APIとの互換性も維持されているため、段階的な移行が可能である。詳細はGo Protobuf: The new Opaque APIを参照していただきたい。