私が歌川です

@utgwkk が書いている

mockgenのコード生成を1回にまとめて高速化するツールbulkmockgenを作った

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) については以下の記事が詳しいので、そちらを参照してみてください。

zenn.dev

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 ./... を実行している間に長めに休憩できるかもしれません。

xkcd: compiling (コンパイル中に遊んでいる様子)
xkcd: compiling*1

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は、大まかには以下のように動作します:

  1. 指定されたinterfaceの情報を得るためのプログラムを書き出す*3*4
  2. 書き出したプログラムをビルドして実行する*5
  3. 得られたinterfaceの情報をもとにモックを生成する*6

重要なことは、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がぜんぜん書けていないので誰か助けてください。