トップ ブログ GoのDependency Injectionライブラリuber-go/dig, google/wireを比較する

GoのDependency Injection
ライブラリuber-go/dig, google/wireを比較する

2024/12/13

本記事は、NRIエンジニアによって2022年12月24日にQiitaに投稿された記事です。

はじめに

普段Java、Spring Frameworkを使って開発していると、Goで開発する場合の依存解決には苦労することがあるように思います。

Dependency Injection(以下DI)をやるとして、階層の深い依存関係のある構造体を初期化しようとすると、あれもこれもとコンストラクタを呼ぶことになります。また、往々にしてそういった構造体は1つではないので、どでかい初期化処理が生まれることになります。

そこで、本記事では、GoにいくつかあるDependency Injectionをサポートするライブラリについて調べていきます。

Goの依存解決の課題は何か

作成するアプリケーションや、開発体制が大きくなればなるほど、以下のような課題が出てきます。

  1. コンストラクタをたくさん呼び出して、やっとこさ初期化できるインスタンス
  2. 初期化の定義が大集合しがち。複数人が同じファイルをあれやこれやと編集することになる。
  3. 構造体が実装しているインタフェースを明示的に記載しないので、後から読むと困る場合が多い。

DIライブラリを調べる

2022/12/23時点でawesome-goに記載のあるDIライブラリは以下の通りです。

ライブラリ fork star License

magic003/alice

4

51

MIT License

goava/di

8

168

MIT License

uber-go/dig

187

2.9k

MIT License

i-love-flamingo/dingo

9

153

MIT License

samber/do

23

727

MIT License

uber-go/fx

229

3.3k

MIT License

vardius/gocontainer

2

18

MIT License

goioc/di

12

227

MIT License

golobby/container

28

406

MIT License

google/wire

533

9.7k

Apache-2.0 license

HnH/di

4

6

MIT License

go-kata/kinit

1

9

MIT License

logrange/linker

6

36

Apache-2.0 license

muir/nject

1

23

MIT License

Fs02/wire

8

37

MIT License

最もstar数の多いgoogle/wireで、9.7kということで.あまり、あまりGoの世界ではDIライブラリの利用は浸透していない印象を受けます。
手続き型言語であるGoで依存関係を定義する煩雑さを厭うのはおかしいのかもしれませんが、気を取り直して本記事では、star数の多いuber-go/diggoogle/wireについてみていくこととします。(uber-go/fxuber-go/digをベースにしたアプリケーションフレームワークなので割愛)

ライブラリ概要

uber-go/dig

DIコンテナを提供するライブラリ。
コンストラクタをコンテナに登録することで、必要な型のインスタンスを提供する機能を持つ。
必要なそれぞれのインスタンスを1度だけ作成して使いまわす。

google/wire

依存性を注入するコードの生成ツール。
コンストラクタを設定することで、必要な型のインスタンスを返却する関数を生成する機能を持つ。
初期化したインスタンスを管理するような機能はない。

従って、モックを注入して扱うなどする場合は工夫が必要。
GoのDIライブラリ google/wire でモックを使う場合のベストプラクティス」が参考になりました。

機能を比較する

双方のドキュメントを確認して機能的な差分をまとめてみました。

項目 uber-go/dig google/wire

DIの手段

コンストラクタ

コンストラクタ

インスタンスの提供方法

DIコンテナ

生成した関数

提供インスタンスのスコープ

singleton

prototype

依存解決のタイミング

実行時

コード生成時

依存グラフ

暗黙的

明示的

interfaceを実装する構造体の解決

明示的に登録

明示的に登録

interface型インスタンスを返却できる

可能

可能

struct型インスタンスを返却できる

可能

可能

登録するコンストラクタに
複数の引数、戻り値を指定できる

可能

可能

インスタンス取得時に
コンストラクタの引数の値を指定できる

不可
コンストラクタの登録時に値をセットしてやる必要がある

可能
生成した関数の引数で指定できる

同一の型を返却するコンストラクタを同時に登録できる・使い分けられる

可能
ラベルを与えることで同一の型を使い分ける

不可
型を分ける。関数生成を分割するなどの対応が必要

依存上不要なコンストラクタの
パッケージの扱い

コンテナに登録したパッケージは依存グラフの登場有無に関わらずビルドに含める

インスタンスに必要なパッケージのみに依存した関数を使う

できないことは、DIに対するアプローチによって不要と判断している機能と判断できそうですが、どちらのライブラリにおいても特色がありそうです。次からは、実装を確認して特徴をつかんでいきます。

実装を比較する

google/wireのサンプルを参考に下記に示すような構成において、Eventインタフェース型でインスタンスを受け取り、Startメソッドの実行を目指します。

シンプルで依存関係も複雑ではないですが、ポイントは以下の通りです。

  • EventインタフェースをGreeterEventが実装しています。
  • GreeterEventGreeterインタフェースに依存しています。
  • GreeterインタフェースをEnglishGreeterが実装しています。
  • Greeterは作成されたタイミングでたまに不機嫌(Grumpy)です。不機嫌である場合、GreeterEventのコンストラクタ処理を失敗させます。
  • MessageString型をラップしています。

※UML図はkazukousen/goumlにて生成しています。

折りたたんでいますが、
UML図の実装はこちら
import (
  "errors"
  "fmt"
  "os"
  "time"
)

func NewMessage(phrase string) Message {
  return Message(phrase)
}

type Message string

type Greeter interface {
  Greet() Message
  Grumpy() bool
}

func NewEnglishGreeter(m Message) EnglishGreeter {
  return EnglishGreeter{Message: m}
}

type EnglishGreeter struct {
  Message Message
}

func (g EnglishGreeter) Greet() Message {
  return g.Message
}

func (g EnglishGreeter) Grumpy() bool {
  var grumpy bool
  if time.Now().Unix()%5 == 0 {
    grumpy = true
  }
  return grumpy
}

type Event interface {
  Start()
}

func NewGreeterEvent(g Greeter) (GreeterEvent, error) {
  if g.Grumpy() {
    return GreeterEvent{}, errors.New("could not create event: event greeter is grumpy")
  }
  return GreeterEvent{Greeter: g}, nil
}

type GreeterEvent struct {
  Greeter Greeter
}

func (e GreeterEvent) Start() {
  msg := e.Greeter.Greet()
  fmt.Println(msg)
}  

DIライブラリを使わずにEventを取得する

依存関係も少ないのであまり複雑な感じはしないですが、eventインスタンスを初期化するまでにいくつかのコンストラクタを呼んで準備していることがわかります。こうした定義がアプリケーションの大規模化とともに大きくなっていくと煩わしいということですね。

func main() {
	var event Event
	message := NewMessage("Hi there")
	greeter := NewEnglishGreeter(message)
	event, err := NewGreeterEvent(greeter)
	if err != nil {
		fmt.Printf("failed to create event: %s\n", err)
		os.Exit(2)
	}
	event.Start()
}

uber-go/dig

uber-go/digでのインスタンスを取得するまでの手続きは以下の通りです。

  1. dig.Newメソッドでコンテナを作成する。
  2. dig.Provideで取得したいインスタンスが依存する型のコンストラクタを登録する。
    オプションで実装しているインタフェースを設定する。
  3. c.Invokeで取得したいインスタンスを扱う関数を実行する。
func main() {
  // 1. コンテナの作成
  c := dig.New()
  err := c.Provide(func() string {
    return "Hi there"
  })
  if err != nil {
    fmt.Printf("failed to provide phrase: %s\n", err)
    os.Exit(2)
  }
    // 2. コンストラクタの登録
  err = c.Provide(NewMessage)
  if err != nil {
    fmt.Printf("failed to provide message: %s\n", err)
    os.Exit(2)
  }
    // 2. オプションで実装しているインタフェースを設定
  err = c.Provide(NewEnglishGreeter, dig.As(new(Greeter))) 
  if err != nil {
    fmt.Printf("failed to provide Greeter: %s\n", err)
    os.Exit(2)
  }
  err = c.Provide(NewGreeterEvent, dig.As(new(Event)))
  if err != nil {
    fmt.Printf("failed to provide GreeterEvent: %s\n", err)
    os.Exit(2)
  }
    // 3. インスタンスを取得して関数の実行
  err = c.Invoke(func(event Event) error {
    event.Start()
    return nil
  })
  if err != nil {
    fmt.Printf("failed to invoke GreeterEvent: %s\n", err)
    os.Exit(2)
  }
}

コード上のボリュームは大きくなっていますね。ただし、十分なコンストラクタを登録していれば、型毎の関係は意識せずともインスタンスが取得できています。

作成されたインスタンスはSingletonのスコープで管理される為、Greeterが不機嫌となったなら、当該コンテナはGreeterEventのコンストラクタ処理に必ず失敗します。これは使い方が悪いですね。

可変値を与えて都度インスタンスを初期化する思想のライブラリでもない為、Messageに登録するstringについてもdig.Provideで登録しています。

「コンストラクタ処理のエラー」、「登録しているコンストラクタが不足している場合のエラー」、「インスタンスを取得して実行した関数が返却するエラー」のすべてをc.Invokeのエラーとしてハンドリングすることになっています。

google/wire

google/wireでのインスタンスを取得するまでの手続きは以下の通りです。

  1. wire.goに取得したいインスタンスを戻り値に持つ関数を定義する。
    この時、wire.Buildに登録するコンストラクタ、または、定義した関数の引数で、取得したい型の依存解決に必要な型を網羅する。
  2. wireコマンドを実行することで、wire_gen.goファイルが作成され、インスタンスを取得する為の関数が生成されます。
  3. 生成された関数を呼び出してインスタンスを取得します。

wire_gen.goの関数を利用する為、wire.goには「// +build wireinject」のビルドタグを定義し、通常ビルドには含まれないようにしておきます。

  • wire.go
    // 1. コンストラクタおよびインタフェースと実装型の関係を登録、引数の定義
    func InitializeEvent(phrase string) (Event, error) {
      wire.Build(wire.Bind(new(Event), new(GreeterEvent)), NewGreeterEvent, wire.Bind(new(Greeter), new(EnglishGreeter)), NewEnglishGreeter, NewMessage)
      return GreeterEvent{}, nil
    }
    
  • wire_gen.go
    // 2. wireコマンドによって生成される関数
    func InitializeEvent(phrase string) (Event, error) {
      message := NewMessage(phrase)
      greeter := NewGreeter(message)
      greeterEvent, err := NewGreeterEvent(greeter)
      if err != nil {
        return nil, err
      }
      return greeterEvent, nil
    }
    
  • main.go
    func main() {
      // 3. 関数を呼び出してインスタンスを取得
      event, err := InitializeEvent("Hi there")
      if err != nil {
        fmt.Printf("failed to create event: %s\n", err)
        os.Exit(2)
      }
      event.Start()
    }
    

ファイルが多くなっていますが、wire.goはビルドには含めません。登録したコンストラクタが十分であるかはwireコマンドの実行時に評価され、必要十分なimportのみが定義される為、アプリケーションの実行時には課題となりません。

生成された関数InitializeEventを実行する度にEvent型インスタンスが取得される為、Greeterが不機嫌かどうかも実行の度に変わります。取得したインスタンスのスコープは呼び出し側で管理する必要があります。また、関数の引数として提供する型は、実行時に与えることができます。

DIライブラリを使うことで課題が解決できるか

再掲ですが、課題感は以下の3点でした。

  1. コンストラクタをたくさん呼び出して、やっとこさ初期化できるインスタンス
  2. 初期化の定義が大集合しがち。複数人が同じファイルをあれやこれやと編集することになる。
  3. 構造体が実装しているインタフェースを明示的に記載しないので、後から読むと困る場合が多い。

DIライブラリを用いることで、コンストラクタを登録しておけば、依存性を注入してインスタンスを返却してくれるため、複雑な依存関係の影響を受けにくくなると感じました。
また、今回は同一ファイルや同一パッケージでコンストラクタの登録を実施していますが、それぞれリソースに紐づくパッケージでコンストラクタの登録を行うようにすれば、大規模な初期化ファイルを複数人が触る影響も局所化できると想定されます。
いずれもインタフェースを実装する構造体を指定する制約がかえって、依存セットの構成を読み取りやすくしているように感じました。
総じてDIライブラリの恩恵を得ることはでき、課題の解消ないし影響を軽減できるかなといった印象です。

所感

uber-go/dig

Singletonのインスタンスを管理するDIコンテナなので、インスタンスの生成時に引数を個別に指定できないことは特に課題にならないと推測されます。DIの仕組みとしてJava、Spring Frameworkに近いですね。

google/wireと比較して柔軟に依存関係を解決できる一方で、暗黙的に依存解決をしてインスタンスを返されるところや、「DIコンテナのエラー」「コンストラクタのエラー」「処理結果としてのエラー」が同じ場所で取得される点はGoらしくない挙動に見え、違和感がありました。

google/wire

wire.goにコンストラクタを登録して、ビルドには含めない実装であるため、設定ファイルに依存セットを外部化しているような感覚があり好ましい方式だと感じました。

機能の比較で触れている通り、同じ型を同一の依存セットに登録できないのは課題となるケースが多いように感じるが、思想的に対応の予定はない様子。依存セットを分割したり、型を分けて避けるような対応が必要。

アプローチとして手続き型言語であるGoらしさのある仕組みだと感じました。gRPCのprotoといい、こういった仕組みがGoogleのブームなのだろうか。

終わりに

しっくり来たのはgoogle/wireでした。インスタンス初期化に際しての手順が生成されるのは、手間を減らすとともにGoの文化に則って可読性の高さを提供しています。ただし、同一の型を同時に扱う場合に課題があるため、ラップ型や引数をまとめた構造体、依存関係の分割が必要になる。そうした対応で構成を複雑にさせる懸念もあり、プロジェクトで受け入れられるかは検討が必要ですね。

最後まで読んでいただきありがとうございました。

お気軽にお問い合わせください
オープンソースに関するさまざまな課題、OpenStandiaがまるごと解決します。
下記コンテンツも
あわせてご確認ください。