今さらだけど、Go言語のDIライブラリについて比較
GolangでのDIについて
まず、Golangで開発でDIについて
GolangでDependency Injection(以下DI)を行うには、一般的にコンストラクタインジェクトと言うコンストラクタで依存しているオブジェクトを引数でInjectすることで実現します。
アプリケーションが複雑化してくると、依存しているオブジェクトが数多くなったり、依存関係も複雑化していきます。
それを手動で実装していくのは手間がかかります。いずれ、人間の行うことではないという気持ちも強まります。
そこで、本記事では、今さらながら、GolangにいくつかあるDIライブラリについて見ていきます。
以下はコンストラクタインジェクションでDIをしているサンプルコードです。
もちろん、DIツール、ライブラリを導入しない場合は、手動でコンストラクタ呼び出しは記述しDIを行う必要があります。
コードが膨大になると、コンパイル->エラー->修正を地道に繰り返すことになります。
package main
import "fmt"
func main() {
dog := NewDog()
cat := NewCat()
service := NewService(dog, cat)
fmt.Println(service.GetNames())
}
type Service struct {
Dog *Dog
Cat *Cat
}
func NewService(d *Dog, c *Cat) *Service {
return &Service{
Dog: d,
Cat: c,
}
}
func (s *Service) GetNames() string {
return s.Dog.Name + " " + s.Cat.Name
}
type Dog struct {
Name string
}
func NewDog() *Dog {
return &Dog{
Name: "pochi",
}
}
type Cat struct {
Name string
}
func NewCat() *Cat {
return &Cat{
Name: "tama",
}
}
比較対象
定番: google/wire
対抗: uber-go/dig
新興勢力: samber/do
wire
2023年12月現在、最も人気のあるDIライブラリと思われます。
Googleが開発しているため、安心感があります。
wireは、go generateによるコード生成を通したDIを提供しています。
特徴としては、DIを行うコードを自動生成します。不備があれば、コード生成時に検知できます。
なので、実行時はDI部分の動作は保証されていることになります。
逆にコード生成が必要なため、実装を追加したり、変更を加えた際にコード生成を行う作業が発生します。
非常に残念なのは、メンテナンスフェーズになってしまったということです。最低限の保守はされるようですが、新機能は追加されません。
1.18で導入されたジェネリクスへの対応も行われていません。
dig
uberが開発しているDIライブラリになります。
2023年12月現在、wireについで人気のあるDIライブラリと思われます。
wireと異なり、コード生成は不要です。
特徴としては、DIコンテナ機能を提供し、依存性を解決します。
ただし、不備があった際は実行時エラーでしか検知できません。
ですので、CI等(静的解析)はチェックすることはできません。
do
Golangのジェネリクスライブラリであるloで有名なsamber氏が開発したDIライブラリです。
これも、digと同じようなアプローチのライブラリになります。
DIコンテナを提供し、コード生成は不要です。
dig同様に不備は実行時エラーでしか検知できません。
DI実装例
wire
package main
import "fmt"
func main() {
service := Init()
fmt.Println(service.GetNames())
}
// 実装部分は省略
wire定義
//go:build wireinject
// +build wireinject
package main
import "github.com/google/wire"
func Init() *Service {
wire.Build(
NewDog,
NewCat,
NewService,
)
return &Service{}
}
wire定義をもとに生成されたコード
// Code generated by Wire. DO NOT EDIT.
//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
package main
// Injectors from wire.go:
func Init() *Service {
dog := NewDog()
cat := NewCat()
service := NewService(dog, cat)
return service
}
wireによって生成されたコードを見ると、素で実装したものと同様だとわかります。
あくまで、手動で書く部分を自動生成してくれるライブラリということがわかります。
wire定義で記載したコンストラクタから良しなに依存を解決してくれているだけで、とても助かります。
サンプルコードはシンプルなので、別に手動で書いたらいいよねと思うかもしれませんが、実際のプロダクションコードとなると人間の扱うものとは思えない程の依存が発生するので導入する意義はあります。
dig
package main
import (
"fmt"
"go.uber.org/dig"
)
func main() {
c := dig.New()
if err := c.Provide(NewDog); err != nil {
panic(err)
}
if err := c.Provide(NewCat); err != nil {
panic(err)
}
if err := c.Provide(NewService); err != nil {
panic(err)
}
if err := c.Invoke(func(s *Service) {
fmt.Println(s.GetNames())
}); err != nil {
panic(err)
}
}
// 実装部分は省略
dig.New
でDIコンテナを生成し、Provide
でコンストラクタを登録していきます。
最終的に実行する部分はInvoke
で必要なインスタンスを受け取り処理を実行しています。
なので、依存性注入部分はライブラリ独自の書き方となります。
wireのようにコード生成は不要で依存性を解決してくれます。
ただし、特徴でも述べたようにコンパイル等事前に不備を検知することはできません。
do
package main
import (
"fmt"
"github.com/samber/do"
)
func main() {
injector := do.New()
do.Provide(injector, NewDog)
do.Provide(injector, NewCat)
do.Provide(injector, NewService)
service := do.MustInvoke[*Service](injector)
fmt.Println(service.GetNames())
}
type Service struct {
Dog *Dog
Cat *Cat
}
func NewService(i *do.Injector) (*Service, error) {
d := do.MustInvoke[*Dog](i)
c := do.MustInvoke[*Cat](i)
return &Service{
Dog: d,
Cat: c,
}, nil
}
func (s *Service) GetNames() string {
return s.Dog.Name + " " + s.Cat.Name
}
type Dog struct {
Name string
}
func NewDog(i *do.Injector) (*Dog, error) {
return &Dog{
Name: "pochi",
}, nil
}
type Cat struct {
Name string
}
func NewCat(i *do.Injector) (*Cat, error) {
return &Cat{
Name: "tama",
}, nil
}
do.New
でDIコンテナを生成し、Provide
、MustInvoke
依存性注入、実行はdigと大体同じ流れになります。
若干シンプルに書けるかなくらいの違いしかありません。
大きく異なるのはコンストラクタ部分です。
コンストラクは*do.Injector
が引数に与えられるものを用意する必要があります。
なので、後からDIライブラリを導入しようとするとコンストラクタを全て書き換える必要がでてきます。
依存しているオブジェクトもdo.MustInvoke[*Dog](i)
というように独自の実装が求められます。
最初からdoベースでDI実装をしていこうという場合は良いですが、後からDIライブラリを導入しようとすると障壁があるかもしれません。
ジェネリクス対応
今さらなので、1.18で導入されたジェネリクの対応についても検証したいと思います。
wireはメンテナンスモードのためジェネリクスに対応していないということだが、使う場合どのような対応が必要なのかも考えたいと思います。
dig, doの対応についても検証してみたいと思います。
ライブラリ導入なし
package main
import "fmt"
func main() {
dog := NewDog()
service := NewService(dog)
fmt.Println(service.GetName())
}
type Namable interface {
GetName() string
}
type Service[T Namable] struct {
Animal T
}
func NewService[T Namable](t T) *Service[T] {
return &Service[T]{
Animal: t,
}
}
func (s *Service[T]) GetName() string {
return s.Animal.GetName()
}
type Dog struct {
Name string
}
func NewDog() *Dog {
return &Dog{
Name: "pochi",
}
}
func (d *Dog) GetName() string {
return d.Name
}
ジェネリクス検証ように作成したコードです。ちょっと強引かもしれませんが勘弁してください。
DIしている部分はジェネリクスを使用していない場合と同様になります。
wire
package main
import "fmt"
func main() {
service := Init()
fmt.Println(service.GetName())
}
wire定義
//go:build wireinject
// +build wireinject
package main
import "github.com/google/wire"
func Init() *Service[*Dog] {
wire.Build(
NewDog,
//NewService[*Dog],
NewGenericService,
)
return &Service[*Dog]{}
}
NewService[*Dog]
と書くと、コード生成時にunknown pattern
というエラーになります。
ジェネリクスに対応していないのでしょうがないかなと思います。
解決するためにはジェネリクスではない形で、型を解決する必要があります。
型解決
package main
func NewGenericService(dog *Dog) *Service[*Dog] {
return NewService[*Dog](dog)
}
上記のwire定義で書いているようにジェネリクスを使わずに型解決するための関数を用意して、それをwireで利用するようにします。
このようにすることでジェネリクスに対応していないwireでも利用することが可能です。
ただし、ジェネリクスを多用していると、この型解決の関数を定義することが煩雑になると思われます。
dig
package main
import (
"fmt"
"go.uber.org/dig"
)
func main() {
c := dig.New()
if err := c.Provide(NewDog); err != nil {
panic(err)
}
if err := c.Provide(NewService[*Dog]); err != nil {
panic(err)
}
if err := c.Invoke(func(s *Service[*Dog]) {
fmt.Println(s.GetName())
}); err != nil {
panic(err)
}
}
// 実装部分は省略
digは普通にジェネリクスが使えました。ジェネリクスで型を指定してはいるものの、DI部分のコードは特に変わりありません。
do
package main
import (
"fmt"
"github.com/samber/do"
)
func main() {
injector := do.New()
do.Provide(injector, NewDog)
do.Provide(injector, NewService[*Dog])
service := do.MustInvoke[*Service[*Dog]](injector)
fmt.Println(service.GetName())
}
func NewService[T Namable](i *do.Injector) (*Service[T], error) {
a := do.MustInvoke[T](i)
return &Service[T]{
Animal: a,
}, nil
}
// 実装部分は省略
doも普通にジェネリクスが使えました。
コンストラクタで型指定している部分は若干ジェネリクスを意識したコードになるものの、dig同様にDI部分のコードは特に変わりありません。
まとめ
個人的にははwire推しです。
理由としてはDIが辛くなるほどのジェネリクスをそこまで多用することが現時点まだない(個人的な経験)ことと、一番の理由としてはDIの不備を実行時エラーではなく事前に検知できるというものがあります。
wireはメンテナンスフェーズになっていますが、まだまだ現役で利用する価値があると思います。
また、既に開発が進んでいる場合doのように独自の実装が必要なものをあとから導入するのは難しく感じました。これから、新規に開発を始めるという方はwireではなく、digや、doを導入しても良いかもしれません。ただ、やはり実行時エラーで不備を検知というのは懸念になるかもしれません。
個人的にはwireの後継ライブラリでジェネリクス等新機能追加を行なってくれるのが理想だと思いました。
Discussion