こんにちは。電通国際情報サービス(ISID) 金融ソリューション事業部の水野です。
go言語で開発しているプロジェクトで、DIを導入する機会があったので紹介します。
開発環境
- Visual Studio Code 1.64.2
- go 1.17.8
なぜDIが必要なのか
goと言えば go generateに典型的な自動生成をイメージされる方が多く、他の言語で良く使用されるDIコンテナとは縁が薄いと思われる方も多いのではないでしょうか。
私も実はそう考えていたクチですが、依存性を切り離して状況に応じて必要な実装をInjection(注入)するというアーキテクチャは、言語を問わず有用であるケースはあります。
以下に例を挙げます。
- ローカル開発環境ではDBアクセスを伴わないモックを使うが、デプロイされた環境ではDBアクセスを伴うモジュールに差し替えたい
- 単体テストでは各種通信(APIコールなど)を行わないが、それ以外では実際に通信を行うモジュールを稼働させる
- ファイル入出力を行う機能で、特定の条件下ではファイルを扱わずにエミュレートするモードに切り替えたい
- インタフェースは確定したが実装が未完の場合。一旦スタブ実装を充ててローカルでの開発は滞らないようにするが、デプロイ時は正式なモジュールを用いる
- 別の話ですが、このケースは本番モジュールの実装が完成するまでは、panicする等で意図を明確にする方が良いでしょう。
など、一定の規模かつ複雑度のシステムを構築する場合、DIコンテナが欲しくなるケースは多いです。
プロダクト選定
goのDIコンテナとしては、GoogleさんのwireやUberさんのfxが有名です 1。
GitHubのStar数はwireが一番多いですが、我々はfxを採用しました。
選定には決定的と呼べるほどの理由はなく、強いて挙げるとwireは依存性を解決するコードを自動生成しますが、fxは明示的に依存性を解決する実装をするという点です。
wireのメリットとしてコンパイル時に依存性の確からしさを検証出来ますが、実際ローカルで一度も稼働確認をしないケースは有り得ないので、fxで特段困る事はないと考えました。
また、直近のコミットログ量やリリース頻度(wireのリリース、fxのリリース)を見ると、wireはここ最近停滞気味でfxの方が活発に活動していそうというのも理由です。
fxの使い方
導入は他のライブラリ同様 go get go.uber.org/fx
で完了です。(ただしgo modules前提)
fxの公式ドキュメントはこちらですが、利用方法とサンプルコードを示します。
利用方法
初期化用のfx.Appを構築する
モジュールの依存関係を定義する
fx.Newの引数には、推移的な依存も含めて依存関係のモジュール定義を渡します。
モジュール定義とは、fx.Optionインタフェースを満たすもので、シグニチャは以下です。
type Option interface { fmt.Stringer apply(*module) }
モジュール定義にはコンストラクタ関数を与えるfx.Povide、起動関数を与えるfx.Invoke等を含みます (fx.Optionを満たすため)。
- fx.NewにはInvokeで得られた1つ以上の起動関数と、依存関係の解決に必要な全てのコンストラクタ関数を与えて得られたProviveの戻り値が必要
- WithLogger(https://pkg.go.dev/go.uber.org/fx#WithLogger)など、その他Optionを返す関数は type Option参照
fx.Appを実行する
実行するもっとも単純な方法はApp.Runを呼びだす事ですが、Runで事足りないケースも多いでしょう。
きめ細かい制御をする場合、fx.AppのApp.Startで起動を開始し、App.Stopで明示的に終了します。
fx.Appの起動にあたっての特徴は以下です。
- App.Startはエラーが発生した場合を除き、App.Stopが呼ばれるまで処理を終了させない
- App.Start、App.StopはLifeCycleによる起動時と終了時のフックが可能で、何らかの処理を挟み込める
- App.Runは、明示的に指定しない限りデフォルトのタイムアウト値を用い、内部的にはDone(ブロックするシグナルのチャンネル)とStartを用いたシンプルな起動処理
- Runの実装は以下の3行のみ
if code := app.run(app.Done()); code != 0 { app.exit(code) }
サンプル
fxを用いたある程度実践的な依存性を解決するコードを書いてみます。
起動と終了でロギングするようなfx.Hookも仕込んでみました。
利用方法で示した3点は、ソースコード中の「①初期化用のfx.Appを構築する」、「②モジュールの依存関係を定義する」、「③fx.Appを実行する」のコメントに対応しています。
// ユースケースのインタフェースです。 // 便宜上1ファイルで書いていますが、本体の実装が終わる前は暫定実装をDIするなどが可能です。 // 今回は、数行下のpaymentUsecaseを本インタフェースの実装してDIします。 type PaymentUsecase interface { Pay() } type paymentUsecase struct { repo AccountRepository } func (paymentUsecase) Pay() { // お金を調達して支払うユースケース実装 fmt.Println("Paid!!") } // ユースケースのファクトリ関数で、fxがPaymentUsecaseの実装を得るために必要です。 // fx.Newの際にこのファクトリ関数を与えていないと、依存性を解決できずに実行時エラーになります。 func NewPaymentUsecase(repo AccountRepository) PaymentUsecase { return &paymentUsecase{repo: repo} //Usecaseインタフェースの実装を返す } // リポジトリインタフェースです。 // モジュールが実際にDBアクセスするかどうかなどを、DIするオブジェクトで切り替えが可能です。 type AccountRepository interface { GetByNo(AccountNo) Account } // リポジトリの実装です。後述しますが、モックリポジトリに差し替えてDIするなども可能です。 type accountRepository struct{} func (repo accountRepository) GetByNo(acntNo AccountNo) Account { return Account{1} // 実装は適当 } // リポジトリのファクトリ関数で、fxがAccountRepositoryの実装を得るために必要です。 func NewAccountRepository() AccountRepository { return &accountRepository{} //AccountRepositoryインタフェースの実装を返す } func ExecUsecase(usecase PaymentUsecase) { usecase.Pay() } // 起動時にStart, 終了にStopと出力するライフサイクルフックです。 func AssignLifeCycleLogging(lc fx.Lifecycle) { hook := fx.Hook{ OnStart: func(context.Context) error { fmt.Println("Start!") return nil }, OnStop: func(context.Context) error { fmt.Println("Stop!") return nil }, } lc.Append(hook) } // goアプリケーションのエントリポイントで、ここでfx.Appの作成と実行を行います。 func main() { // ①初期化用のfx.Appを構築する app := fx.New( //②モジュールの依存関係を定義する。依存関係を定義したfx.Optionの実装を引数で与える fx.Provide( NewPaymentUsecase, NewAccountRepository, ), fx.Invoke(AssignLifeCycleLogging, ExecUsecase), ) if err := app.Err(); err != nil { log.Fatalf(err.Error()) } startCtx, cancel := context.WithTimeout(context.Background(), config.StartTimeOut) defer cancel() if err := app.Start(startCtx); err != nil { //③fx.Appを実行する log.Fatal(err) } stopCtx, cancel := context.WithTimeout(context.Background(), config.StopTimeOut) defer cancel() if err := app.Stop(stopCtx); err != nil { log.Fatal(err) } }
このように、利用するだけならば導入はさして難しくありません。
ですが、一定の規模のプロジェクト(数十名の開発者がいるイメージ)となると、他にも考えなければならない課題がいくつかあります。
逆に、ある程度の規模でない限り、DIコンテナのような複雑な仕掛けを導入するメリットは希薄です。
我々が、fx導入にあたってどのような開発運用をしたのか、次節以降でご紹介します。
プロジェクトでfxを使う
開発の規模が大きくなってくると、1つのパッケージに複数のソースコードが並び、総数数千になることも珍しくありません。
管理対象となるオブジェクトを返すファクトリ関数をどのように定義し、統治するかは重要なテーマです。
この課題を「管理対象オブジェクトとファクトリ関数の実装指針」、「モジュール定義方法」、「ビルドタグ」の3つのアプローチから解決しました。
この方向性自体は、wireと少し重なりあう部分もあります。
管理対象オブジェクトとファクトリ関数の実装指針
特別なことはなく、以下としました。
- 管理対象とするオブジェクトはインタフェースを設け、対象インタフェースを実装する構造体を作成する
- 構造体に依存するオブジェクトをフィールドとして保持する
- 構造体名はインタフェース名称の先頭を小文字としてexportせず、ファクトリ関数でのみ生成可能とする
- ファクトリ関数のシグニチャではインタフェースを宣言し、構造体の実装を返す
- 管理対象のインタフェース1つにつき、1ファイルを作成する
- モック実装が必要な場合は、インタフェースにgo:generateを付与する
サンプルの実装サンプルでは、type PaymentUsecase interface
からNewPaymentUsecase
までの以下が該当します。
type PaymentUsecase interface { Pay() } type paymentUsecase struct { repo AccountRepository } func (paymentUsecase) Pay() { fmt.Println("Paid!!") } func NewPaymentUsecase(repo AccountRepository) PaymentUsecase { return &paymentUsecase{repo: repo} }
別ファイルでgo:generateを宣言したリポジトリ実装の例を見て見ましょう。
account_repository.go
package repository //go:generate mockgen -source ./account_repository.go -destination ./account_repository_mock.generated.go -package repository type AccountRepository interface { GetByNo(AccountNo) Account } type accountRepository struct{} func (repo accountRepository) GetByNo(acntNo AccountNo) Account { // ・・・省略 }
これで、コマンドでもIDEからでもモック生成が可能です。
IDEにVS Codeを用いていれば、以下をクリックすれば生成されます。
モジュール定義方法
管理対象のオブジェクトとファクトリ関数の実装を、モジュールとしてどう管理するかが次の課題です。
provider.goというファクトリ関数を集約する実装と、ファクトリ関数とFxを結びつけるmodule.goを実装することにしました。
具体的には以下です。
- 各パッケージ毎に1つ、provider.goというファイルを設け、当該パッケージのファクトリ関数を並べる
- ファクトリ関数とFxを結びつけるため、各パッケージ毎に1つmodule.goを設ける
- module.goはfx.Provideにパッケージ内の全ファクトリ関数を渡した結果得られるfx.Optionを返す
- 得られたfx.Optionは
Module
という名称でexportする - 管理対象オブジェクトが必要な場合、利用者側が該当するパッケージの
Module
をfx.Optionsに与えて依存性を解決する
- provider.goは用途毎にビルドタグ [^2]で切り替える。
リポジトリに依存するUsecaseが複数ある例で、呼び出し側のコードと合わせて確認します。
provider.go
package usecase import ( "sample-prj/domain/repository" ) func NewPaymentUsecase(repo repository.AccountRepository) PaymentUsecase { return &paymentUsecase{repo} } func NewRcvUsecase(repo repository.AccountRepository) RcvUsecase { return &rcvUsecase{repo} }
module.go
package usecase import "go.uber.org/fx" var Module = fx.Provide(NewPaymentUsecase, NewRcvUsecase)
利用側は、fx.AppにModule
を与えて依存性を解決します。
fx.New( fx.Options( repository.Module, //リポジトリの依存性を解決するモジュール usecase.Module, //Usecaseの依存性を解決するモジュール ), fx.Invoke( ・・・省略 )
ビルドタグ
provier.go、module.goで依存性は解決できるようになりました。
ただ、これではまだ環境や状況に応じてインタフェースの実装を切り替えることは出来ません。
そこで、ビルドタグを用います。
ビルドタグとはGo tool によって提供されるビルド制約システムです。
コードの先頭に //go:build [タグ名]
を付与することで、ビルド対象ファイルを指定します。
go build
のtagsオプションで複数個のタグ名が指定可能で、該当するタグ名のgoファイルのみビルド対象となります。
これを踏まえ、provider.goと組み合わせたビルド戦術は以下です。
- provider.goに用途毎のビルドタグを付与する。
モジュール定義方法のサンプルコードに、utビルドタグとその他タグを付与したprovider.goの実装は以下です。
なお、モック実装には gomockを利用しています。
provider_ut.go
//go:build ut package usecase import ( "github.com/golang/mock/gomock" ) func NewPaymentUsecase(ctrl *gomock.Controller) PaymentUsecase { return NewMockPaymentUsecase(ctrl) } func NewRcvUsecase(ctrl *gomock.Controller) RcvUsecase { return NewMockRcvUsecase(ctrl) }
provider.go
//go:build it || stg package usecase import ( "sample-prj/domain/repository" ) func NewPaymentUsecase(repo repository.AccountRepository) PaymentUsecase { return &paymentUsecase{repo} } func NewRcvUsecase(repo repository.AccountRepository) RcvUsecase { return &rcvUsecase{repo} }
リポジトリに依存するUsecaseが複数あるケースで、provider_ut.goとprovider.goで実装を切り替えます。
単体テスト (ut) 時は自動生成したUsecaseのモック実装が用いられ、内部結合テスト以降は正規の実装が用いられます。
Module.goや呼び出し側のコードに変更はありません。
その他の便利機能
他にfxで積極的に使用している機能はfx.Inです。
依存関係が増えてくると、ファクトリ関数の引数が相当数になり、コードの見通しや保守性が著しく低下します。
そういった場合、依存関係のオブジェクトを全て定義した構造体を作成してInjectionします。
この構造体をパラメータ構造体と呼びます。
依存関係が増えた場合でもパラメータ構造体にフィールドを追加するだけで済み、かなり強力な機能です。
サンプルコードで違いを確認します。
SetlUsecaseという、複数のオブジェクトに依存するユースケースを例に、モジュール定義と生成を見てみます。
パラメータ構造体を使わない例
type SetlUsecase interface { Settle() } type setlUsecase struct { config *Config repoAccount AccountRepository repoSetl SettlementRepository repoCode CodeMasterRepository cal *Calendar setlExecutor SetlExecutor priorityJudger PriorityJudger } func NewSetlUsecase(config *Config, repoAccount AccountRepository, repoSetl SettlementRepository, repoCode CodeMasterRepository, cal *Calendar, setlExecutor SetlExecutor, priorityJudger PriorityJudger) SetlUsecase { return &setlUsecase{ config: config, repoAccount: repoAccount, repoSetl: repoSetl, repoCode: repoCode, cal: cal, setlExecutor: setlExecutor, priorityJudger: priorityJudger, } }
この程度ならそこまででもないですが、実際の開発ではもっと多くのオブジェクトに依存するケースもあります。
これ以上ファクトリ関数の引数が増えるのは辛いでしょう。
パラメータ構造体で置き換えたコードを見てみましょう。
パラメータ構造体で実装した例
// SetlUsecaseとsetlUsecaseは「パラメータ構造体を使わない例」と同様のため省略 type UsecaseParams struct { fx.In Config *Config RepoAccount AccountRepository RepoSetl SettlementRepository RepoCode CodeMasterRepository Cal *Calendar SetlExecutor SetlExecutor PriorityJudger PriorityJudger } func NewSetlUsecase(params UsecaseParams) SetlUsecase { return &setlUsecase{ config: params.Config, repoAccount: params.RepoAccount, repoSetl: params.RepoSetl, repoCode: params.RepoCode, cal: params.Cal, setlExecutor: params.SetlExecutor, priorityJudger: params.PriorityJudger, } }
NewSetlUsecaseがすっきりしました。
ただし、パラメータ構造体を使う上で、以下の2点はご注意ください。
- パラメータ構造体のフィールドはexportしなければならない
- ファクトリ関数に与えるパラメータ構造体は、ポインタで渡すことが出来ないので値として渡す
上記1, 2に違反する場合、実行時fxがエラーを出力します。
- エラー出力例:
cannot depend on a pointer to a parameter object, use a value instead: *main.UsecaseParams is a pointer to a struct that embeds dig.In
これで、setlUsecaseに依存するオブジェクトが増えたとしても、コンストラクタ関数に引数を追加せず、パラメータ構造体への追加で済みます。
まとめ
今回はgo開発でDIを導入したお話でした。
当初の目的は全て達成できており、大きなハマりもなく快適に開発出来ています。
オブジェクトの生成処理を一元的に管理する意義は大きく、稼働確認を一発すれば初期化に伴う実装ミスはほぼなくなりました。
また副次的な効果として、モジュール間の依存関係を意識することで、インタフェースが洗練された印象があります。
結果、様々な言語のDIコンテナが目指すところである、インターフェースと実装を分離して抽象度を高め、変更容易性を向上するという点を達成できました。
go開発でDI導入を検討中なら、uber-go/fxを選択肢の一つとしてお考えになってはいかがでしょうか。
執筆:@mizuno.kazuhiro、レビュー:@sato.taichi (Shodoで執筆されました)