🐈

GoのDIライブラリのuber-go/digを導入した話

2024/03/26に公開

はじめに

CastingONEでバックエンドエンジニアをやっている永田と申します。
本日は、最近導入したDIライブラリのuber-go/digについてお話ししようと思います。

DIライブラリを導入する動機

CastingONEのバックエンドでは、Dependency Injection自体はすでに導入されていたのですが、実装上以下の問題がありました。

  • 多くの依存関係を持つ構造体の初期化のコードが冗長になる。
  • 構造体に依存関係を追加したときに、利用側で構造体を初期化するコードを修正し忘れる。

一点目はそのままですが、二点目について補足をします。例えば、以下のようなUserUseCase構造体があったとします。(サンプルコードではimport文を省略しています。)

user_usecase.go
package application

type UserUseCase struct {
    UserRepo    domain.UserReporitory
}

func (uc *UserUseCase) Create(ctx context.Context, user *input.User) error {
    if err := uc.UserRepo.Create(ctx, user); err != nil {
        return err
    }

    return nil
}

そして、ユーザーの永続化に失敗した場合に何らかのサービスにログを送るようにする仕様変更があったとします。

user_usecase.go
package application

type UserUseCase struct {
    UserRepo    domain.UserReporitory
    Logger      domain.Logger // 追加した
}

func (uc *UserUseCase) Create(ctx context.Context, user *input.User) error {
    if err := uc.UserRepo.Create(ctx, user); err != nil {
        u.Logger.LogRegistrationFailure(ctx, user) // 追加した
        return err
    }

    return nil
}

このとき、UserUceCaseに依存するUserHandlerの、初期化のコードを修正するのを忘れてしまいました!

user_handler.go
package api

type UserHandler struct {
    UseCase    *application.UserUseCase
}

func NewUserHandler() *UserHandler {
    return &UserHandler{
        UserUseCase: &application.UserUseCase{
            UserRepo: postgres.NewUserRepository()
            // ここでロガーを渡さなければいけないが、忘れてしまった!
        }
    }
}

これではユーザーの永続化に失敗する経路において、nilポインターエラーが発生してしまいます。 しかし、実装時にもQAテスト時にも、このミスに気づくことはできませんでした。 以下の理由があるからです。

  • コンパイルは通ってしまう。
  • ユーザーの永続化に失敗するのはDBとの接続エラー等の特殊な場合のみで、インテグレーションテストでも、E2Eテストでも確認していない。(できない。)

これらの問題を何とかしたいなあということでDIライブラリの導入を検討し、結果的にdigを導入しました。

技術選定

技術選定は以下の記事を参考にさせて頂き、下記の流れで進めました。

https://qiita.com/s-ota/items/584be39c7611c0d48b0c

  • awesome-goに載っていて比較的ポピュラーであり、日本語の記事も多いgoogle/wireとuber-go/digを候補とする。(ちなみにuber-go/fxはwebフレームワークであり、DIに関する部分を切り出したものがuber-go/digになります。)

  • 両ライブラリのメンテナンス状況を確認する。(wireは機能の追加は停止しているがバグ修正等はしている状況で、digは活発でした。どちらも問題なさそうです。)

  • 既存のAPI実装にライブラリを導入してみて、それぞれの使用感の把握と比較をする。

実装

冒頭のUserUseCase, UserHandlerの例に、それぞれのライブラリを導入するとどうなるのか見ていきましょう。

前提としてどちらのライブラリを導入する場合も、基本的には自動でDIを行いたい構造体、及びその依存関係の構造体にコンストラクタを用意する必要があります。それらの実装については割愛します。

wire

wireの場合、まず以下のようなwire.goファイルを用意します。これはwireに対して、ある構造体のインスタンスを生成するのにどのコンストラクタを呼び出す必要があるかを教えるためのものです。

wire.go
//go:build wireinject
package api

func InitializeUserHandler() (*UserHandler, error) {
    wire.Build(
        postgres.NewUserRepository,
        datadog.NewLogger,
        application.NewUserUseCase,
        NewUserHandler,
        wire.Bind(new(domain.UserRepository), new(postgres.UserRepository)) // インターフェースと実装のバインド
        wire.Bind(new(domain.Logger), new(datadog.Logger))
    )

    return &UserHandler{}, nil // 関数のシグネチャを満たすためだけの記述
}

その後、wire.goがあるディレクトリでwireコマンドを実行するとwire_gen.goが生成され、実際に依存関係を全て含んだ構造体を生成できる同名のメソッドが実装されています。

wire_gen.goの内容は割愛しますが、wire.Build()に渡されたコンストラクタをどの順でどう呼び出せばいいかを的確に判断し、実装が生成されます。実際の処理では、そちらの方の関数を利用することができます。

また、wireに何らかのコンストラクタを渡し忘れていると依存関係が不完全なためにwireコマンドがエラーになってくれるので、そういったミスも防げます。

dig

digでは以下のようなファイルを作り、digのDIコンテナに依存関係として利用したい構造体のコンストラクタを登録します。

provider.go
package api

var container *dig.Container

type provideArg struct {
    constructor interface{}
    opts        []dig.ProvideOption
}

func ProvideDependencies() {
    container = dig.New()
    
    args := []provideArg{
        {postgres.NewUserRepository, []dig.ProvideOption{dig.As(new(domain.UserRepository))}},
        {datadog.NewLogger, []dig.ProvideOption{dig.As(new(domain.UserRepository))}},
        {application.NewUserUseCase, nil},
        {NewUserHandler, nil}
    }

    for _, arg := range args {
        if err := container.Provide(arg.constructor, arg.opts...); err != nil {
            panic(err)
        }
    }
}

上記のProvideDependencies()をランタイムで一度だけ呼び出すようにしておくと、以下のような形でDIコンテナに登録済みの構造体を取得できます。

また、DIコンテナに何らかの必要なコンストラクタを渡し忘れているとProvideDependencies()がpanicになるので、必ず気づくことができます。

router.go
package api

func RegisterHandlers(e *echo.Echo) {
    ProvideDependencies()

    var userH *UserHanlder
    if err := container.Invoke(func(handler *UserHanlder) { userH = handler }); err != nil {
        panic(err)
    }

    e.POST("/users", userH.Create)
}

比較、結論

元々解決したかった「構造体初期化のコードの冗長さ」「依存関係の渡し忘れによるランタイムのnilポインター」という問題に、それぞれのライブラリがどの程度効果がありそうかまとめてみました。

コードの簡潔さ nilポインター防止
wire ◯(wireコマンド実行時に気付ける)
dig ◯(ランタイムで必ず気付ける)

結論、nilポインター防止についてはどちらも同じくらい効果がありそうですが、コードの簡潔さでdigに軍配が上がるためdigを採用しました。

wireの場合、依存関係のグラフごとに依存関係のコンストラクタをwire.Build()に毎回渡さなくてはなりません。例えばOrderHandlerというハンドラーがあって、その依存関係でもUserRepositoryを使っているとします。その場合はInitializeUserHandler()と同様にInitializeOderHandler()を用意して、そこでもまたUserRepositoryのコンストラクタを渡さなければなりません。

一方digの場合、一度dig.ContainerProvide()された構造体は、シングルトンインスタンスとして全ての依存関係のグラフで使いまわされます。つまり、OrderHandlerを実装する際に再度UserRepositoryのコンストラクタを登録する必要がないのです。この差は、大規模なアプリケーションではかなり効いてくるかなと思います。

依存関係の構造体をシングルトンで使いまわしたくない事情があったり、パフォーマンスにとてもシビアでランタイムで依存関係のDIコンテナへの登録処理をやりたくない場合等にwireが優先されるのかなと個人的には思いました。(CastingONEではどちらも当てはまらず。)

また、ライブラリの導入にハードルとなる要件がないかも考えましたが、wireもdigも人気なだけあって大抵のユースケースには対応していました。例えば、一つのインターフェースに複数の実装が存在する、構造体のコンストラクタに異なるパラメーターを渡すことがある、等々。詳しくは割愛しますが、よほどピーキーなことをやっていない限りライブラリ導入の妨げにはならないかなと思います。

終わりに

弊社ではいっしょに働いてくれるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、ご気軽にご連絡ください!

https://www.wantedly.com/projects/1130967

https://www.wantedly.com/projects/1063903

Discussion