protobufで0 byteをデコードする
Language Guideに以下のようにフィールドに値がセットされていない場合はdefault valueが使われることが書かれています。
When a message is parsed, if the encoded message does not contain a particular singular element, the corresponding field in the parsed object is set to the default value for that field.
ということは何も書かれていない(0 byteの)バリナリをパースしようとすると、すべてのフィールドがdefault valueとなるはずなので試してみました。
パースする.proto
以下のような.proto
を用意します。
syntax = "proto3"; package blank; message User { int32 id = 1; string name = 2; fixed64 age = 3; Contact contact = 4; } message Contact { string phone = 1; string email = 2; }
protocします。
❯ protoc -I=blank/ --go_out=blank/ blank.proto
パースする
0 byteの入力としてin := []byte{}
を使います。
in
をUser
にパースし、各フィールドを標準出力して、default valueになっているか確認します。
package main import ( "fmt" "log" pb "github.com/cipepser/protobuf-sample/blank" "github.com/golang/protobuf/proto" ) func main() { user := &pb.User{} in := []byte{} if err := proto.Unmarshal(in, user); err != nil { log.Fatal(err) } fmt.Println("ID: ", user.Id) fmt.Println("Name: ", user.Name) fmt.Println("Age: ", user.Age) fmt.Println("Contact: ", user.Contact) }
結果
実行結果は以下の通りです。
❯ go run BlankRead.go ID: 0 Name: Age: 0 Contact: <nil>
ちゃんとdefault valueでパースできました。
ちなみに、上記の通りuser.Contact
はnil
がdefault valueのため、Phone
やEmail
にアクセスしようとするとpanicになります。
fmt.Println("Phone: ", user.Contact.Phone) // panic: runtime error: invalid memory address or nil pointer dereference
References
Golangで出力したprotobufバイナリをRustで読み込む
ここ三回くらいprotobufの記事を書いてきましたが、Goばかりだったので、Rustで読み込んでみました。
事前準備:Goでバイナリを出力する
user.proto
を以下のように定義します。
syntax = "proto3"; package user; message User { string name = 1; int32 age = 2; }
Go用にprotocする
❯ protoc -I=./ --go_out=./ user.proto
user.pb.go
が生成されます。
バイナリを書き出す
以下のユーザをprotobufのバイナリとしてgo_user.bin
を書き出します。
フィールド | 値 |
---|---|
Name | Alice |
Age | 20 |
出力用のコードは以下の通りです。
package main import ( "fmt" "io/ioutil" "log" pb "github.com/cipepser/protobuf-sample/rust-protobuf-example/user" "github.com/golang/protobuf/proto" ) func main() { p := &pb.User{ Name: "Alice", Age: 20, } out, err := proto.Marshal(p) if err != nil { log.Fatal(err) } fmt.Println(out) if err := ioutil.WriteFile("./go_user.bin", out, 0644); err != nil { log.Fatalln("Failed to write:", err) } }
Rustで読み込む
いよいよ本題のRustです。
Cargo.toml
の設定
Rustでprotobufを扱うためにrust-protobufを使います。
Cargo.toml
のdependencies
にprotobuf
を記載します。
READMEにも書いてありますが、bytes
クレートを使っているのでbytes
も記載します。
[dependencies] protobuf = { version = "~2.0", features = ["with-bytes"] }
2019/08/03追記
stepancheg/rust-protobuf: Rust implementation of Google protocol buffersに、version2未満がサポート対象外になったと書いてあるので、追記現在では以下のように記載します。
[dependencies] protobuf = { version = "2", features = ["with-bytes"] }
Rust用にprotocする
user.proto
は、Goでprotoc
したときと同じものです。
❯ protoc --rust_out src/ user.proto
以下のようなメッセージが出る場合は、protoc-gen-rust
がないのでインストールしましょう。
protoc-gen-rust: program not found or is not executable --rust_out: protoc-gen-rust: Plugin failed with status code 1
以下でインストール出来ます(Cargo.toml
に書いてもいいのかも)。
❯ cargo install protobuf-codegen
protoc
がうまくいけば、src/user.rs
がgenerateされます。
読み込む
ファイル読み込み、User
型にmerge
、標準出力するまでのコードは以下のとおりです。
extern crate protobuf; mod user; use user::User; use std::fs::File; use std::io::{BufReader}; use protobuf::{CodedInputStream, Message}; fn main() { let file = File::open("./go_user.bin").expect("fail to open file"); let mut buffered_reader = BufReader::new(file); let mut cis = CodedInputStream::from_buffered_reader(&mut buffered_reader); let mut u = User::new(); u.merge_from(&mut cis).expect("fail to merge"); println!("Name: {}", u.get_name()); println!("Age: {}", u.get_age()); }
実行すると、以下のようにName
とAge
が読み取れていることがわかります。
/usr/local/bin/cargo run --color=always --package rust-protobuf-example --bin rust-protobuf-example Finished dev [unoptimized + debuginfo] target(s) in 0.04s Running `target/debug/rust-protobuf-example` Name: Alice Age: 20 Process finished with exit code 0
References
protobufのVarintを64bitから32bitに変更したときの挙動
protobufのVarint
はProtocol Buffers - Encodingで定義されるように、32-bit(int32
, uint32
, sint32
)や64-bit(int64
, uint64
, sint64
)のどちらも含まれています。
Varint
のエンコーディングは、その値によってbyte長が変わりますが(負数は-1
のような小さい数でも最上位bitが1
になりエンコードした結果長くなってしまうので非推奨となっている)、64-bitでエンコードされた値を32-bitで読み込んだ場合、どのような動作になるのか気になったので試してみました。
なお、Varint
には、bool
やenum
も含まれていますが、
その変換については以下の記事で検証したのため、興味があればそちらをご覧ください。
結論から言うと仕様に、以下のように書いてあるので、処理系の実装依存と思われますが、Goで実際に試してみたメモとして残します。
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).
環境
❯ go version
go version go1.10.3 darwin/amd64
❯ protoc --version
libprotoc 3.6.0
検証(uint64
-> uint32
)
uint64
でmath.MaxUint32
よりも大きくなる値をuint32
で読み込んでみて動作を検証します。
(最後にint
も検証結果も記載しますが、コードも含めてすべて記載すると長いのでuint
のみとします)
今回の例では、読み込む値をUser
がもつId
とし、以下のように.proto
を定義します。
syntax = "proto3"; package max; message User { uint64 id = 1; }
書き出し
次に書き出し用のコードです。
math.MaxUint32
よりも大きくなる値を0b1111111111111111111111111111111100000000000000000000000000000000
とします。
これは前半32-bitが1
で後半32-bitが0
となる値で、math.MaxUint64 - math.MaxUint32
で生成します。この数字を使うことでuint32
で読み込んだときに後半32-bitだけに切り詰められる(0
になる)のか、math.MaxUint32
より大きくオーバーフローしてしまうのか確認します。
// MaxWrite.go package main import ( "io/ioutil" "log" "math" pb "github.com/cipepser/protobuf-sample/max" "github.com/golang/protobuf/proto" ) func main() { user := &pb.User{ Id: math.MaxUint64 - math.MaxUint32, } if err := write("./max/uint.bin", user); 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 }
読み込み
読み込み用のコードです。
// MaxRead.go package main import ( "fmt" "io/ioutil" "log" pb "github.com/cipepser/protobuf-sample/max" "github.com/golang/protobuf/proto" ) func main() { if err := read("./max/uint.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.Printf("0d%d\n0b%b\n", user.Id, user.Id) return nil }
10進数(0d
)と2進数(0b
)でuser.Id
を標準出力します。
試しに実行してみると、以下のように正常にデコードできていることがわかります。
❯ go run MaxRead.go 0d18446744069414584320 0b1111111111111111111111111111111100000000000000000000000000000000
.proto
をuint32
に変更する
では、uint32
に変更して、上記で出力したuint64
のバイナリを読み込みましょう。
syntax = "proto3"; package max; message User { uint32 id = 1; }
protoc
してから読み込みます。
❯ go run MaxRead.go 0d0 0b0
0
になったので、後半32-bitだけに切り詰められたようです。
結果
同様の検証をint64
-> int32
、sint64
-> sint32
でもやってみた結果も含めて、まとめると以下のようになりました。
検証パターン(.proto) | 読み込む値 | 64-bitのまま読み込み | 32-bitにして読み込み |
---|---|---|---|
uint64 -> uint32 |
math.MaxUint64 - math.MaxUint32 | 0d18446744069414584320 0b1111111111111111111111111111111100000000000000000000000000000000 |
0d0 0b0 |
int64 -> int32 |
math.MaxInt64 - math.MaxInt32 - 1 <<31 | 0d9223372032559808512 0b111111111111111111111111111111100000000000000000000000000000000 |
0d0 0b0 |
結果、uint
でもint
でも同様に後半32-bitに切り詰められる結果となりました。
余談
int
のほうで1 << 31
を引いているのですが、最初、math.MaxInt64 - math.MaxInt32
としていました。
符号付き整数なので、これだと下位31-bitしか0
にできず、この値をint32
として読み込むと以下のようになってしまいます。
0d-2147483648 0b-10000000000000000000000000000000
検証としては、下位32-bitを残して切り詰める結果から相違ないので、参考としてこの結果も載せておきます。
ちなみにmath.MinInt64 - math.MinInt32
を読み書きしても上記と同じ結果になります(2進数で見たときに同じ値になるので)。
References
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
, andbool
are all compatible
enum
is compatible withint32
,uint32
,int64
, anduint64
bool
とenum
で、わざわざ文を2つにわけていたので気になって調べることにしました(結論、あまり分けている意味はなさそうでしたが)。
今回確認したいのは以下2点です。
enum
とbool
は互換性があるのか.proto
で未定義のenum
フィールドをパースした場合の挙動
enum
とbool
は互換性があるのか
では、早速1つ目を確認していきましょう。
enum
-> bool
とbool
-> enum
の両方向に変えてみたときの挙動を順番にみていきます。
例題は、前回記事と同じUser
のtype
を変えるものとします。
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; }
この.proto
でEnumWrite.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つ目「enum
とbool
は互換性があるのか」は「互換性あり」ということが確認できました。
.proto
で未定義のenum
フィールドをパースした場合の挙動
続けて2つ目の疑問を解決していきましょう。
3種類のユーザ種別を定義する
変更前は、ユーザ種別type
を以下のようにenum
で 3つ 定義します。
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()
は正常に動作するものの、GOLD
が2
として出力される、でした。
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
protobufのboolはどこまでcompatibleなのか
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 |
References
gRPCでリクエストパラメータのValidation
goのgRPCで便利ツールを使うで紹介されているGo gRPC MiddlewareとGolang ProtoBuf Validator CompilerでgRPCのvalidationをします。
今回の例では、User
の年齢は負数にならない、電話番号やメールアドレスを正規表現でvalidationするといったことを実装します。
インストール
Go gRPC Middlewareのインストール
❯ go get github.com/grpc-ecosystem/go-grpc-middleware
Golang ProtoBuf Validator Compilerのインストール
❯ go get github.com/mwitkow/go-proto-validators/protoc-gen-govalidators
もとになるサーバとクライアント
protobufでgRPCを呼び出すサーバとクライアントを実装します。
今回はサーバにUser
を登録できるだけのサービスです。
protobufの定義
// user.proto syntax = "proto3"; package user; service UserService { rpc GetUser (Name) returns (User) {} rpc GetUsers (Empty) returns (Users) {} rpc AddUser (User) returns (Empty) {} } message User { string name = 1; int32 age = 2; string phone = 3; string mail = 4; } message Name { string name = 1; } message Empty {} message Users { repeated User users = 1; }
server
// server.go package main import ( "context" "errors" "log" "net" pb "github.com/cipepser/gRPC-validation/user" "google.golang.org/grpc" ) type server struct { users map[*pb.User]struct{} names map[string]struct{} } var ( empty = new(pb.Empty) ) const ( port = ":50051" ) func (s *server) GetUser(ctx context.Context, in *pb.Name) (*pb.User, error) { for u := range s.users { if u.Name == in.Name { return u, nil } } return nil, errors.New("user not found") } func (s *server) GetUsers(ctx context.Context, in *pb.Empty) (*pb.Users, error) { out := new(pb.Users) for u := range s.users { out.Users = append(out.Users, u) } return out, nil } func (s *server) AddUser(ctx context.Context, in *pb.User) (*pb.Empty, error) { if _, ok := s.names[in.Name]; ok { return empty, errors.New("user already exists") } s.users[in] = struct{}{} s.names[in.Name] = struct{}{} return empty, nil } func main() { l, err := net.Listen("tcp", port) if err != nil { log.Fatalf("failed to listen: %v", err) } s := grpc.NewServer() pb.RegisterUserServiceServer(s, &server{ users: map[*pb.User]struct{}{}, names: map[string]struct{}{}, }, ) s.Serve(l) }
client
// client.go package main import ( "context" "fmt" "log" pb "github.com/cipepser/gRPC-validation/user" "google.golang.org/grpc" ) const ( address = "localhost" port = ":50051" ) var ( empty = new(pb.Empty) ) type client struct { } func main() { conn, err := grpc.Dial(address+port, grpc.WithInsecure()) if err != nil { log.Fatalf("failed to connect: %v", err) } defer conn.Close() c := pb.NewUserServiceClient(conn) u := pb.User{ Name: "Bob", Age: 24, Phone: "", Mail: "", } _, err = c.AddUser(context.Background(), &u) if err != nil { log.Fatalf("failed to add user: %v", err) } resp, err := c.GetUsers(context.Background(), empty) if err != nil { log.Fatalf("failed to get users: %v", err) } log.Printf("users:") for _, u := range resp.Users { fmt.Println(u) } }
validation
上記だけでもgo run server.go
でサーバを起動し、go run client.go
すればサービスが動きますが、以下に仕様に従ったvalidationをしてきましょう。
仕様
フィールド | 制約 |
---|---|
name | - |
age | 0〜150歳 |
phone | 携帯電話の正規表現にマッチ |
メールアドレスの正規表現にマッチ |
phone
とmail
の正規表現は、よく使う正規表現はもうググりたくない!から拝借します。そのままだと動かないので、エスケープを\
から\\
に変更しています。
Golang ProtoBuf Validator Compilerでは、
[(validator.field) = {msg_exists : true}];
とすることでrequired
を実現できますが、proto3でrequiredが廃止されたことからも利用しません(name
フィールドで使いたくなった)。
protobufの定義(validationあり)
// user.proto syntax = "proto3"; package user; import "github.com/mwitkow/go-proto-validators/validator.proto"; service UserService { rpc GetUser (Name) returns (User) {} rpc GetUsers (Empty) returns (Users) {} rpc AddUser (User) returns (Empty) {} } message User { string name = 1; int32 age = 2 [(validator.field) = {int_gt: -1, int_lt: 151}];; string phone = 3 [(validator.field) = {regex: "^(070|080|090)-\\d{4}-\\d{4}$"}]; string mail = 4 [(validator.field) = {regex: "^\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$"}]; } message Name { string name = 1; } message Empty {} message Users { repeated User users = 1; }
以下でprotobufをコンパイルします。
❯ protoc \ --proto_path=${GOPATH}/src \ --proto_path=. \ --go_out=plugins=grpc:./ \ --govalidators_out=./ \ *.proto
server
Go gRPC Middlewareでvalidateさせるために、server.go
に以下を追記する。
// server.go
s := grpc.NewServer(
grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(
grpc_validator.StreamServerInterceptor(),
)),
grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
grpc_validator.UnaryServerInterceptor(),
)),
)
実行
validationされるか試してきましょう。
なお、ディレクトリ構成は以下のようになっています。
❯ tree . . ├── README.md ├── client │ └── client.go ├── server │ └── server.go └── user ├── user.pb.go ├── user.proto └── user.validator.pb.go 3 directories, 6 files
正常系
u := pb.User{ Name: "Alice", Age: 20, Phone: "090-1111-1111", Mail: "alice@example.com", }
❯ go run client/client.go 2018/07/22 14:20:28 users: name:"Alice" age:20 phone:"090-1111-1111" mail:"alice@example.com"
正常に登録できています。
異常系(age: -1歳)
u := pb.User{ Name: "Bob", Age: -1, Phone: "090-1111-1111", Mail: "bob@example.com", }
❯ go run client/client.go 2018/07/22 14:22:19 failed to add user: rpc error: code = InvalidArgument desc = invalid field Age: value '-1' must be greater than '-1' exit status 1
異常系(age: 200歳)
u := pb.User{ Name: "Bob", Age: 200, Phone: "090-1111-1111", Mail: "bob@example.com", }
❯ go run client/client.go 2018/07/22 14:22:35 failed to add user: rpc error: code = InvalidArgument desc = invalid field Age: value '200' must be less than '151' exit status 1
異常系(phone: 英字)
u := pb.User{ Name: "Bob", Age: 20, Phone: "09a-1111-11112", Mail: "bob@example.com", }
❯ go run client/client.go 2018/07/22 14:23:40 failed to add user: rpc error: code = InvalidArgument desc = invalid field Phone: value '09a-1111-1111' must be a string conforming to regex "^(070|080|090)-\\d{4}-\\d{4}$" exit status 1
異常系(phone: ハイフンなし)
u := pb.User{ Name: "Bob", Age: 20, Phone: "090111111112", Mail: "bob@example.com", }
❯ go run client/client.go 2018/07/22 14:23:48 failed to add user: rpc error: code = InvalidArgument desc = invalid field Phone: value '09011111111' must be a string conforming to regex "^(070|080|090)-\\d{4}-\\d{4}$" exit status 1
異常系(phone: 桁が多い)
u := pb.User{ Name: "Bob", Age: 20, Phone: "090-1111-11112", Mail: "bob@example.com", }
❯ go run client/client.go 2018/07/22 14:23:55 failed to add user: rpc error: code = InvalidArgument desc = invalid field Phone: value '090-1111-11112' must be a string conforming to regex "^(070|080|090)-\\d{4}-\\d{4}$" exit status 1
異常系(mail: @なし)
u := pb.User{ Name: "Bob", Age: 20, Phone: "090-1111-1111", Mail: "bob.example.com", }
❯ go run client/client.go 2018/07/22 14:24:40 failed to add user: rpc error: code = InvalidArgument desc = invalid field Mail: value 'bob.example.com' must be a string conforming to regex "^\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$" exit status 1
終わりに
異常系をちゃんとvalidationできていました。client側に正規表現の実装詳細を伝えてしまっているのが気になりますが、generateされたコードはuser/user.validator.pb.go
にあるので、そちらのメッセージを変更すれば見えなくなると思います。ただ、これでやるとgenerateし直すたびにuser/user.validator.pb.go
を直すことになるのでおすすめしませんが。
References
vgoを試してみる
Go1.11から導入されるvgoを試してみたメモ書きです。
基本的な流れは、和訳: A Tour of Versioned Go (vgo) (Go & Versioning, Part2)に沿っています。versioningが必要な理由や議論などは本記事では扱いません。
上記記事からvgoの開発が進みコマンドがいくつか変更になっていたので、同じくvgoを試す方の一助となればと思います。
今後も変更が入る可能性がありますが、x/vgo
からgo本体にマージされたので、いい区切りかと思い、記事としてまとめることにしました。
環境
❯ vgo version
go version go1.10.3 darwin/amd64 go:2018-02-20.1
vgoのインストール
❯ go get -u golang.org/x/vgo
vgoを動かしてみる
サンプルプログラム
// hello.go package main // import "github.com/you/hello" import ( "fmt" "rsc.io/quote" // この時点では、cannot find packageになるけどvgoが解決してくれる ) func main() { fmt.Println(quote.Hello()) }
あとは何も書いていないgo.mod
も作っておく。
❯ touch go.mod
vgo build
❯ vgo build vgo: resolving import "rsc.io/quote" vgo: finding rsc.io/quote v1.5.2 vgo: finding rsc.io/quote (latest) vgo: adding rsc.io/quote v1.5.2 vgo: finding rsc.io/sampler v1.3.0 vgo: finding golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c vgo: downloading rsc.io/quote v1.5.2 vgo: downloading rsc.io/sampler v1.3.0 vgo: downloading golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
これでbuildされるので、実行してみる。
❯ ./hello こんにちは世界。
空だったgo.mod
にも追記されている。
// go.mod module github.com/you/hello require rsc.io/quote v1.5.2
依存しているモジュールをvgo list -m
で表示できる。
コマンドが変更になったので、vgo list -m
だと1個しか出てこない。
→vgo list -m all
で全部出てくる。
❯ vgo list -m all github.com/you/hello golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c rsc.io/quote v1.5.2 rsc.io/sampler v1.3.0
Upgrade
-u
オプションでupdated packageを確認できる。コマンドが変わって[]
内にLATESTが表示されるようになった。
❯ vgo list -u -m all vgo: finding golang.org/x/text v0.3.0 vgo: finding rsc.io/sampler v1.99.99 github.com/you/hello golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c [v0.3.0] rsc.io/quote v1.5.2 rsc.io/sampler v1.3.0 [v1.99.99]
golang.org/x/text
をUpgradeしてみる。
❯ vgo get golang.org/x/text vgo: downloading golang.org/x/text v0.3.0
go.mod
が変わっている。
❯ git diff go.mod diff --git a/go.mod b/go.mod index 3200210..6246735 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,6 @@ module github.com/you/hello -require rsc.io/quote v1.5.2 +require ( + golang.org/x/text v0.3.0 + rsc.io/quote v1.5.2 +)
listで見ても以下のようにv0.0.0-20170915032832-14c0d48ead0c
からv0.3.0
になっていることがわかる。
❯ vgo list -m all
github.com/you/hello
golang.org/x/text v0.3.0
rsc.io/quote v1.5.2
rsc.io/sampler v1.3.0
テストしてみる。
❯ vgo test all ? github.com/you/hello 0.016s [no test files] ? golang.org/x/text/internal/gen 0.078s [no test files] ok golang.org/x/text/internal/tag 0.011s ? golang.org/x/text/internal/testtext 0.043s [no test files] ok golang.org/x/text/internal/ucd 0.015s ok golang.org/x/text/language 0.073s ok golang.org/x/text/unicode/cldr 0.132s ok rsc.io/quote 0.015s ok rsc.io/sampler 0.013s
rsc.io/quote
のv1.5.2
には以下のようにバグがあるが、上記のようにok
となる。
これは、all
の意味が"今の module 中の全てのパッケージと、それらが再帰的に import している全てのパッケージ"だから。
❯ vgo test rsc.io/quote/... ok rsc.io/quote (cached) --- FAIL: Test (0.00s) buggy_test.go:10: buggy! FAIL FAIL rsc.io/quote/buggy 0.008s
vgo get -u
ですべてのmoduleをupgradeできる。
❯ vgo get -u
vgo: finding rsc.io/quote latest
vgo: finding golang.org/x/text latest
vgo: finding rsc.io/sampler latest
vgo: finding golang.org/x/text latest
vgo: finding rsc.io/sampler latest
rsc.io/sampler
がv1.99.99
にupgradeされた。
❯ vgo list -m all
github.com/you/hello
golang.org/x/text v0.3.0
rsc.io/quote v1.5.2
rsc.io/sampler v1.99.99
でもテストには失敗する。
❯ vgo test all vgo: downloading rsc.io/sampler v1.99.99 ? github.com/you/hello 0.016s [no test files] ? golang.org/x/text/internal/gen 0.032s [no test files] ok golang.org/x/text/internal/tag (cached) ? golang.org/x/text/internal/testtext 0.020s [no test files] ok golang.org/x/text/internal/ucd (cached) ok golang.org/x/text/language (cached) ok golang.org/x/text/unicode/cldr (cached) --- FAIL: TestHello (0.00s) quote_test.go:19: Hello() = "99 bottles of beer on the wall, 99 bottles of beer, ...", want "Hello, world." FAIL FAIL rsc.io/quote 0.014s --- FAIL: TestHello (0.00s) hello_test.go:31: Hello([en-US fr]) = "99 bottles of beer on the wall, 99 bottles of beer, ...", want "Hello, world." hello_test.go:31: Hello([fr en-US]) = "99 bottles of beer on the wall, 99 bottles of beer, ...", want "Bonjour le monde." FAIL FAIL rsc.io/sampler 0.014s
Downgrade
v1.99.99
へUpgradeしたらテストに失敗してしまったので、Downgradeして戻すことにする。
戻せるバージョンに何があるのか確認。
❯ vgo list -m -versions all github.com/you/hello golang.org/x/text v0.1.0 v0.2.0 v0.3.0 rsc.io/quote v1.0.0 v1.1.0 v1.2.0 v1.2.1 v1.3.0 v1.4.0 v1.5.0 v1.5.1 v1.5.2 v1.5.3-pre1 rsc.io/sampler v1.0.0 v1.2.0 v1.2.1 v1.3.0 v1.3.1 v1.99.99
rsc.io/sampler
を一つ前のversionv1.3.1
へ戻す。
❯ vgo get rsc.io/sampler@v1.3.1 vgo: finding rsc.io/sampler v1.3.1 vgo: downloading rsc.io/sampler v1.3.1
rsc.io/sampler
がv1.99.99
からv1.3.1
にdowngradeされた。
❯ git diff go.mod diff --git a/go.mod b/go.mod index 6246735..a62aa4e 100644 --- a/go.mod +++ b/go.mod @@ -3,4 +3,5 @@ module github.com/you/hello require ( golang.org/x/text v0.3.0 rsc.io/quote v1.5.2 + rsc.io/sampler v1.3.1 )
ちゃんとテストも通る。
❯ vgo test all ? github.com/you/hello 0.021s [no test files] ? golang.org/x/text/internal/gen 0.020s [no test files] ok golang.org/x/text/internal/tag (cached) ? golang.org/x/text/internal/testtext 0.031s [no test files] ok golang.org/x/text/internal/ucd (cached) ok golang.org/x/text/language (cached) ok golang.org/x/text/unicode/cldr (cached) ok rsc.io/quote 0.016s ok rsc.io/sampler 0.015s
もっと前のversionv.1.2.0
にすることもできる。
rsc.io/quote
も依存しているので一緒にdowngradeする必要がある。
❯ vgo get rsc.io/sampler@v1.2.0 vgo: finding rsc.io/sampler v1.2.0 vgo: finding rsc.io/quote v1.5.1 vgo: finding rsc.io/quote v1.5.0 vgo: finding rsc.io/quote v1.4.0 vgo: finding rsc.io/sampler v1.0.0 vgo: downloading rsc.io/sampler v1.2.0
ただし、テストに失敗する。
❯ vgo test all vgo: downloading rsc.io/quote v1.4.0 ? github.com/you/hello 0.019s [no test files] ? golang.org/x/text/internal/gen 0.023s [no test files] ok golang.org/x/text/internal/tag (cached) ? golang.org/x/text/internal/testtext 0.035s [no test files] ok golang.org/x/text/internal/ucd (cached) ok golang.org/x/text/language (cached) ok golang.org/x/text/unicode/cldr (cached) --- FAIL: TestHello (0.00s) quote_test.go:12: Hello() = "こんにちは世界。", want "Hello, world." FAIL FAIL rsc.io/quote 0.017s --- FAIL: TestHello (0.00s) hello_test.go:31: Hello([fr en-US]) = "Bonjour le monde.", want "Bonjour la monde." FAIL FAIL rsc.io/sampler 0.012s
none
で依存しているモジュールをすべて削除できる。
❯ vgo get rsc.io/sampler@none vgo: finding rsc.io/quote v1.3.0
こうするとテストが通る(全然モジュールが残っていないけど)。
❯ vgo test all vgo: downloading rsc.io/quote v1.3.0 ? github.com/you/hello 0.009s [no test files] ok rsc.io/quote 0.009s
Exclude
v1.99.99
がうまく動かないことを記録したい。
事前準備として、最新化しておく。
❯ vgo get -u
vgo: finding rsc.io/quote latest
vgo: finding golang.org/x/text latest
vgo: finding rsc.io/quote latest
vgo: finding rsc.io/sampler latest
vgo: finding golang.org/x/text latest
vgo: finding rsc.io/sampler latest
たしかにv1.99.99
になっている。
❯ vgo list -m all
github.com/you/hello
golang.org/x/text v0.3.0
rsc.io/quote v1.5.2
rsc.io/sampler v1.99.99
go.mod
にexclude "rsc.io/sampler" v1.99.99
を追加すればよい。
合わせてgo.mod
でrsc.io/sampler v1.3.1
にするのも忘れずに。
❯ vgo list -m all
github.com/you/hello
golang.org/x/text v0.3.0
rsc.io/quote v1.5.2
rsc.io/sampler v1.3.1
ちなみに、rsc.io/sampler v1.3.1
にするのも忘れると以下のようにエラーになってくれるのでちゃんとExcludeされている。
❯ vgo get -u vgo: github.com/you/hello() depends on excluded rsc.io/sampler(v1.99.99) with no newer version available
これでテストが通る。
❯ vgo test all ? github.com/you/hello 0.016s [no test files] ? golang.org/x/text/internal/gen 0.021s [no test files] ok golang.org/x/text/internal/tag (cached) ? golang.org/x/text/internal/testtext 0.021s [no test files] ok golang.org/x/text/internal/ucd (cached) ok golang.org/x/text/language (cached) ok golang.org/x/text/unicode/cldr (cached) ok rsc.io/quote (cached) ok rsc.io/sampler (cached)
Replace
依存していたrsc.io/quote
を置き換えてみる。
❯ git clone https://github.com/rsc/quote ../quote
../quote/quote.go
を以下のように書き換える。
// Hello returns a greeting. func Hello() string { // return sampler.Hello() // これを消して return sampler.Glass() // こっちを追加 }
go.mod
の末尾にreplace "rsc.io/quote" v1.5.2 => "../quote"
を追記する。
すると以下のようにreplaceされていることが確認できる。
❯ vgo list -m all github.com/you/hello golang.org/x/text v0.3.0 rsc.io/quote v1.5.2 => ../quote rsc.io/sampler v1.3.1
buildして実行する。
❯ vgo build ❯ ./hello 私はガラスを食べられます。それは私を傷つけません。