goaでマイクロサービス間のI/Fを共有する
最近会社のAPIサーバーがgoaを使って実装されることが多くなってきています。設計とドキュメンテーションが同時にできる上にボイラープレートの省略(バリデーションとかレスポンス定義とか)ができるので使わない手はないですね。
それはいいのですが、今まで単体のAPIサーバーでgoaを使っていた時には気づかなかった問題に遭遇しました。
下のような構成を考えてみます。
client -> gateway API -> user API
gatewayAPIが内部のuser APIからデータを取得してクライアントにレスポンスを返すとします。マイクロサービスアーキテクチャでよくある構成だと思います。user APIのレスポンスだけをclientに返すのであればproxyすればいいのですが、gatewayで構造を変える場合はいい感じにレスポンスの構造体に詰める必要があります。
// こんなイメージ type User struct { ID int Name string } type Response struct { Page int Users []*User // この部分はマイクロサービス通信 }
愚直にuser APIのレスポンス構造体をgateway側にコピペ定義してあげてもいいのですが、ネストした構造だったり更新が多かったりすると辛いところです。また、どうせBのレスポンスを返すんだからAny (interface{})でいいじゃんという気持ちもあるのですが、なんでも突っ込めるフィールドになってしまうのが悩ましいところです。
そこでgoaを活かした解決策がないか考えてみました。
1. user APIのdesignをimportする
goaのdesign DSLはgoで定義します。つまりgo getできるということです。 user APIのdesignをgo getしてgatewayのdesignで同じものを使うようにしてgenerateすれば同じ構造のstructを作ることができます。gRPCのprotoファイルのような使い方ですね。 goは1.8から同じstructの構造であれば簡単にキャストできるようになったので変換もすぐできます。 Go 1.8 Release Notes - The Go Programming Language
// user api package media_types import ( . "github.com/goadesign/goa/design" . "github.com/goadesign/goa/design/apidsl" ) var User = MediaType("application/vnd.user+json", func() { Description("user") Attributes(func() { Attribute("id", Integer, "ID of user", func() { Example(1) }) Attribute("name", String, "Name of user", func() { Example("test") }) Attribute("created_at", DateTime, "Date of creation") Required("id", "name", "created_at") }) View("default", func() { Attribute("id") Attribute("name") Attribute("created_at") }) })
// gateway package design import ( . "github.com/azihsoyn/goa_microservice_sample/user_service/design/media_types" . "github.com/goadesign/goa/design" . "github.com/goadesign/goa/design/apidsl" ) var Complex = MediaType("application/vnd.complex+json", func() { Attributes(func() { Attribute("users", CollectionOf(User)) // user_serviceのUserを使う Required("users") }) View("default", func() { Attribute("users") }) })
2. user APIでgenerateしたモデルを使う
1でもある程度問題は解決できるのですが、同じ構造のstructの配列はそのままcastできません。
package main type A struct { ID int } type B struct { ID int } type ArrA []*A type ArrB []*B func main() { a := []*A{&A{1}, &A{2}, &A{3}, &A{4}, &A{5}} var b ArrB b = ArrB(a) } // ./cast.go:18:10: cannot convert a (type []*A) to type ArrB
愚直に変換してあげてもいいのですが、どうせならuser APIの構造体をそのまま使いたいところです。 以前
というエントリで書いた方法と同じなのですが、Metadata DSLを使います。
var Complex = MediaType("application/vnd.complex+json", func() { Attributes(func() { Attribute("users", CollectionOf(User), "list of user", func() { Metadata("struct:field:type", "app.UserCollection", `github.com/azihsoyn/goa_microservice_sample/user_service/app`) }) Required("users") }) })
と書くと以下のように
import ( "github.com/azihsoyn/goa_microservice_sample/user_service/app" "github.com/goadesign/goa" "time" ) type Complex struct { // list of user Users app.UserCollection `form:"users" json:"users" xml:"users"` }
と、user APIの型そのままでgenerateすることができます。 これでgateway → user API間のstructの構造とレスポンスの構造を再利用できるようになりました。
ただこの方法も幾つか制限があり、
- design, appがinternalパッケージになってると使えない
- 1つのAPIから接続するマイクロサービスの数が2つ以上になるとappパッケージがバッティングする(Metadata DSLはnamed importできない模様)
- ので、generateする際のディレクトリ名を変更する(デフォルトapp)
- designをimportするときにapi_definitionが重複するとgenerateできないのでimportされる側はパッケージを分ける必要がある
- designをimportするときにMediaTypeのidentifierが重複するとgenerateできないので識別子はユニークにする
と気軽には使えないかもしれません。
せっかくgoa実装のAPIが増えてきたので連携もいい感じにできたら嬉しいんですけどね。。
こんな感じにやってるなどの知見があればアドバイスいただけるとうれしいです。
素直にI/F用のパッケージ置き場を作ったほうがいいんだろうか🤔
// こんなイメージ? gateway -(import)-> I/F packages <-(import)- user api
サンプルはこちらに置いておきます。