💭

Goa の OneOf を利用してみる

2022/12/27に公開

はじめに

Goa には OneOf という DSL が用意されています。これは protobuf の oneof に相当するもので、複数あるフィールドのうちの1つだけを指定できます。protobuf にある機能なので、利用できるのは不思議ではないのですが、Goa は HTTP トランスポートもサポートしているので、この機能は HTTP な REST API でも利用できます。

Goa のドキュメント(Blog)でも解説されています。
https://goa.design/blog/013-oneof/

デザイン

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 のいずれかが指定可能です。ポイントは、dogcat にはそれぞれ別の型があって、互換がないということです。

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