まじめにgoでDIを考える前のメモ
そろそろDIについてもまじめに考える必要が出てきたので考えることにする(この記事では終わらない)。たまには答えになっていないようなメモでも。
何となく最近思うのは、goゆえの制約はあっても、goだからで省略できる特別なことは特に無いなということ(省略できるかどうかは書こうとしてるものの領域に依存してる)。
あと、アプリケーションのコードが書ける人と、ライブラリのコードが書ける人と、設計をやる人はいても、ライブラリを上手くアプリケーション側に統合できる人は少ないのかもしれないというようなこと。
いつDIを気にしたくなるか
いつDIを気にしたくなるかあるいはどういう状態なら必ずどうしてもDIのことを考える必要が出てくるかについてもメモしておきたい。DIを本気で考える必要があるというタイミングは個人的には以下かなと想った。
依存の依存が共有されたとき
前提として設定による分岐と文脈による分岐が分離できている必要がある。そして設定の分岐による生成が面倒というところで片足DIに踏み込む。特にconfigとコンポーネント(ないしはその依存ライブラリ)との結びつきがめんどうという話と複数のバイナリを相手にしたくなったとき。
ここまではまだどうにか無くても頑張っていけるが依存の依存が共有されたときが無理。
依存の依存が共有されたとき
図示すると以下の様な形。AはXからもYからも依存されている。
A -> X A -> Y
ここで、例えば以下の様なXやYのファクトリー関数をいちいち変更するのが大変という状況で
x/New :: A -> something* -> x/X y/New :: A -> something* -> y/Y
設定からX,Yを作る関数を定義するだけでは避けられないとき
XFromConfig :: conf -> x/X YFromConfig :: conf -> y/Y // 内部的にAを共有したい
(その他登録するコンポーネントはファクトリーであるべきだとか細々とした実装依存はある)
ところでなぜ設定からX,Yを作る関数を定義したくなるかというとXやYの依存が時折変わるから。その際に数十のmain.goを書き換えて回るのは不毛。
どう対応するつもりか
wireとかを調べたのだけれど、なるべく段階的に移行したかったり、なるべく特殊なコマンドの実行が必須になることは後々に遅延したい。
対応予定の方針
個人的には以下のような方針で対応することを考えている。
- sharedなパッケージを作りここでコンポーネントを作成
- registry的なオブジェクトに登録する
- ただし、そのままだと全ての依存が全てのバイナリの依存になるので直接は使わないようにbuild
ここで特別なコマンドが必須になりそうでそれなら既存の何かを上手に使った方が良いのではという気持ちになっている。
制約
ただし以下の様なことも気にしたいと思っている(制約)。
- main.goひとつだけを指定してgo runで動かせることは死守したい
- main.go内のコードが太るのは悪
- ビルドタグで分岐という形で持っていくと楽だけれどビルドシステム必須ということはなるべく避けたい
間違いなさそうだなと思っているのは、個々のバイナリでの不要な依存を断ち切るにはコード生成(コード出力)が必須だということ。一方で依存最小を考えすぎるのはもはやロートル的な思考という感じもしなくもない(機械学習系の何かとかはそのまま入れるとひどいことになるし(goとは無関係))。
misc的なこと
misc的なメモを。まずDIコンテナとかは実装の詳細の話な気がするので依存管理の本質ではないような感覚がある。とはいえ手軽ななにかで済ませられるなら済ませたい。
あとこの辺のパッケージは調べてみたりした。
例えばwireなどをまじめに使っている人などの話を聞いてみたりしたい。(一応触ったことはあるしこれらについて意見は持っているものの)。
現在時刻のような実行の度に値が変わるようなresponseを含んだAPIのテストについて
昨日に引き続きgo-webtestの話。
現在時刻のような実行の度に値が変わるようなresponseを含んだAPIのテストがしたいとする。こういう場合はそもそも固定値を返せるように依存するコンポーネントを注入できるような構成にしておくのが良い。
依存の注入方法について
注入方法は大まかに以下の2つ
- Handlerを特定のstructのメソッドにして、そのstructのfieldに依存をもたせる
- Handlerを定義するのではなく、Handlerを生成する関数を定義する
- (contextにWithValue()で依存を持たせる)
1つ目の例は世の中にあふれているので2つ目の例を書くことにする。詳しい話が知りたい場合には以下の記事を読んだ方が早いかもしれない。
(DIコンテナなどがどうこうみたいな話はやり方の話なので省略(例えばwebAPIを作成する時にコード生成(出力)を利用するか手書きするかみたいな話))
Handlerを定義するのではなく、Handlerを生成する関数を定義する
今回作成するAPIは以下のようなもの。
$ http -b GET :8080/now { "now": "2019-09-18T18:16:53+09:00" }
現在時刻を返すだけのAPI。現在時刻に限らずDBのauto-incrementだとかUUIDだとか入力値に寄らず現在のグローバルな状態を元に生成される値が存在することがある。それらの例のうちもっとも単純なもの例として見て欲しい。
Handler関数(http.HandlerFunc)を返す関数として各APIのエンドポイントを定義してあげると、依存はただ単にその関数の引数になる。以下の様な感じ。
api.go (Now)
func Now(now func() time.Time) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { fmt.Fprintf(w, `{"now": "%s"}`, now().Format(time.RFC3339)) } }
そしてこれらをまとめてhttp.Handlerを返してあげるような関数でラッピングしてあげれば完成。
api.go (Handler)
func Handler( now func() time.Time, ) http.Handler { var mux http.ServeMux mux.HandleFunc("/now", Now(now)) return &mux }
main関数もおまけに書いておく
func main() { h := Handler(time.Now) http.ListenAndServe(":8080", h) }
こうしてあげると以下のようにテストが書ける。ほとんど通常のweb APIのテストと変わらない感じ。
package main import ( "testing" "time" rfc3339 "github.com/podhmo/go-rfc3339" webtest "github.com/podhmo/go-webtest" "github.com/podhmo/go-webtest/try" ) func TestAPI(t *testing.T) { h := Handler(func() time.Time { return rfc3339.MustParse("2000-01-01T00:00:10Z") }) c := webtest.NewClientFromHandler(h) var want interface{} try.It{ Code: 200, Want: &want, }.With(t, c, "GET", "/now", ) }
便利ですね(とはいえgo-webtestの紹介にはなっていない)。
以下の様なgolden dataが埋まっています。
{ "modifiedAt": "2019-09-18T18:07:48.045620749+09:00", "data": { "request": { "method": "GET", "path": "/now" }, "response": { "data": { "now": "2000-01-01T00:00:10Z" }, "statusCode": 200 } } }
これを使ってopenAPI documentなどにexampleとして注入できないかなと妄想していたりもします。
既存のコードがゴミの場合
既存のコードがゴミの場合はこんなにうまくはいかない。さてどうしてやろうかと言うのが悩みどころ。そして大抵の場合domainやentity付近で作られているのでだるい(だるい)。。ゴミと呼ぶのは良くないですね。time.Nowなどが直接使われているテストのことが全く考えられていないコードのことです。ごめんなさい。
どうやるのが良いでしょうか?一番単純な方法はパッケージグローバルにnowのような関数を持ちそれを使うという方法かもしれません。個人的には依存が引数の関係性の外に現れるし、気にするべき状態が暗黙に存在することになるので好きではないやり方だったりします。
例えば以下のような感じでしょうか?
SetNow()
などを作るのはオススメしないです。せめてWithNow()
というようなteardown的な関数を返すような実装にしておきましょう(どうせSetNow()などが必要になるような設計しかできない人はテストでもどこかでcleanupを忘れる(ちなみにこういう埋め込みのNowに拒絶反応を持つ人はもっと早くにどこかでcleanupを忘れて不快感をつのらせます。うかつなので))。
now.go
package main import "time" var now func() time.Time func Now() time.Time { if now != nil { return now() } return time.Now() } func WithNow(fn func() time.Time) func() { if now != nil { // prevとか保持する必要無いと思う。どうせ壊れている panic("heh") } now = fn return func() { now = nil } }
テストは以下の様な形。io.Closerみたいなメソッドにteardownを持つような定義はテストで必ず実行させたい場合にはオススメしないです。teardownのような関数で返ってきた方がまだマシ。
package main import ( "testing" "time" rfc3339 "github.com/podhmo/go-rfc3339" webtest "github.com/podhmo/go-webtest" "github.com/podhmo/go-webtest/try" ) func TestAPI(t *testing.T) { teardown := WithNow(func() time.Time { return rfc3339.MustParse("2000-01-01T00:00:10Z") }) defer teardown() h := Handler() c := webtest.NewClientFromHandler(h) var want interface{} try.It{ Code: 200, Want: &want, }.With(t, c, "GET", "/now", ) }
こういう依存って1つならまだどうにかなるんですけれど、複数になってくると見た目が直列なのでどの依存が何に対応しているのか追うのが辛くなってくるんですよね。あんまり良い方針だとは思えない。
(こういう定義がコピペでいろんなパッケージに現れてくると最悪ですね。さすがにそこまでひどいものはあまり経験したことが無いですが。「あなたの作っているのはツールですか?アプリですか?ツールならまだ許してあげないこともないですけど」みたいな問答をしたくなるレベル)。
responseだけを書き換えたい場合
responseだけを書き換えたい場合にも一応は対応しています。とはいえそれだけで済むことはあまり無いような気もしますが。
ModifyResponse()
というフィールドを受け取れます。ここで返した値がそのテストで取得した値として扱われます。このときwantの中には以前のテストの結果が詰め込まれています。
(またWantを指定しなかった場合にはsnapshot testingの比較が行われません)
package main import ( "testing" webtest "github.com/podhmo/go-webtest" "github.com/podhmo/go-webtest/try" "github.com/podhmo/noerror" ) func TestAPI(t *testing.T) { h := Handler() c := webtest.NewClientFromHandler(h) type response struct { Now string `json:"now"` } var got response var want interface{} try.It{ Code: 200, Want: &want, ModifyResponse: func(res webtest.Response) interface{} { noerror.Must(t, res.ParseJSONData(&got)) pp.Println("previous", want) pp.Println("current", got) got.Now = want.(map[string]interface{})["now"].(string) // nowを何か良い感じに扱うか無視する return got }, }.With(t, c, "GET", "/now", ) }
実行結果。
$ go test $ go test "previous" map[string]interface {}{ "now": "2019-09-18T18:51:37+09:00", } "current" main.response{ Now: "2019-09-18T18:55:53+09:00", } PASS
無視したい場合
無視したい場合はomitする形でsnapshotをupdateしてくださいとできれば良いんですがまだサポートしていません。現状ではwantもgotも取り除く必要があります。注意してください。この辺りはもう少し良い方法を考えようかなとおもっています。
はじめからmapにしておくと便利かもしれません。
package main import ( "testing" webtest "github.com/podhmo/go-webtest" "github.com/podhmo/go-webtest/try" "github.com/podhmo/noerror" ) func TestAPI(t *testing.T) { h := Handler() c := webtest.NewClientFromHandler(h) var got map[string]interface{} var want interface{} try.It{ Code: 200, Want: &want, ModifyResponse: func(res webtest.Response) interface{} { noerror.Must(t, res.ParseJSONData(&got)) delete(got, "now") delete(want.(map[string]interface{}), "now") return got }, }.With(t, c, "GET", "/now", ) }
実はgo-webtest/snapshot/replaceというパッケージを作っていてこちらはsnapshotを読み込むタイミングでの置換をサポートしようと思っているのですがまだ統合が上手くいっていないです。