protobufのenumの互換性について

前回記事の続きです。 前回は、boolだったフィールドをint32に変換した場合に互換性が保たれるのかを見ました。 実用上はint32にするより、enumにすることが多いと思うので、今回はenumでの互換性をみていきます。

enumについては、Language GuideのEnumerationsに、以下のように書いてあります。

Enumerator constants must be in the range of a 32-bit integer. Since enum values use varint encoding on the wire, negative values are inefficient and thus not recommended.

これを見るとenumは、同じくvarintとしてencodingされるint32と互換性がありそうです。 ただ、個人的に気になった点として、Language GuideのUpdating A Message Typeに以下のような二文があることです。

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

enum is compatible with int32, uint32, int64, and uint64

boolenumで、わざわざ文を2つにわけていたので気になって調べることにしました(結論、あまり分けている意味はなさそうでしたが)。

今回確認したいのは以下2点です。

  • enumboolは互換性があるのか
  • .protoで未定義のenumフィールドをパースした場合の挙動

enumboolは互換性があるのか

では、早速1つ目を確認していきましょう。 enum -> boolbool -> enumの両方向に変えてみたときの挙動を順番にみていきます。 例題は、前回記事と同じUsertypeを変えるものとします。

enum -> bool

enumで書き出す

まずenum.protoを以下のように定義します。

syntax = "proto3";
package enum;

message User {
  enum Type {
    NORMAL = 0;
    PREMIUM = 1;
  }
  Type type = 1;
}

前回同様、ファイルに書き出していきます。

// EnumWrite.go
package main

import (
    "io/ioutil"
    "log"

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

func main() {
    normal := &pb.User{
        Type: pb.User_NORMAL,
    }
    if err := write("./enum/normal.bin", normal); err != nil {
        log.Fatal(err)
    }

    premium := &pb.User{
        Type: pb.User_PREMIUM,
    }
    if err := write("./enum/premium.bin", premium); 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
}

boolで読み込む

この.protoで読み込ます。

syntax = "proto3";
package enum;

message User {
  enum Type {
    NORMAL = 0;
    PREMIUM = 1;
  }
  bool type = 1;
}

読み込み用のコードは以下の通りです。

// EnumRead.go
package main

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

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

func main() {
    if err := read("./enum/normal.bin"); err != nil {
        log.Fatal(err)
    }
    if err := read("./enum/premium.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 EnumRead.go
./enum/normal.bin :  false
./enum/premium.bin :  true

bool -> enum

次に逆方向も確認しましょう。 書き出し、読み出し用のコードは長くなるので差分のみ表にまとめます。

EnumWrite.goの差分です。

ユーザ ファイル名 pb.User.Type(前) pb.User.Type(後)
一般 normal02.bin pb.User_NORMAL false
プレミアム premium02.bin pb.User_PREMIUM true

EnumRead.goと上記表のファイル名のみの変更なので省略します。

boolで書き出す

.protoは以下の通りです。

syntax = "proto3";
package enum;

message User {
  enum Type {
    NORMAL = 0;
    PREMIUM = 1;
  }
  bool type = 1;
}

この.protoEnumWrite.goを実行し、ファイルに書き出します。

enumで読み込む

.protoを以下のようにenumに変更し、boolで書き出したファイルを読み込みます。

syntax = "proto3";
package enum;

message User {
  enum Type {
    NORMAL = 0;
    PREMIUM = 1;
  }
  Type type = 1;
}

実行結果は以下の通りです。

❯ go run EnumRead.go
./enum/normal02.bin :  NORMAL
./enum/premium02.bin :  PREMIUM

結果

上記の結果をまとめると以下表のようになりました。

ユーザ enum -> bool bool -> enum
一般 false NORMAL
プレミアム true PREMIUM

確認したかったことの1つ目「enumboolは互換性があるのか」は「互換性あり」ということが確認できました。

.protoで未定義のenumフィールドをパースした場合の挙動

続けて2つ目の疑問を解決していきましょう。

3種類のユーザ種別を定義する

変更前は、ユーザ種別typeを以下のようにenum3つ 定義します。

message User {
  enum Type {
    NORMAL = 0;
    PREMIUM = 1;
    GOLD = 2;
  }
  Type type = 1;
}

先程と同じく、書き出し、読み出しは以下の設定で行います。

ユーザ ファイル名 pb.User.Type
一般 normal03.bin pb.User_NORMAL
プレミアム premium03.bin pb.User_PREMIUM
ゴールド gold03.bin pb.User_GOLD

3種のまま読み込む

比較のため、.protoを変更せずにユーザ種別が3つのまま読み込んだ結果は以下となります。 当たり前ですが、正しく読み込めていますね。

❯ go run EnumRead.go
./enum/normal03.bin :  NORMAL
./enum/premium03.bin :  PREMIUM
./enum/gold03.bin :  GOLD

2種類にして読み込む

互換性を確認するためユーザ種別を 2つ に減らします。

syntax = "proto3";
package enum;

message User {
  enum Type {
    NORMAL = 0;
    PREMIUM = 1;
  }
  Type type = 1;
}

GOLDを削除しました。この.protoで読み込んだ結果は以下の通りです。

❯ go run EnumRead.go
./enum/normal03.bin :  NORMAL
./enum/premium03.bin :  PREMIUM
./enum/gold03.bin :  2

結果

上記の結果を表にまとめます。

ユーザ 変更前(3種) 変更後(2種)
一般 NORMAL NORMAL
プレミアム PREMIUM PREMIUM
ゴールド GOLD 2

2つ目の疑問「.protoで未定義のenumフィールドをパースした場合の挙動」の回答としては、エラーは起きず、Unmarshal()は正常に動作するものの、GOLD2として出力される、でした。 enumの定義を知らないことを考えると妥当な挙動なように思えます。

検証後に気付いたのですが、Language GuideのEnumerationsに、以下のように書いてありました。

During deserialization, unrecognized enum values will be preserved in the message, though how this is represented when the message is deserialized is language-dependent. In languages that support open enum types with values outside the range of specified symbols, such as C++ and Go, the unknown enum value is simply stored as its underlying integer representation. In languages with closed enum types such as Java, a case in the enum is used to represent an unrecognized value, and the underlying integer can be accessed with special accessors. In either case, if the message is serialized the unrecognized value will still be serialized with the message.

未定義のenumの処理自体は、言語に依存するようで、今回のようにGoの場合は単純にint型の数値として認識されるそうです。

上記挙動を理解した上で利用することも考えられますが、あまりおすすめできる方法ではありません。そもそも今回のようなユーザ種別を減らすような変更は、「一度定義したmessage typeは変更しない」というprotobufの思想に反するので、基本的には削除しない方が良いでしょう。

References