protobufのboolはどこまでcompatibleなのか

Protocol Buffers(以下、protobuf)におけるboolのcompatibilityは、Language Guideで以下のように述べられています。

int32, uint32, int64, uint64, and bool are all compatible

これを読むと、もともとboolだったMessage Typeをint32にアップデートできるように思えます。 本記事では、実際に起こりそうな例で、boolからint32へのアップデートを試してみた結果をまとめます。

ストーリー

今回は、一般ユーザnormalとプレミアムユーザpremiumが存在するシステムに、追加仕様としてゴールドユーザgoldを増やさなければならなくなった場合を考えます。

もともとの仕様

もともと、一般ユーザnormalとプレミアムユーザpremiumの2種類のユーザしか存在しなかったため、以下のようなboolでユーザのtypeを区別する.protoを定義します。 gRPCで呼ぶときはIsPremium()を呼ぶようなイメージです。

syntax = "proto3";
package user;

message User {
  bool type = 1; // 0: normal, 1: premium
}

仕様変更

さて、追加仕様としてゴールドユーザgoldを増やさなければなりません。 boolだったtypeフィールドをint32に変更しましょう。

syntax = "proto3";
package user;

message User {
  int32 type = 1; // 0: normal, 1: premium, 2: gold
}

互換性を確認

互換性を確認するにあたり、gRPCサーバを書いてもよかったですが、今回はバイナリをファイルに書き出すことにします。 書き出したバイナリを、仕様変更前のクライアントと仕様変更後のクライアントが読み込み、互換性が保たれるか確認します。

書き出し用のコードは以下の通りです。

package main

import (
    "io/ioutil"
    "log"

    pb "github.com/cipepser/protobuf-sample/user"
    "github.com/golang/protobuf/proto"
)

func main() {
    normal := &pb.User{
        Type: 0,
    }

    if err := write("./user/normal_int32.bin", normal); err != nil {
        log.Fatal(err)
    }

    premium := &pb.User{
        Type: 1,
    }
    if err := write("./user/premium_int32.bin", premium); err != nil {
        log.Fatal(err)
    }

    gold := &pb.User{
        Type: 2,
    }
    if err := write("./user/gold_int32.bin", gold); err != nil {
        log.Fatal(err)
    }
}

func write(file string, user *pb.User) error {
    out, err := proto.Marshal(user)
    if err != nil {
        return err
    }

    if err := ioutil.WriteFile(file, out, 0644); err != nil {
        return err
    }

    return nil
}

ユーザのtypeごとに以下のファイル名で保存しています。

ユーザ file
一般 normal_int32.bin
プレミアム premium_int32.bin
ゴールド gold_int32.bin

仕様変更後のクライアントで読み込む

まずは、仕様変更 int32.protoを知っているクライアントで読み込んでみましょう。

syntax = "proto3";
package user;

message User {
  int32 type = 1; // 0: normal, 1: premium, 2: gold
}

クライアント想定の読み込み用のコードは以下の通りです。

package main

import (
    "fmt"
    "io/ioutil"
    "log"

    pb "github.com/cipepser/protobuf-sample/user"
    "github.com/golang/protobuf/proto"
)

func main() {
    if err := read("./user/normal_int32.bin"); err != nil {
        log.Fatal(err)
    }
    if err := read("./user/premium_int32.bin"); err != nil {
        log.Fatal(err)
    }
    if err := read("./user/gold_int32.bin"); err != nil {
        log.Fatal(err)
    }
}

func read(file string) error {
    in, err := ioutil.ReadFile(file)
    if err != nil {
        return err
    }
    user := &pb.User{}
    if err := proto.Unmarshal(in, user); err != nil {
        return err
    }
    fmt.Println(file, ": ", user.Type)
    return nil
}

読み込み結果

結果は以下の通りです。

❯ go run UserRead.go
./user/normal.bin :  0
./user/premium_int32.bin :  1
./user/gold_int32.bin :  2

表にまとめると以下の通りなので、正しく動作していますね。compatibleです。

ユーザ type
一般 0
プレミアム 1
ゴールド 2

仕様変更前のクライアントで読み込む

いよいよ問題の仕様変更 bool.protoしか知らないクライアントで読み込んでみます。

syntax = "proto3";
package user;

message User {
  bool type = 1; // 0: normal, 1: premium
}

読み込み用のコードは、上述の仕様変更後で利用したものと同一です。

読み込み結果

結果は以下の通りです。

./user/normal.bin :  false
./user/premium_int32.bin :  true
./user/gold_int32.bin :  true

表にまとめると以下の通りです。

ユーザ type
一般 false
プレミアム true
ゴールド true

考察

proto.Unmarshal()するときにエラーは起きていませんが、一般ユーザがfalse、プレミアムユーザとゴールドユーザがtrueとなる結果となりました。
最終的にboolで読み込む以上、1bit分の情報になるしかないのは納得ですが、サービスによって想定外の動作となる可能性がありますね。
例えば、冒頭で述べたIsPremium()では、ギリギリ問題ないかもしれませんが、IsNormal()を呼ぶような場合はNGです。以下表のように.protoを定義していた場合、boolしか知らないクライアントから、一般ユーザとゴールドユーザが同一に見えてしまいます。

ユーザ 仕様変更前 仕様変更後
プレミアム false 0
一般 true 1

このようにboolからint32へ変更するとエラーが起きず互換性が崩れる場合があります。逆にすべてのクライアントが最新の.protoが使えるような場合は、仕様通りcompatibleに使えると思います。

おまけ:Unmarshalの実装

上記例で、一般ユーザtype=0とプレミアムユーザtype=1がそれぞれfalsetrueとして読み込まれるのは納得できる動作ですが、ゴールドユーザtype=2trueとなるのは少し気になるので、もう少し深掘りしていきます。

castが伴うようなパースについて、Language Guideで以下のように書かれています。

If a number is parsed from the wire which doesn't fit in the corresponding type, you will get the same effect as if you had cast the number to that type in C++ (e.g. if a 64-bit number is read as an int32, it will be truncated to 32 bits).

言語処理系の実装依存な気もしますが、64-bitを32-bitに切り詰めると後半32-bitだけが残るような気もしてきます。 なので、今回の実験をする前は、type=2では、0b0010(便宜上4-bit表記)はboolにするときに最後の1-bitだけが残り、0=falseになるのではないかと予想していました。 結論から言うと、この予想は誤りで、Googleの実装では00以外falsetrueを返します。

protobufのint32boolUnmarshalするときの実装は、ここです(関数を抜粋すると以下)。

func unmarshalBoolValue(b []byte, f pointer, w int) ([]byte, error) {
    if w != WireVarint {
        return b, errInternalBadWireType
    }
    // Note: any length varint is allowed, even though any sane
    // encoder will use one byte.
    // See https://github.com/golang/protobuf/issues/76
    x, n := decodeVarint(b)
    if n == 0 {
        return nil, io.ErrUnexpectedEOF
    }
    // TODO: check if x>1? Tests seem to indicate no.
    v := x != 0
    *f.toBool() = v
    return b[n:], nil
}

v := x != 0によって0だけがfalseになり、0以外trueとなります。

いくつかの言語の実装箇所も調べてみましたが、GoogleのProtocol Buffersのレポジトリにあるものは、いずれも00以外で区別しているようでした。

言語 実装 リンク
PHP if ($value == 0) {
$value = false;
} else {
$value = true;
}
https://github.com/google/protobuf/blob/0b0890b36d01bf7b372cb237998ee793f5cdc433/php/src/Google/Protobuf/Internal/GPBWire.php#L263
C++ *value = temp != 0; https://github.com/google/protobuf/blob/0b0890b36d01bf7b372cb237998ee793f5cdc433/src/google/protobuf/wire_format_lite_inl.h#L158
java return readRawVarint64() != 0; https://github.com/google/protobuf/blob/964201af37f8a0009440a52a30a66317724a52c3/java/core/src/main/java/com/google/protobuf/CodedInputStream.java#L807
python wire_format.WIRETYPE_VARINT, _DecodeVarint, bool)
# pythonの場合0以外の数値はtrueになる
https://github.com/google/protobuf/blob/39f9b43219bc5718b659ed72a2130a7b2ce66108/python/google/protobuf/internal/decoder.py#L458

References