Goのサイズセマンティクス

proto.Sizeの使い方のポイント(と注意点)を説明します

proto.Size関数は、すべてのフィールド(サブメッセージを含む)を走査することで、proto.Messageのワイヤー形式エンコーディングのバイト単位のサイズを返します。

特に、Go Protobufがメッセージをどのようにエンコードするかのサイズを返します。

典型的な使用法

空のメッセージの特定

proto.Sizeが0を返すことを確認することは、空のメッセージを識別する簡単な方法です。

if proto.Size(m) == 0 {
    // No fields set (or, in proto3, all fields matching the default);
    // skip processing this message, or return an error, or similar.
}

プログラム出力のサイズ制限

この例では、「ダウンストリームシステム」と呼ぶ別のシステムのために作業タスクを生成するバッチ処理パイプラインを作成しているとします。ダウンストリームシステムは、小規模から中規模のタスクを処理するようにプロビジョニングされていますが、ロードテストの結果、500MBを超える作業タスクが提示されるとシステムがカスケード障害に陥ることが示されています。

最善の修正策はダウンストリームシステムに保護を追加することですが (参照: https://cloud.google.com/blog/products/gcp/using-load-shedding-to-survive-a-success-disaster-cre-life-lessons)、ロードシェアリングの実装が現実的でない場合は、パイプラインに緊急の修正を追加することを決定できます

func (*beamFn) ProcessElement(key string, value []byte, emit func(proto.Message)) {
  task := produceWorkTask(value)
  if proto.Size(task) > 500 * 1024 * 1024 {
    // Skip every work task over 500 MB to not overwhelm
    // the brittle downstream system.
    return
  }
  emit(task)
}

誤った使用法: Unmarshalとの関連性なし

proto.SizeはGo Protobufがメッセージをどのようにエンコードするかを示すバイト数を返すため、受信するProtobufメッセージのストリームをアンマーシャリング(デコード)する際にproto.Sizeを使用するのは安全ではありません

func bytesToSubscriptionList(data []byte) ([]*vpb.EventSubscription, error) {
    subList := []*vpb.EventSubscription{}
    for len(data) > 0 {
        subscription := &vpb.EventSubscription{}
        if err := proto.Unmarshal(data, subscription); err != nil {
            return nil, err
        }
        subList = append(subList, subscription)
        data = data[:len(data)-proto.Size(subscription)]
    }
    return subList, nil
}

data非最小ワイヤー形式のメッセージを含む場合、proto.Sizeは実際にアンマーシャリングされたサイズとは異なるサイズを返す可能性があり、その結果、最良の場合にはパースエラー、最悪の場合には不正にパースされたデータが生じます。

したがって、この例は、すべての入力メッセージが(同じバージョンの)Go Protobufによって生成されている場合にのみ確実に機能します。これは驚くべきことであり、おそらく意図されたものではありません。

ヒント: 代わりにprotodelimパッケージを使用して、サイズ区切りされたProtobufメッセージのストリームを読み書きしてください。

高度な使用法: バッファの事前サイズ設定

proto.Sizeの高度な使用法は、マーシャリング前にバッファに必要なサイズを決定することです

opts := proto.MarshalOptions{
    // Possibly avoid an extra proto.Size in Marshal itself (see docs):
    UseCachedSize: true,
}
// DO NOT SUBMIT without implementing this Optimization opportunity:
// instead of allocating, grab a sufficiently-sized buffer from a pool.
// Knowing the size of the buffer means we can discard
// outliers from the pool to prevent uncontrolled
// memory growth in long-running RPC services.
buf := make([]byte, 0, opts.Size(m))
var err error
buf, err = opts.MarshalAppend(buf, m) // does not allocate
// Note that len(buf) might be less than cap(buf)! Read below:

遅延デコードが有効な場合、proto.Sizeproto.Marshal(およびproto.MarshalAppendのようなバリアント)が書き込むよりも多くのバイトを返す可能性があることに注意してください。したがって、エンコードされたバイトをワイヤー上(またはディスク上)に配置する際は、必ずlen(buf)で作業し、以前のproto.Sizeの結果を破棄してください。

具体的には、(サブ)メッセージはproto.Sizeproto.Marshalの間で「縮小」する可能性があります。

  1. 遅延デコードが有効になっている場合
  2. かつメッセージが非最小ワイヤー形式で到着した場合
  3. かつメッセージがproto.Sizeが呼び出される前にアクセスされていない場合、つまりまだデコードされていない場合
  4. かつメッセージがproto.Sizeの後にアクセスされた場合(ただし、proto.Marshalの前に)、それが遅延デコードされる原因となります

デコードの結果、その後のproto.Marshal呼び出しはメッセージをエンコードし(ワイヤー形式を単にコピーするのではなく)、Goがメッセージをエンコードする方法への暗黙的な正規化をもたらします。これは現在、最小ワイヤー形式で行われています(ただし、それに依存しないでください!)。

ご覧のとおり、このシナリオはかなり特殊ですが、それでもproto.Sizeの結果を上限として扱うことがベストプラクティスであり、その結果が実際にエンコードされたメッセージサイズと一致すると決して仮定しないでください。

背景: 非最小ワイヤー形式

Protobufメッセージをエンコードする際、1つの最小ワイヤー形式サイズと、同じメッセージにデコードされる多数のより大きな非最小ワイヤー形式があります。

非最小ワイヤー形式(「非正規化ワイヤー形式」とも呼ばれることがあります)は、非繰り返しフィールドが複数回出現したり、最適でないvarintエンコーディング、ワイヤー上で非パックとして現れるパックされた繰り返しフィールドなど、さまざまなシナリオを指します。

非最小ワイヤー形式はさまざまなシナリオで発生する可能性があります。

  • 意図的に。Protobufは、メッセージのワイヤー形式を連結することで、メッセージの連結をサポートしています。
  • 偶発的に。(おそらくサードパーティの)Protobufエンコーダが理想的にエンコードしない場合(例:varintをエンコードする際に必要以上のスペースを使用する)。
  • 悪意を持って。攻撃者がネットワーク経由でクラッシュを誘発するために、意図的にProtobufメッセージを作成する可能性があります。