おはようございます。Gunosy 開発本部グノシー開発部のふそやん (@azihsoyn)です。
若干大げさなタイトルで失礼します。
この記事は Gunosy Advent Calendar 2017 兼 Go Advent Calendar 2017 の7日目の記事です。
昨日のエントリーは プロダクトの変更ログを記録することと、Slack+Zapier+Google Calendarを利用した記録の自動化について と Goのリバースプロキシーでレスポンスを書き換える でした。
グノシーAPIのフレームワーク
グノシーアプリのAPIはgoで書かれているのですが、 フレームワークに kami と goa が使われています。元々kamiだけだったのですが、APIのドキュメントが手書きだったりそもそもなかったりな状況が辛くなってきたので、新規で追加するAPIはgoaで実装するようにしています。
今のところ無理に既存のAPIを置き換えることはしていないので2つのフレームワークが共存する形になっています。
goaもkamiも標準のhttp.Handlerと共存できるように設計されているので共存はそこまで難しくなく実現できます。
goaの内部ではdimfeld/httptreemuxが使われています。これはrouting可能なAPIが登録されているかを見つけるLookupが実装されているので独自muxを以下のように書いて、
/*
https://github.com/goadesign/goa/blob/master/mux.go
goaのServeHTTPのみを以下のように変えたmux.goを実装
*/
func (m *mux) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if result, found := m.router.Lookup(rw, req); found {
m.router.ServeLookupResult(rw, req, result)
return
}
m.kamiHandler.ServeHTTP(rw, req)
}
mainでhandlerに登録してあげればkami, goaそれぞれのAPIにroutingしてくれます。
func main(){
/*
kamiのrouting登録
*/
service := goa.New("gunosy")
service.Mux = NewMux(kami.Handler())
/*
goaのrouting登録
*/
http.HandleFunc("/", service.Mux.ServeHTTP)
http.ListenAndServe(":8080", nil)
}
※エラーハンドリングは省略しています。
グノシーAPIのアーキテクチャ
ここまではただのTipsで本題はここからなのですが、グノシーAPIではいわゆる(?)レイヤードアーキテクチャを採用しています。先日のtimakin さんのエントリーの4に近い感じの構成でしょうか。Goのパッケージ構成の失敗遍歴と現状確認 – timakin – Medium
main.go
├── controllerA // kami実装のAPI(エンドポイント別にディレクトリがある)
│ ├── view.go
│ ├── request.go
├── app // goaが生成するコード。触らない。
│ ├── contexts.go
│ ├── controllers.go
│ ├── hrefs.go
│ ├── media_types.go
│ └── user_types.go
├── design // goaのDSL置き場
│ ├── api_definition.go
│ ├── common
│ ├── media_types
│ ├── resources
│ └── types
├── internal
│ ├── common // 本当に必要な共通処理。
│ ├── domain // エンティティとリポジトリのinterface
│ │ ├── article
│ │ │ ├── article.go
│ │ │ ├── repository.go
│ ├── middleware // 認証とか
│ ├── repository // domainのリポジトリの実装
│ │ ├── article
│ │ │ ├── article.go
│ │ │ ├── repository.go
│ ├── service // repositoryを使ってゴニョゴニョする(最近usecaseに名前を変えたほうがいい気がしている)
controller系は大分散らかっているので割愛します(エンドポイント単位でディレクトリが分かれてる)。
(ちなみにinternalにしているのは他のプロジェクトから万が一にもimportさせないようにするという強い意思の現れです。多分。)
また、articleのみ書いていますが、実際はもっとディレクトリが切られています。
レイヤードアーキテクチャではdomainのエンティティをリポジトリから取得してviewに変換して返すというのが基本的な実装になると思います。実装とインターフェースを分離できるので変更に強い設計だと思ってますが、実際の運用では
APIのレスポンスを追加したい
→ viewにフィールド追加
→ エンティティにフィールド追加
→ リポジトリの実装でフィールドに値を詰める処理を追加
というフローになって結局全部編集してるやんけ!みたいなことが結構あります(domainがコロコロ変わるのがよくないのかもしれませんが)。これをgoaでなんとかできないかと最近考えています。(やっとタイトル回収)
まずviewに関してはgoaでdesignを変更すればgenerateできるので問題はありません。
リポジトリで値を詰める処理が変わるのは当然なので触れません。
肝心のエンティティは、goaのTypeで定義したものを使えそうです。ドキュメントによると
Types can be used to define action payloads (amongst other things):
The goa API Design Language · goa :: Design-first API Generation
とあるので、基本はpayloadに使うもののようですが、生成されるコードを見るにシンプルなstructなので使用するのは問題なさそうです。
そう、使用するのは問題ないんですが、今のところgoaで生成されるrequest/responseのstructはどうやら1つのディレクトリにまとめられてしまうようでした(出力先のディレクトリとパッケージ名は変更できます)。
自分はgoではあまり推奨されていませんがパッケージは結構分けたい派なのですが、生成されたコードを移動するのはちょっと違う気がするので別の方法を考えたいと思います。
案1. typeを再定義する
// internal/domain/article/article.go
package article
import "github.com/gunosy/api/app"
type Article app.Article
一番オーソドックスな方法です。
案2. embedする
// internal/domain/article/article.go
package article
import "github.com/gunosy/api/app"
type Article struct {
app.Article
}
これだとgoaで定義した情報以外も持てるので拡張性は高そうです(やりすぎるとgoaで生成する意味がなくなりそうですが)
案3. type alias
// internal/domain/article/article.go
package article
import "github.com/gunosy/api/app"
type Article = app.Article
go1.9で追加されたtype aliasです。
問題はなさそうですが、type aliasは元のtypeの別名を付けているだけなので、メソッドの追加ができません。
この方法を取った場合はエンティティのメソッドではなくエンティティを引数に取る関数として実装する必要があります。
また、どの方法をとってもdomainのフィールドを確認するのにappに飛ばないと行けないというデメリットが生まれてしまいます。
ちょうど現在大きめのPJが動いていて新規にAPIを追加する機会が訪れたのでgoaのTypeをdomainエンティティとして使うことに挑戦しています。今のところは案1で実装していますが、もしかしたら実装を進める中で変えたりやめたりするかもしれません。
レイヤードアーキテクチャの一歩先
今の構成というかグノシーのAPIの特徴というか、記事を返すAPIが多いので似たviewがどうしても多くなっています。
これはgoa化すれば解決していく問題ではあります。
以下のように一つのMediaTypeに複数のviewを定義するとそれぞれのレスポンスstructをgenerateしてくれます。
var ArticleMedia = MediaType("application/vnd.gunosy.article.v1+json", func() {
Description("記事")
ContentType("application/json")
Reference(Article) // なんとなくMediaTypeはTypeをReferenceするようにしてます
Attributes(func() {
Attribute("id")
Attribute("title")
Attribute("image")
Attribute("url")
Attribute("published_at")
Required(
"id",
"title",
"image",
"url",
)
})
View("default", func() {
Attribute("id")
Attribute("title")
Attribute("image")
Attribute("url")
Attribute("published_at")
})
View("tiny", func() {
Attribute("id")
Attribute("title")
Attribute("url")
})
})
goa化が進むとviewが一箇所に集約されるので、controller毎にviewに変換するというよりは、viewに合わせて変換する機構が欲しくなってきます。
ここからは完全に構想レベルなのですが、Clean Architectureみたいな構成にできないかと考え中です。
- 参考
あまりレイヤーを増やして抽象化しすぎるのもgoには向いてなさそうな気がしますが、translator層とusecase層あたりを用意して今のアーキテクチャとクリーンアーキテクチャの中間ぐらいの構成にしようとしています。
main.go
├── controllerA // kami実装のAPI(エンドポイント別にディレクトリがある)
│ ├── handle.go // usecase, translator
├── app // goaが生成するコード。触らない。
│ ├── contexts.go
│ ├── controllers.go
│ ├── hrefs.go
│ ├── media_types.go // viewが集約されてる
│ └── user_types.go
├── design // goaのDSL置き場
│ ├── api_definition.go
│ ├── common
│ ├── media_types
│ ├── resources
│ └── types
├── internal
│ ├── common // 本当に必要な共通処理。
│ ├── domain // エンティティとリポジトリのinterface
│ │ ├── article
│ │ │ ├── article.go
│ │ │ ├── repository.go
│ ├── middleware // 認証とか
│ ├── repository // domainのリポジトリの実装
│ ├── usecase // repositoryを使ってentityを取得
│ ├── translator // entity -> viewに変換する
│ │ ├── article
translator層は作ってみたところdomainと同じような単位でパッケージを分けることになりそうです。
このあたりはClean Architectureの理解を深めつつ進捗があればまたどこかで書きたいと思います。
まとめ
後半は若干ポエムになってしまいました。
良い設計と楽な運用のバランスをとるのは難しいですが、品質を維持できるよう精進していきたいです。