Goa の OneOf を利用してみる
はじめに
Goa には OneOf という DSL が用意されています。これは protobuf の oneof
に相当するもので、複数あるフィールドのうちの1つだけを指定できます。protobuf にある機能なので、利用できるのは不思議ではないのですが、Goa は HTTP トランスポートもサポートしているので、この機能は HTTP な REST API でも利用できます。
Goa のドキュメント(Blog)でも解説されています。
デザイン
Goa のドキュメントに示された例を参考に OneOf の使い方を見ていきたいと思います。
- OneOf は Attribute (もしくは Field) の代わりに使えます。
- OneOf は複数の Attribute (もしくは Field) を指定できます
- 指定されたもののうち、1つを取ることが出来ます
var PetOwner = Type("PetOwner", func() {
OneOf("pet", func() {
Description("The owner's pet")
Field(1, "dog", Dog)
Field(2, "cat", Cat)
})
})
上の例は、Dog もしくは Cat をとる PetOwner 型の定義です。フィールド pet
には dog
もしくは cat
のいずれかが指定可能です。ポイントは、dog
と cat
にはそれぞれ別の型があって、互換がないということです。
var Dog = Type("Dog", func() {
Description("Dogs are cool")
Field(1, "Name")
Field(2, "Breed")
Required("Name", "Breed")
})
var Cat = Type("Cat", func() {
Description("Cats are cool too")
Field(1, "Name")
Field(2, "Age", Int)
Required("Name", "Age")
})
Dog も Cat も Name
というフィールドがあるので、全てのフィールドを包含するような型を用意してもいいですが(たいていの場合は OneOf を利用せずともこういうやり方でも問題なさそうですが)、それぞれの必須要素が異なるので、別の型として扱いたい、ここではこういった背景があると考えてください。
OneOf を含む Payload を利用してみる
では実際にサービスで利用してみたいと思います。pet-hotel
というサービスを定義します。pet-owner
というフィールドに先ほどの OneOf を含む型 PetOwner を指定して、犬か猫を預かるメソッドとします。説明のために、レスポンスは受け取った Payload をそのまま返すことにします。
デザイン
デザインは以下のようになります。
var _ = Service("pet-hotel", func() {
Method("check-in", func() {
Payload(func() {
Field("1", "body", PetOwner)
Required("body")
})
Result(PetOwner)
HTTP(func() {
POST("/check-in")
Body("body")
Response(StatusOK)
})
GRPC(func() {
Response(CodeOK)
})
})
})
サービスメソッド
さて、デザインを goa gen && goa example
したら、実際のビジネスロジックを埋めていきます。OneOf はどのようにサービスメソッドで受け取れるでしょうか?
Payload のフィールド PetOwner に Pet というフィールドがあります。この Pet は Dog もしくは Cat になっているので、型アサーション(type switch)することでこれを判別して取り出すことが出来ます。
func (s *petHotelsrvc) CheckIn(ctx context.Context, p *pethotel.CheckInPayload) (*pethotel.PetOwner, error) {
s.logger.Print("petHotel.check-in")
switch t := p.Body.Pet.(type) {
case *pethotel.Dog:
fmt.Printf("bow bow, %#+v\n", t)
case *pethotel.Cat:
fmt.Printf("mew mew, %#+v\n", t)
default:
return nil, fmt.Errorf("unknown type: %T", p.Body.Pet)
}
res := &pethotel.PetOwner{
Pet: p.Body.Pet,
}
return res, nil
}
便利なことに、p.PetOwner.Pet が Dog である場合にも、Cat である場合にも、すでに値がセットされています。
便利! しかし・・・
OneOf を使えば便利に色々な形式の Payload を受け取れますね!ですが、リクエストはあんまりきれいな感じになりません。たとえばこんな感じです。
curl -X POST localhost:8888/check-in -d '{"pet":{"Type":"dog","Value":"{\"Name\":\"POCHI\",\"Breed\":\"AKITA\"}"}}'
Payload の部分が見づらいので整形すると以下のようになっています:
{
"pet": {
"Type": "dog",
"Value": "{\"Name\":\"POCHI\",\"Breed\":\"AKITA\"}"
}
}
そうです。pet に指定するのは、結局
- 利用するフィールド名(OneOf で指定したフィールドのうちのひとつ)
- 実際のデータの JSON raw message な値
のペアになってしまうのです。運用上は、この辺は適当に生成してしまえば困ることはなさそうですが、ちょっと手で触ってみたい、みたいなときにはややこしさがあります。
どういう仕組みか?
じっさいこれはどういう仕組みなんでしょうか?
Payload は以下のように生成されます。
type CheckInPayload struct {
Body *PetOwner
}
この PetOwner が OneOf をフィールドとして持っているはずでした。どうなっているか見てみましょう。
type PetOwner struct {
// The owner's pet
Pet interface {
petVal()
}
}
func (*Cat) petVal() {}
func (*Dog) petVal() {}
OneOf 部分が inetrface に置き換わっています。置き換わったインターフェースを満たすように、Dog と Cat の構造体にメソッドが生やされています。これで、Pet を満たすのが Dog と Cat であることが分かります。なるほど。
おわりに
Goa の OneOf、gRPC では動くけど、HTTP では動かないと思っていて、しばらくぶりに試してみたら、ちゃんと動いていたのでした。型の違う Payload を取りたい、というのは設計として思いとどまるべきケースが多いような気はしますが、どうしても OneOf のような機能が利用したいことがあります。そういったときにちょっと試してみてもいいかなと思いました。
動くのがうれしかったのでざっと書きましたが、あとで 本(Goa v3入門) の方にももうちょっとまとめて追記しておきます 🙇♂️
Happy hacking!
Discussion