Go の DI コンテナ do を活用して Dependency Injection を実現する
Awarefy はバックエンドアプリの開発言語として Go を採用しています。また、フロントエンドアプリやモバイルアプリも含めたソフトウェアアーキテクチャとしてオニオン・アーキテクチャを取り入れています。
本記事ではオニオン・アーキテクチャ自体については論じず、Go の実装に話題を進めます。
Go でオニオン・アーキテクチャを実現する (DI コンテナのない世界)
Go 言語でオニオン・アーキテクチャを実現するためには interface を活用します。
ごく単純なリポジトリとユースケースの例をあげます。
まずはリポジトリのインターフェースを定義します。
pacakge repository
// DocumentRepository represents an interface of document repository.
type DocumentRepository interface {
FindByID(context.Context, string) (*Document, error)
}
定義したインターフェースを満たす実装を行います。
pacakge psqlrepo
type psqlDocumentRepository strcut {}
func NewDocumentRepository() DocumentRepository {
&psqlDocumentRepository{}
}
// FindByID finds a document by id.
func (r psqlDocumentRepository) FindByID(ctx context.Context, id string) (*Document, error) {
// 実装
}
ユースケースレイヤについても同様に、インターフェースと実装を分けて行います。
package usecase
// import は省略
// DocumentUseCase represents an interface of document use case.
type DocumentUseCase interface {
GetDocument(context.Context string) (*Document, error)
}
type documentUseCase struct {
repository DocumentRepository
}
func NewDocumentUseCase(repository DocumentRepository) DocumentUseCase {
return &documentUseCase{
repository: repository,
}
}
// GetDocument gets a document.
func (u documentUseCase) GetDocument(ctx context.Context id string) (*Document, error) {
// 実装
}
プレゼンテーション層などはすべて省略するとして、ユースケースを利用するためのコードは次のようになります。
pacakge main
// import 文は省略
func main() {
documentRepository := repository.NewDocumentRepository()
// DocumentUseCase の初期化に DocumentRepository が必要
documentUseCase := usecase.NewDocumentUseCase(documentRepocitory)
doc, err := documentUseCase.GetDocument(context.Background(), "id_0001")
if err != nil {
fmt.Errorf("failed to get the doc: %w", err)
}
fmt.Println(doc)
}
DI の必要性として引き合いに出されるのが、依存オブジェクトを代入する処理の部分です。
documentUseCase := usecase.NewDocumentUseCase(documentRepocitory)
NewDocumentUseCase()
の例では、依存するオブジェクトが DocumentRepository
1つであるためそこまで煩雑性は感じませんが、複数のクラス(Struct)に依存する場合、引数が次のようになっていきます。
usecase.NewDocumentUseCase(
documentRepocitory,
otherRepository,
otherService,
// ....
)
また、実際のアプリでは、Controller層のクラス(Struct)は UseCase に依存し... と更にレイヤーがあります。
前提として、多くのクラスに依存するクラスの存在自体の善し悪しがあると思います。また、型があるために引数が多少多くなったとしても実装上の問題はない、と考えもあると思います。しかしながら、一定規模のアプリになると、初期化のコードが膨らんでいき、なんとか整理をしたいというニーズが生まれることがあります(ありました)。
こうしたときに活用できるのが DI ライブラリです。
samber/do を使い DI コンテナを活用する
Go の DI 関係パッケージはいくつか選択肢があるのですが、Awarefy では samber/do
を採用しました。
samber/do
は Lodash 的なヘルパーを Go で実装した samber/lo と同じ作者によるパッケージです。
samber/lo
も、Go 1.18 から導入されたジェネリクスの機能を前提としており、samber/do
も同様です。
A dependency injection toolkit based on Go 1.18+ Generics.
リポジトリの実装 psqlrepo
パッケージの初期化のコードが次のようになります。
pacakge psqlrepo
import (
"github.com/samber/do"
)
func NewDocumentRepository(i *do.Injector) (DocumentRepository, error) {
// ここではまだ `i *do.Injector` は利用していないが、代入するために必要
&psqlDocumentRepository{}, nil
}
ユースケースの初期化コードは次のようになります。
pacakge psqlrepo
import (
"github.com/samber/do"
)
func NewDocumentUseCase(i *do.Injector) (DocumentUseCase, error) {
// リポジトリを引数で受け取るのをやめ、代わりに `do` から取り出す
repository := do.MustInvoke[DocumentRepository](i)
return &documentUseCase{
repository: repository,
}, nil
}
セットアップのコードは次のようになります。
pacakge main
// import 文は省略
func main() {
injector := do.New()
// DI コンテナにリポジトリを登録
do.Provide(injector, repository.NewDocumentRepository)
// DI コンテナにユースケースを登録
do.Provide(injector, repository.usecase.NewDocumentUseCase)
// MustInvoke でユースケースを取り出す
// 依存関係が解決された状態で `documentUseCase` が得られる
documentUseCase := do.MustInvoke[DocumentUseCase](injector)
doc, err := documentUseCase.GetDocument(context.Background(), "id_0001")
if err != nil {
fmt.Errorf("failed to get the doc: %w", err)
}
fmt.Println(doc)
}
サンプルコードのレベルだと差分は大きくありませんが、実用レベルのアプリケーションの場合、テストコードでも各オブジェクトを代入して利用することになるので、見通しの良さの高まりを実感できると思います。
まとめ
samber/do
は Go のジェネリクスを使い、シンプルなコードで DI、DI コンテナの機能を利用できるようになるパッケージです。型安全であること、コード生成が必要ないことから、開発コスト、学習コストを低く抑えたまま、コードベースをシンプルに保つことができます。
ほかの優れた方法やパッケージがあれば、ぜひ教えてください。
付録
その他の DI 関連パッケージ
wire
はコード生成により DI コンテナの機能を実現しています。
di
は reflect を活用して DI コンテナの機能を実現しています。
Discussion