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.
}

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

この例では、「ダウンストリームシステム」と呼ぶ別のシステム向けに作業タスクを生成するバッチ処理パイプラインを作成しているとします。ダウンストリームシステムは小規模から中規模のタスクを処理するようにプロビジョニングされていますが、負荷テストでは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(の同じバージョン)によって生成されている場合にのみ信頼できます。これは驚くべきことであり、おそらく意図されていません。

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

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

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

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