every Tech Blog

株式会社エブリーのTech Blogです。

DI toolkit samber/do の紹介

はじめに

この記事は every Tech Blog Advent Calendar 2023 の 6 日目です。

今回は「DI toolkit samber/do の紹介」と題しまして、samber/do のざっくりとした紹介と、今後リリースされるであろう次期バージョンでの変更点についてまとめていきます。

samber/do とは

samber/do は Go で DI を実現するモジュールのひとつです。

同様の領域ではgoogle/wire が一番メジャーでしょうか。 その次にuber-go/fx が使われている印象です。

google/wire は現在はメンテナンスモードで、継続的な開発は行われていません。 uber-go/fx は reflection を利用した高度な機能を提供しているほか、単なるDIツールではなく、アプリケーションフレームワークとしての性格も兼ね備えている点が特徴です。

今回紹介する samber/do は reflection も code generation も用いず、シンプルに依存性の解決のみに注力している点が特徴的なモジュールです。

samber/do の DI

以下、公式の Quick start より転載です。

import (
    "github.com/samber/do"
)

func main() {
    injector := do.New()

    // provides CarService
    do.Provide(injector, NewCarService)

    // provides EngineService
    do.Provide(injector, NewEngineService)

    car := do.MustInvoke[*CarService](injector)
    car.Start()
    // prints "car starting"

    do.HealthCheck[EngineService](injector)
    // returns "engine broken"

    // injector.ShutdownOnSIGTERM()    // will block until receiving sigterm signal
    injector.Shutdown()
    // prints "car stopped"
}
type EngineService interface{}

func NewEngineService(i *do.Injector) (EngineService, error) {
    return &engineServiceImplem{}, nil
}

type engineServiceImplem struct {}

// [Optional] Implements do.Healthcheckable.
func (c *engineServiceImplem) HealthCheck() error {
    return fmt.Errorf("engine broken")
}

func NewCarService(i *do.Injector) (*CarService, error) {
    engine := do.MustInvoke[EngineService](i)
    car := CarService{Engine: engine}
    return &car, nil
}

type CarService struct {
    Engine EngineService
}

func (c *CarService) Start() {
    println("car starting")
}

// [Optional] Implements do.Shutdownable.
func (c *CarService) Shutdown() error {
    println("car stopped")
    return nil
}

基本的に do.Provide[T any](*do.Injector, func(*do.Injector) (T, error)) で生成関数を登録し、do.Invoke[T any](*do.Injector) で値を取得するという流れです。

uber-go/fx のように生成関数の引数から reflection で自動的に解釈して依存ツリーを作ってくれるような仕組みはないため、利用側で samber/do に依存した生成関数を定義する必要があります。

samber/do のメリット、デメリット

samber/do は非常に小規模なモジュールで、コア機能は依存性を登録することと依存性を解決することのみです。 それゆえに取り回しが非常にしやすく、例えばあるタイミングで依存性ツリーを再構築したい(実例として、Config のホットリロードなど)、と言うようなことも自分でアプリケーションを作り込めば無理なく実現可能です。

しかし、samber/do に依存する生成関数を定義しなければならない点はやや取り回しは悪いと感じるかもしれません。 また、依存性ツリーの構築はコンパイル時ではなく実行時に動的に行われるため、構築されたツリーに瑕疵がないか(登録が不十分で依存性の解決ができない)は利用者が担保する必要があります。 私も実際にテストを書いていて、うっかり生成関数の登録を忘れていて依存性解決の際にエラーになるということが稀にありました。

この辺りをDIツール側で担保してほしいというニーズが強い場合、google/wire や uber-go/fx の方が合っているかもしれません。

私が開発に関わっているプロジェクトでは、今の所規模も小さいこともあってこのようなトラブルは発生していませんが、いずれ向き合わなければならない問題だと認識しております。 近い将来に google/wire と似たようなアプローチで samber/do 向けの依存ツリーを静的に生成するモジュールを作ろうかと考えているところです。

samber/do@v2

samber/do は非常に新しいモジュールです。一般的に、新しいモジュールを導入する場合、それらがどのようにメンテナンスされているかを把握しておくことが重要です。

ところで私は最近 samber/do に次のメジャーバージョン v2 の計画があることに気づきました。

まだ計画段階ですが、いくつかこれが欲しかったんだ!と言う機能が盛り込まれる予定ですので、いくつかピックアップして紹介します。

Scope

v2 における目玉となる機能です。 依存ツリーを一つのまとまり(Scope)として、Scope間のツリー構造を構築し、依存性解決の際にScopeツリーを辿りながら値を取得することができるようになります。

これだけ聞くとなんのこっちゃ、と思うかもしれませんが、Java/Spring Boot に慣れている方であれば @ApplicationScoped@SessionScoped@RequestScoped のように生存時間が異なるオブジェクトを一つの依存性ツリーでまとめて管理できるようになると言えばイメージできるかもしれません。

Java/Spring Boot を触っていない人は全くわからない話で申し訳ありません。実際のサンプルコードがあるので、そちらを読むと良いかもしれません。

依存ツリーの明確化

これまでは do.Injector のフィールドにサービスを単純にmapで保持していたのですが、依存ツリーをDAG(有向非巡回グラフ)として明確に保持するように変わります。 これによって実行時にサービス間の依存関係を取得できるようになるため、DI部分で何かトラブルがあった際に問題を特定することが容易になります

循環参照の検出

依存性解決の際に循環参照を検出し、エラーにすることができるようになります。

Transient services

依存性解決のたびに毎回生成関数を呼び出して新たなオブジェクトを生成するサービスを登録することができるようになります。 現時点では以下のように引数なしの関数をサービスとして登録し、依存性解決後に自分で関数を呼び出してオブジェクトを取得する工夫が必要です。

import (
    "time"

    "github.com/samber/do"
)

func main() {
    injector := do.New()

    do.ProvideNamed(injector, "nowFunc", func(_ *do.Injector) (func() time.Time, error) { 
      return func() time.Time { return time.Now() }, nil
    })
    nowFunc := do.MustInvokeNamed[func() time.Time](injector, "nowFunc")
    now := nowFunc()
    println(now.Format(time.RFC3339))
}

tag ベースでの依存性注入の自動化

これはまだ v2 に入るかは不透明ですが、生成関数を毎回自分で書くのはそれなりに面倒なため、uber-go/fx のように reflection を使って自動的に依存性注入を行うヘルパー関数が提供される予定です。

おわりに

今回は samber/do という Go で DI を実現するモジュールについて紹介をしました。

Go は他の言語に比べて DI が採用されるケースが少ない印象です。もちろんDIは銀の弾丸でも、唯一の答えでもなく、採用するかどうかはプロジェクトごとに判断が必要です。

あくまで個人的な意見ですが、今回紹介した samber/do は比較的 Go の思想に寄り添った形で無理なく DI を導入できるバランスのいいモジュールだと思います。

今回の記事がみなさんの参考になれば幸いです!