Protocol Buffers(以下、protobuf)におけるbool
のcompatibilityは、Language Guideで以下のように述べられています。
int32
,uint32
,int64
,uint64
, andbool
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
がそれぞれfalse
、true
として読み込まれるのは納得できる動作ですが、ゴールドユーザtype=2
がtrue
となるのは少し気になるので、もう少し深掘りしていきます。
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の実装では0
と0以外
でfalse
、true
を返します。
protobufのint32
をbool
にUnmarshal
するときの実装は、ここです(関数を抜粋すると以下)。
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のレポジトリにあるものは、いずれも0
と0以外
で区別しているようでした。
言語 | 実装 | リンク |
---|---|---|
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 |