💭

フィールドタグだけで完結する Go の DI ツール「injector」- uber-go/dig との比較 -

に公開

フィールドタグだけで完結する Go の DI ツール injector というものを作った。

https://zenn.dev/mickamy/articles/fa36b68923c510

既存の DI ライブラリとして使われることの多い、uber-go/dig と比較してみる。
https://github.com/uber-go/dig

google/wire との比較は以下
https://zenn.dev/mickamy/articles/fa36b68923c510

uber-go/fx との比較は以下
https://zenn.dev/mickamy/articles/3165e720550de9

uber-go/dig と injector をコード量で比較してみる

Go の DI ライブラリとして uber-go/dig は、軽量で柔軟なランタイム DI コンテナとしてよく使われている。

一方 injector は、DI をランタイムではなく 静的解析 + コード生成で解決するアプローチを取る。

この記事では、

「開発者が実際に書き、維持する DI コード量」

という観点で、dig と injector を比較する。

※ 実行時の内部コード量ではなく、人間が読む・直す・レビューするコードに注目する。


想定する構成

比較をフェアにするため、これまでと同様に、よくあるバックエンド構成を想定する。

  • Config
  • Database(初期化で error が起き得る)
  • Repository
  • Service
  • Handler

依存関係は次の通り。

Config → Database → Repository → Service → Handler

dig の場合

dig はコンテナを生成し、provider を登録し、最後に Invoke で必要な依存を取り出す。

Provider 定義

func NewConfig() *Config
func NewDatabase(cfg *Config) (*Database, error)
func NewUserRepository(db *Database) *UserRepository
func NewUserService(repo *UserRepository) *UserService
func NewUserHandler(svc *UserService) *UserHandler

Container 構築と Provide

func buildContainer() (*dig.Container, error) {
	c := dig.New()

	if err := c.Provide(NewConfig); err != nil {
		return nil, err
	}
	if err := c.Provide(NewDatabase); err != nil {
		return nil, err
	}
	if err := c.Provide(NewUserRepository); err != nil {
		return nil, err
	}
	if err := c.Provide(NewUserService); err != nil {
		return nil, err
	}
	if err := c.Provide(NewUserHandler); err != nil {
		return nil, err
	}

	return c, nil
}

起動コード(Invoke)

func main() {
	c, err := buildContainer()
	if err != nil {
		log.Fatal(err)
	}

	if err := c.Invoke(func(h *UserHandler) {
		// start server
		_ = h
	}); err != nil {
		log.Fatal(err)
	}
}

dig で人間が書く DI コード

  • dig.New()
  • Provide の列挙(error handling 付き)
  • Invoke の起動点

小規模でも 30〜50 行程度は発生する。

provider が増えるほど Provide の列挙と error handling が増えていく。


injector の場合

injector は、Container に inject を付けて「露出させたい依存」を宣言するだけでよい。

Container 定義

type Container struct {
	Handler *UserHandler `inject:""`
}

生成されるコード(要点)

func NewContainer() (*Container, error) {
	cfg := NewConfig()
	db, err := NewDatabase(cfg)
	if err != nil {
		return nil, err
	}
	repo := NewUserRepository(db)
	svc := NewUserService(repo)
	h := NewUserHandler(svc)

	return &Container{Handler: h}, nil
}

Must モード(エントリポイント向け)

injector generate --must ./...
func MustNewContainer() *Container {
	c, err := NewContainer()
	if err != nil {
		panic(err)
	}
	return c
}

--on-error=fatal を指定すれば log.Fatal に切り替えられる。


コード量の比較

項目 dig injector
DI 初期化コード Provide + Invoke Container 定義のみ
provider 登録 必須(列挙) 不要
書く行数(小規模) 30〜50 行 1〜3 行
エラーの扱い 毎回明示 New / MustNew を選べる
依存解決タイミング ランタイム 静的解析 + 生成

コード量の差は、ほぼそのまま

  • Provide の列挙
  • error handling
  • Invoke の起動点

の差になる。


dig が向いているケース

  • 実行時に provider を差し替えたい
  • plugin 的な構成がある
  • コンテナを動的に組み替えたい
  • DI を「ライブラリ」として軽く使いたい

injector が向いているケース

  • 依存関係を コンパイル前に確定させたい
  • DI 記述を最小限にしたい
  • 起動失敗を build-time / generate-time で検出したい
  • Container を明示的な API として扱いたい

まとめ

dig は軽量で柔軟なランタイム DI を提供するが、

  • provider 登録の列挙
  • error handling
  • 起動コード

といった 人間が管理する DI コードが必ず発生する。

injector は、

  • Container に inject を書く
  • provider は普通の関数を書く

という最小構成で、DI の記述量がほとんど増えない。

**「配線コードを書きたくない」「型に任せたい」**なら、injector は dig に対する明確な代替になり得る。

Discussion