少し前に、GoogleからWireというDIツールがリリースされた。このツールは元々Go Cloudの内部で使われていたもので、汎用的なツールとして切り出したのだそうだ。このツールの特徴は依存グラフの解決がコード生成によって行われるという点で、リフレクションを使った動的なDIと比べるとエラーがコンパイル時に検出できるというアドバンテージがある。

ところで、The Go BlogCompile-time Dependency Injection With Go Cloud’s Wireによると、コード生成によるDIというアイディアはJavaのDaggerにインスパイアされたものらしい。自分も以前、DaggerにインスパイアされてDIKitというSwiftのライブラリを開発したことがある。DIKitは今もiOSアプリの開発で使っていて生産性に貢献している実感があり、いつかGoにも移植したいとチームのメンバーと時々話していたところだった。そんな時にWireが出てきたので、早速試してみた。

providerとinjector

Wireに登場する概念は非常にシンプルで、大雑把に言えばproviderとinjectorしかない。 どちらも値を返す関数という点では同じだが、依存グラフの中における役割が異なる。

providerは依存グラフにおける点の役割をする関数となっている。ある型の値を返すために必要なもの(dependency)を引数に取り、戻り値としてはその型の値を返す。次の例では、dependencyなしのFooRepositoryのproviderと、FooRepositoryをdependencyに持つFooServiceのproviderを定義している。

type FooRepository interface{}
type FooService interface{}

func ProvideFooRepository() FooRepository {...}
func ProvideFooService(fooRepository FooRepository) FooService {...}

injectorは依存グラフにおけるパスの役割をする関数となっている。複数のproviderを組み合わせて依存関係の解決し、目的の型の値を返すものだ。injectorの実装はコードジェネレーターによって自動生成されるものだが、その元となるコードは手書きで用意する必要があり、そこでインターフェースと、依存関係の解決に使用するproviderを指定する。コード生成の元となる手書きのコードをwire.goという名前で保存しておいてwireコマンドを実行すると、wire_gen.goというファイルが生成される。以下はwire.goの例。

// +build wireinject

package main

func InitializeFooService() (fooService FooService) {
	wire.Build(ProvideFooRepository, ProvideFooService)
	return
}

これに対し、Wireは次のようなwire_gen.goを生成する。

// Code generated by Wire. DO NOT EDIT.

//go:generate wire
//+build !wireinject

package main

// Injectors from wire.go:

func InitializeFooService() FooService {
	fooRepository := ProvideFooRepository()
	fooService := ProvideFooService(fooRepository)
	return fooService
}

wire.gowire_gen.goは同じパッケージなのでInitializeFooService()が衝突しそうだが、build constraintによってwire.goの方はWireのみから認識され、逆にwire_gen.goはWireには無視されるので、結果的に衝突することはない。

InitializeFooService()が生成されたことにより、FooServiceは引数なしで取得できるようになった。生成されたコードから、InitializeFooService()の内部では、元々providerで定義した内容に基づいて依存関係の解決が行われていることがわかる。ちなみに、injectorが依存関係を解決できない場合にはコードジェネレーターがエラーを吐くので、依存関係が解決できないままプログラムが実行されることはない。

injectorのパラメーターとエラー

providerがパラメーターを取れることは既に説明したが、先に紹介した例ではinjectorが持つproviderだけでパラメーターの解決がされていた(FooRepositoryのこと)。世のパラメーターの中には事前に値を決めることができないものもある。例えば、Cloud DatastoreのClientはコンストラクタの引数にcontext.ContextとプロジェクトIDを取るが、context.Contextはコンパイル時に決まるものではないのでproviderで返すことができない。そういう時には、injectorのパラメーターとしてcontext.Contextを取るように定義できる。

func InitializeFooService(ctx context.Context) (fooService FooService) {...}

ついでに言うと、Cloud DatastoreのClientのコンストラクタはエラーも返すのだけど、これもinjectorに引き継ぐことができる。

func InitializeFooService(ctx context.Context) (fooService FooService, err error) {...}

provider set

serviceをテストする場合、データにアクセスするrepositoryはモックに差し替えたくなる。実際に動かす時にはMySQLに繋がるrepositoryを使い、テストではモックのrepositoryを使いたいという具合に。こうした切り替えはinjectorが使用するproviderを切り替えれば可能だが、providerの数が多くなるとダルくなってくる。provider setはそれを解決してくれる概念で、MySQLに繋がるrepository群とモックのrepository群をまとめることができる。

func ProvideFooRepository() FooRepository {...}
func ProvideBarRepository() BarRepository {...}
var RepositoryProviderSet = wire.NewSet(ProvideFooRepository, ProvideBarRepository)

func ProvideMockFooRepository() FooRepository {...}
func ProvideMockBarRepository() BarRepository {...}
var MockRepositoryProviderSet = wire.NewSet(ProvideMockFooRepository, ProvideMockBarRepository)

injectorはprovider setも受け取ることができるので、切り替えがまとめてできるようになる。

// +build wireinject

package main

func InitializeFooService() (fooService FooService) {
	wire.Build(RepositoryProviderSet, ProvideFooService)
	return
}

func InitializeMockedFooService() (fooService FooService) {
	wire.Build(MockRepositoryProviderSet, ProvideFooService)
	return
}

テストパッケージ用のwire.go

Goでは1つのディレクトリには1つのパッケージしか存在できないが、テストコードは別のパッケージとして同一ディレクトリに同居できる。この時、通常のパッケージとテストコードのパッケージでそれぞれ別のinjectorを用意したいところだが、残念ながら現時点ではまだサポートされていないみたい。既にissueは上がっていてassigneeもついてるので、いずれはサポートされるものと思われる。

これがまだサポートされていなくても泣く必要はなくて、当該ディレクトリの下にでもモック用のinjectorのためのパッケージを用意すれば、とりあえずは要件は満たせる。今のチームでは全面的にgolang/mockが使われているので、これ用に使っているmock_*パッケージにモック用のinjectorを入れておけば良さそうかなと思っている。

実際に動作する例

以下のようなディレクトリの構成になっているとする。

.
├── domain
│   ├── error.go
│   ├── foo.go
│   ├── foo_repository.go
│   ├── foo_service.go
│   ├── foo_service_test.go
│   ├── mock_domain
│   │   ├── foo_repository.go
│   │   ├── wire.go
│   │   └── wire_gen.go
│   └── wire_set.go
├── infra
│   ├── foo_repository.go
│   ├── foo_repository_test.go
│   └── wire_set.go
├── main.go
├── wire.go
└── wire_gen.go

コードの全体はGitHubにおいてある。

domainパッケージ

repositoryのインターフェースはdomainパッケージで定義している。repositoryの実装はdomainパッケージの対象外だが、実装がないとFooRepositoryを扱うもののテストができないのでgolang/mockでモックを生成している。

package domain

import "context"

//go:generate mockgen -destination mock_domain/foo_repository.go github.com/ishkawa/wire_example/domain FooRepository
type FooRepository interface {
	Get(ctx context.Context, id int64) (foo *Foo, err error)
	Put(ctx context.Context, foo *Foo) (err error)
}

domain serviceではrepositoryなどを使ったロジックを記述する。FooServiceでは指定されたIDのFooを複製するDuplicate()を定義しており、内部ではFooRepositoryを通じてデータを読んだり書いたりしている。

package domain

import "context"

type FooService interface {
	Duplicate(ctx context.Context, id int64) (duplicated *Foo, err error)
}

func NewFooService(fooRepository FooRepository) FooService {
	return &fooService{fooRepository: fooRepository}
}

type fooService struct {
	fooRepository FooRepository
}

func (service *fooService) Duplicate(ctx context.Context, id int64) (duplicated *Foo, err error) {
	source, err := service.fooRepository.Get(ctx, id)
	if err != nil {
		return
	}

	duplicated = &Foo{Name: source.Name}
	err = service.fooRepository.Put(ctx, duplicated)
	return
}

FooServiceのように、repositoryを使ったdomain serviceはdomainパッケージ内にはたくさん登場する。これらをまとめて扱えるようにprovider setを定義しておき、FooServiceもその中に入れておく。

package domain

import (
	"github.com/google/wire"
)

var WireSet = wire.NewSet(NewFooService)

mock_domainパッケージ

先にも説明した通り、ここは本来golang/mockによって生成されたモック専用のパッケージだが、テストコードのための別パッケージに対するwire.goのようなものはまだサポートされていないので、ここにモックを使うinjectorを定義する。

テストコードでいつでもrepositoryのモックにアクセスできるようにするため、MockRepositorySetというstructにrepositoryのモックをまとめ、providerはそのstructを通じてrepositoryを返すようにしている。そして、injectorにはMockRepositorySetをパラメーターとして受け取らせ、テストに使用するMockRepositorySetをコントロールできるようにしておく。

// +build wireinject

package mock_domain

import (
	"github.com/golang/mock/gomock"
	"github.com/google/wire"
	"github.com/ishkawa/wire_example/domain"
)

type MockRepositorySet struct {
	MockFooRepository *MockFooRepository
}

func NewRepositorySet(ctrl *gomock.Controller) *MockRepositorySet {
	return &MockRepositorySet{
		MockFooRepository: NewMockFooRepository(ctrl),
	}
}

func ProvideFooRepository(repositorySet *MockRepositorySet) *MockFooRepository {
	return repositorySet.MockFooRepository
}

var providerSet = wire.NewSet(
	domain.WireSet,
	ProvideFooRepository,
	wire.Bind(new(domain.FooRepository), new(MockFooRepository)))

func InitializeFooService(provider *MockRepositorySet) (fooService domain.FooService) {
	wire.Build(providerSet)
	return
}

domainパッケージのテスト

いくらDIが簡単になったとはいえ、テストケース毎にcontext.Contextgomock.Controllermock_domain.MockRepositorySetのあれこれを書くのは無意味だし面倒なのでtestify/suiteを使って共通化しておく。

type FooServiceTestSuite struct {
	suite.Suite

	ctx           context.Context
	ctrl          *gomock.Controller
	repositorySet *mock_domain.MockRepositorySet
	service       domain.FooService
}

func TestFooServiceTestSuite(t *testing.T) {
	suite.Run(t, new(FooServiceTestSuite))
}

func (suite *FooServiceTestSuite) SetupTest() {
	suite.ctx = context.Background()
	suite.ctrl = gomock.NewController(suite.T())
	suite.repositorySet = mock_domain.NewRepositorySet(suite.ctrl)
	suite.service = mock_domain.InitializeFooService(suite.repositorySet)
}

func (suite *FooServiceTestSuite) TearDownTest() {
	suite.ctrl.Finish()
}

これで、テスト対象のFooServiceにはsuite.serviceでアクセスでき、FooServiceが使う各repositoryのモックの挙動はsuite.repositorySetから制御できるようになった。

FooServiceのDuplicate()のテストは以下のように書ける。ここでは、FooServiceがRepositoryに対してどのような値を書き込んでいるか、結果としてどのような値を返しているかをテストしている。ちなみに、本筋ではないがtestify/assertを使っている。

func (suite *FooServiceTestSuite) TestDuplicate() {
	source := &domain.Foo{ID: 123, Name: "Source"}
	allocatedID := int64(456)

	suite.repositorySet.MockFooRepository.EXPECT().
		Get(suite.ctx, source.ID).
		Return(source, nil)

	suite.repositorySet.MockFooRepository.EXPECT().
		Put(suite.ctx, gomock.Any()).
		Do(func(ctx context.Context, duplicated *domain.Foo) {
			assert.Equal(suite.T(), int64(0), duplicated.ID)
			assert.Equal(suite.T(), source.Name, duplicated.Name)
			duplicated.ID = allocatedID
		})

	duplicated, err := suite.service.Duplicate(suite.ctx, source.ID)
	assert.NoError(suite.T(), err)
	assert.Equal(suite.T(), allocatedID, duplicated.ID)
	assert.Equal(suite.T(), source.Name, duplicated.Name)
}

このようなテストを書けるようにするためには、やっぱりWireのようなDIをサポートする仕組みがあると良い。手動のDIで頑張る場合、それではserviceのdependencyが変わる度にパッケージ自身のコンストラクタだけでなく、テストコードのセットアップも変えなければならない。Wireを使っている場合は、そのようなコンストラクタだけを組み替えれば済むので、その分テストにより集中しやすい。

infraパッケージとmainパッケージ

FooRepositoryの実装は、MySQLに繋ぎに行くでも、Cloud Datastoreに繋ぎに行くでも、mapを使うでも、どんな方法でも良い。ここでは、とにかくGet()とPut()を何かしらの方法で実装したものとする。

package infra

type fooRepository struct{}

func NewFooRepository() domain.FooRepository {
	return &fooRepository{}
}

domainパッケージ内におけるserviceと同様の話で、repositoryもinfraパッケージ内にはたくさん登場することになる。なので、これらをまとめて扱えるようにprovider setを定義しておき、FooRepositoryもその中に入れておく。

package infra

import "github.com/google/wire"

var WireSet = wire.NewSet(NewFooRepository)

mainパッケージでは、infraパッケージで実装されたrepositoryがinjectされたdomain serviceを使いたい。これをWireにやってもらうためには、domainパッケージで定義したdomain serviceをまとめたprovider setと、infraパッケージで定義したrepositoryをまとめたprovider setを使ったinjectorを定義する。

// +build wireinject

package main

import (
	"github.com/google/wire"
	"github.com/ishkawa/wire_example/domain"
	"github.com/ishkawa/wire_example/infra"
)

func InitializeFooService() (fooService domain.FooService) {
	wire.Build(domain.WireSet, infra.WireSet)
	return
}

これで、domainパッケージのテストではモックのrepositoryをinjectしつつも、mainパッケージではinfraパッケージで実装したrepositoryをinjectできるようになった。

まとめ

コード生成によってDIをサポートする仕組みというと、何だか複雑なものを想像しがちだが、Wireがやっていることは素朴なDIのプラクティスのうち、退屈な部分のコードを自動生成しているに過ぎない。なので、もし既に素朴なDIを実践しているのなら比較的簡単に移行できると思う。

ここでは自分の場合どのように使えそうかという話を中心に書いたが、本家のチュートリアルユーザーガイドベストプラクティスもそれほど分量があるわけではないので、興味がある人には是非読んでもらいたい。

もし、もっと突っ込んだ話が聞きたい場合は、会社に遊びにきてください。

追記 (2018/12/18)

テスト用のwire.gowire_gen.goをgolang/mockが作成するmock_*パッケージに置くと書いたが、それではモックがないパッケージで困ることに気づいた。一貫性を保つため、 #48 が解決されるまではwire.gowire_gen.gotest_injectorのような専用のパッケージに置くことにした。