tl;dr
表題のようなツールを作りました。go install
コマンドでただちにインストールできます。
$ go install github.com/utgwkk/bulkmockgen/cmd/bulkmockgen@latest
従来の //go:generate
コメントから移行するツールもあります。
$ go install github.com/utgwkk/bulkmockgen/cmd/mockgen-to-bulkmockgen@latest
課題感
モックを使った開発と問題
Goでinterface定義のモックを生成するツールとしては、mockgen (gomock) がよく知られています。最近 github.com/golang/mock のほうがarchiveされてforkされましたね。
mockgen (gomock) については以下の記事が詳しいので、そちらを参照してみてください。
Goでgomockを使った開発をすることを考えると、mockgenでモックのコードを生成し、テストではモックを使ってアサーションを記述する、というのがよくある開発フローだと思います。たとえば以下のようにinterface定義を書いたのち go generate
コマンドでモックを生成できるように //go:generate
コメントを書くでしょう。
package interfaces import "context" //go:generate mockgen -package mock_interfaces -destination mock_interfaces/iface1.go . Iface1 type Iface1 interface { Meth1(ctx context.Context, arg int) error }
ところで、開発が進むにつれてinterface定義の数が多くなり、//go:generate
コマンドの数も多くなると思います。こうなってくるとよくある問題として、mockgenのコード生成にかかる時間がどんどん長くなります。ひどい場合は go generate ./...
を実行している間に長めに休憩できるかもしれません。
mockgenのコード生成に時間がかかる
mockgenのコード生成に時間がかかる理由としては、大きく2つのことが挙げられるでしょう。
//go:generate
コメントに書いたコマンドは逐次実行される- mockgenのreflect modeが遅い
前者は go help generate
にも書いてあるように、 go generate
コマンドがそのように設計されているためです。
Within a package, generate processes the source files in a package in file name order, one at a time. Within a source file, generate runs generators in the order they appear in the file, one at a time.
go generate
を並列実行できるようにしたい、というproposalが提出されたことがありますがrejectされています*2。
後者はmockgenのreflect mode特有の事情です。
mockgenにはsource modeとreflect modeという2つの動作モードがあります。mockgenのreflect modeは、大まかには以下のように動作します:
重要なことは、reflect modeではmockgenコマンドを実行するたびにプログラムのビルドが行われている、ということです。つまり、素朴に //go:generate
コマンドを都度書いていくとその数だけプログラムのビルドが実行されるということになります。これによってモックの生成に時間がかかってしまいます。
別の話題として、mockgenを go run
で実行するとmockgenのコードが都度コンパイルされるので余計に時間がかかる、というものがあります。
//go:generate
コマンドを1行で書くと人間に優しくない
mockgenコマンドが一度で実行されるようにすればよいのではないか、ということを思いつくでしょう。実際、以下のように複数のinterfaceのモックを一括で生成することもできます。
//go:generate mockgen -destination mock_interfaces/mock_interfaces.go -package mock_interfaces . Iface1,Iface2,Iface3
これでひとまずコード生成にかかる時間は短縮できますが、一方で //go:generate
コメントの末尾がどんどん長くなっていくことが予想されます。開発が佳境になって、どんどんinterfaceを足したいというときに複数人が //go:generate
コメントを編集するとコンフリクトの温床となってしまうでしょう。なんとかコンフリクトを解消したと思ったらモックが生成されなくなってしまっていた、という事故が起こりえます。
//go:generate
コメントを複数行にわたって書けるようにする、というproposalが提出されたことがありましたが取り下げられています*7。ということで、2023/7/8 時点では //go:generate
コメントの仕組みに乗った上でmockgenだけを使ったまま高速化するにはコメントを1行で書ききらないといけないようです。
bulkmockgen
先述したような課題感をなんとかするためにbulkmockgenを作りました。以下のように、モックしたいinterfaceをまとめたスライスを定義しつつbulkmockgenを実行するような //go:generate
コメントを書いたファイルを用意します。
package interfaces //go:generate go run github.com/utgwkk/bulkmockgen/cmd/bulkmockgen MockInterfaces -- -package mock_interfaces -out ./mock_interfaces/mock_interfaces.go var MockInterfaces = []any{ new(Iface1), new(Iface2), new(Iface3), }
この状態で go generate
すると ./mock_interfaces/mock_interfaces.go ファイルに MockInterfaces
に列挙されたinterface (Iface1, Iface2, Iface3) に対応するモックが生成されます。
内部的には、MockInterfaces
に列挙されたinterface名をカンマで結合して、mockgenコマンドを一度だけ実行しています。こうすることによって//go:generate
コマンドを1行で書いたのと同等の効果を得ることができています。--
以降のコマンドライン引数はそのままmockgenに渡ります。
モックしたいinterfaceが増えたときは MockInterfaces
に足せばよいです。1行に1つのinterface名を書けばよいので差分が人間にも優しく、コンフリクトの解消も比較的やりやすいのではないかと思います。
mockgenから移行する
さて、もう既にbulkmockgenを使ってみたくなっていると思いますが、一方で既存のコードには大量の //go:generate
コメントがあることでしょう。これらのコメントを取り除きつつモック対象を列挙していく、というのはなかなかに骨の折れる作業かもしれません。
ということで移行ツールも用意してあり、こちらも今すぐにインストールしてもらえます。
$ go install github.com/utgwkk/bulkmockgen/cmd/mockgen-to-bulkmockgen@latest
以下のように移行したいファイルがあるディレクトリを指定して実行することで移行できます。
$ mockgen-to-bulkmockgen -in_dir ./benchmark/interfaces -out ./benchmark/interfaces/0_bulkmockgen.go
mockgenの //go:generate
コメントを発見して、モックの生成先packageごとに別の変数にinterface一覧を格納して、bulkmockgenでコードを生成する //go:generate
コメントを書いたファイルを生成してくれます。便利ですね。移行ツールでコードを一括で書き換えた上で、コマンドライン引数をちょっと調整してあげればすぐに使えるのではないでしょうか。
ベンチマーク
ベンチマークとして、20個のメソッドを持つinterfaceを50個定義し、モックを1つずつ生成する場合とまとめて生成する場合とを比較します。
bulkmockgenのディレクトリにベンチマーク用のファイルを用意してある*8ので、これを使いましょう。go generate ./benchmark/interfaces/...
にかかる時間を計測します。
before (1つずつ生成する)
まずは元のコードのまま、interface定義ごとに //go:generate
コメントを書いて go generate
を実行してみます。
% time go generate ./benchmark/interfaces/... go generate ./benchmark/interfaces/... 22.95s user 10.13s system 119% cpu 27.643 total
50個のinterfaceのモックの生成に20秒ほどかかっています。
after (まとめて生成する)
bulkmockgenでモックを生成するようにコードを修正した上で go generate
コマンドを実行してみましょう。
% time go generate ./benchmark/interfaces/... go generate ./benchmark/interfaces/... 1.62s user 0.48s system 151% cpu 1.380 total
50個のinterfaceのモックの生成がわずか数秒で終わりました。よかったですね。
bulkmockgenの制約
2023/7/9 時点では、bulkmockgenには以下のような制約があります。
- mockgenのsource modeに対応していない
指定したパッケージの外部のinterfaceのモックが生成できない
後者について、具体的には以下のようなコマンドに対応するモック生成ができません。
2023/7/29 14:37 追記: generator: interface mocking for external packages by utgwkk · Pull Request #4 · utgwkk/bulkmockgen · GitHub を取り込んだので対応しました。移行ツールについては未対応です。
$ mockgen database/sql/driver Conn,Driver
ひとまず自分が関わっているプロジェクトでのユースケースを網羅できればよい、という方針で実装したのでこのようになっています。真にmockgenを代替したいのであれば、いずれは解決する必要がありそうですね。
他の方針
gomockhandler
mockgenのコード生成を高速化する試みとして、専用のツールで並列にコードを生成するアプローチのgomockhandlerがあります。source modeや依存ライブラリのinterfaceのモックにも対応しており、またコードが最新になっているか確認する機能があることなどからもgomockhandlerのほうが機能は揃っていると思います。
gomockhandlerがあるのにも関わらずbulkmockgenを作ったのは、もっと素朴な方法でもコード生成を高速化できるのではないかと考えたことや、go generate
コマンドの仕組みから逸脱せずにコード生成を高速化できないかと思ったためです (そして実際に高速化できました)。ほかにも、1プロジェクト内でレイヤードアーキテクチャ的に実装する際のモックを用意するのにひとまず特化して作れるのではないか、というのを検証したかったのもありました。
強いてgomockhandlerに対する優位性を挙げるとするならば、-use_go_run
コマンドライン引数を使って go run
形式でmockgenを実行できることや、モックの追加・削除にCLIを使わなくてもよい点などがあるでしょうか。
Makefileやシェルスクリプトで生成する
素朴にはMakefileやシェルスクリプトで並列に生成することもかもしれません。が、筆者の環境ではMakefileを使って並列にmockgenコマンドを叩いてモックを生成しようとしたところ、途中でビルドエラーが発生してうまくモックを生成できませんでした。並列にモックを生成しているという点ではgomockhandlerも同様の問題に当たったと思いますが、どうやって解決したのでしょうか? 今度コードを読んでみます。
MakefileやシェルスクリプトではなくGoのコード中にモック対象のinterfaceを列挙することで、renameに強いという効能が得られています。あとは go generate
の仕組みから逸脱していないので、従来の暮らしからのジャンプが比較的小さいと思います。
おわりに
mockgenコマンドを一度だけ実行すればよいようにラップすることで、モックの生成にかかる時間を大幅に短縮できました。モックを使った堅牢な開発と素早い実装を両立するのに役立てられればと思います。
READMEがぜんぜん書けていないので誰か助けてください。
*2:https://github.com/golang/go/issues/20520
*3:https://github.com/uber/mock/blob/dac455047760bb7061f57f42615cacfa1fac75c1/mockgen/reflect.go#L50
*4:https://github.com/uber/mock/blob/dac455047760bb7061f57f42615cacfa1fac75c1/mockgen/reflect.go#L152
*5:https://github.com/uber/mock/blob/dac455047760bb7061f57f42615cacfa1fac75c1/mockgen/reflect.go#L165-L169
*6:https://github.com/uber/mock/blob/dac455047760bb7061f57f42615cacfa1fac75c1/mockgen/mockgen.go#L160
*7:https://github.com/golang/go/issues/46050
*8:https://github.com/utgwkk/bulkmockgen/tree/main/benchmark/interfaces