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

proto.Sizeの(誤った)使用方法を説明します

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

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

Protobuf Editionに関する注意

Protobuf Editionsでは、.protoファイルでシリアライズ動作を変更する機能を有効にすることができます。これはproto.Sizeが返す値に影響を与える可能性があります。例えば、features.field_presence = IMPLICITを設定すると、デフォルト値に設定されているスカラーフィールドはシリアライズされず、したがってメッセージのサイズに寄与しません。

典型的な使用方法

空のメッセージの識別

proto.Sizeが0を返すかどうかをチェックすることは、空のメッセージをチェックする一般的な方法です。

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

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

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

最善の修正策は、ダウンストリームシステムに保護を追加することですが(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(の同じバージョン)によって生成される限り、確実に機能します。これは驚くべきことであり、意図されたものではないでしょう。

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

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

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つの最小ワイヤフォーマットサイズと、同じメッセージにデコードされる多数のより大きな非最小ワイヤフォーマットがあります。

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

非最小ワイヤフォーマットは、さまざまなシナリオで遭遇する可能性があります。

  • 意図的に。 Protobufは、ワイヤフォーマットを連結することでメッセージの連結をサポートしています。
  • 偶発的に。 (おそらくサードパーティ製の)Protobufエンコーダが理想的なエンコードを行わない場合(例: 可変長整数をエンコードする際に必要以上のスペースを使用する場合)。
  • 悪意を持って。 攻撃者が、ネットワークを介してクラッシュを引き起こすために特別にProtobufメッセージを作成する可能性があります。