🐿️

[Go] Uber製DIフレームワークfxの利用法(基礎)

2024/08/19に公開

この記事について

この記事ではGoのDIフレームワークであるfxパッケージの基本的な使用法について記載します。

このパッケージはUber製で、Uberのサービスの様々な所で使われているようです。リフレクションを使うタイプでDIを実行するようです。Googleにもwireというものがあり、これはスター数が大きかったのですが、リリース自体はこちらの方が活発に見受けられます。また、fxのもっとコアな中身としてはdigというものがあるようです。

私がこのライブラリを触ったのは全くの気分で(強いて言えば私が一応最も知見のある…であろう.NETのフレームワーク群はDIを全面に押し出したような環境が多い印象があった為)、特に技術選定などに係るような深い理由は何もありません。
この記事自体が自分が学んでいる中での覚書みたいな内容ですが、大まかな基本的な使い方を書いているので、公式ドキュメントと合わせてご参考になれば幸いです。

ネットで探してみると海外リソース含めてコード例がWebを想定したパターンのものばかりだったのですが、私はそこまで詳しくないため、一般的なプログラミング(クラス表現など)のような形で示しています。

DIとは

「アプリケーションをコンテナ化し、オブジェクトが必要とする依存オブジェクトを外部から提供される仕組み」というのが私の今の理解です。
C#(.NET)のDependency Injectionパッケージで言うと、コンテナに色々なクラスの型を登録しておいて、アプリのクラスコンストラクタ引数には必要なクラスを書いておくと、それを設定するロジックを記述することなくアプリ実行時によしなに注入してくれるようなデザインパターン、と解釈しています。
このメリットとしては状況に応じた実装(オブジェクト)の差し替えやコード自体の結合度の削減などが挙げられるのではないかと考えられます。

(とはいえ、これはこれで割りかし設計プラクティスも必要なのかな、と(言語化できないのですが)ちょっと感じていたりしますが...このあたりの知見は私には少ないです。)

基本的な使い方

基本的な使い方は次の通りです。

  • fx.New()のコンストラクタ引数にfx.Optionを指定する。指定するために対応したものにはfx.Provide(), fx.Invoke(), fx.Module()などがある
  • fx.Provide()には提供するオブジェクトのファクトリ関数を指定する。生成のために必要な要素は全てコンテナに提供されなければならない
  • fx.Invoke()はアプリケーション実行前にやってほしいことを行う。呼び出しは順次的に実行される
    • これが1つもないとRunしても特に何も起こらない。その為、例えばエントリポイント的なオブジェクトを引数に取る空の関数を呼び出してあげる、みたいなことが必要
  • fx.Lifecycleを引数で呼び出すことで、fx.Hookを用いたフックを追加できる。アプリケーションをRunさせた、または終了した時に行ってほしいフックを追加する
  • fx.Modulefx.Optionをひとまとめにできる

大まかな利用するための骨組みとしては以下の通りです。

func main() {
	app := fx.New(
		// must
		fx.Provide(// something),
		fx.Decorate(),
		// must
		fx.Invoke(// something),
	)
	app.Run()
}

以下はfxの基本的な機能を大体示すHello Worldの例です。
このコードでは、Person構造体のファクトリを登録し、実行前にJhonと名前が設定されるようにし、アプリケーションが実行されたらGreet()関数を呼び出す例を示します。

記法の例
type Person struct {
	Name string
}

func NewPerson(lc fx.Lifecycle) *Person {
	person := &Person{Name: ""}
	lc.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			person.Greet()
            // ここでエラーを返すとアプリケーションが停止する
			return nil
		},
	})
	return person
}

func (p *Person) Greet() {
	fmt.Printf("Hello, %s!\n", p.Name)
}

func main() {
	app := fx.New(
		fx.Provide(
			NewPerson,
		),
		fx.Invoke(func(person *Person) {
			person.Name = "Jhon"
		}),
	)
	app.Run()
}

インターフェースを用いた注入の差し替え

次の例では関数の引数にインターフェースを指定することにより、注入されるものを設定によって変化させられることを示します。

このコードでは次のことを実行します。

  • IdGeneratorを実装する2つの構造体RandomIdGeneratorUUIDGeneratorがあります
  • IdGeneratorをファクトリの引数に取る必要があるIdCardがあります
  • fx.Annotatefx.Asを使い、注入されるオブジェクトがIdGeneratorであることをfxに伝えます
  • fx.Invokeが走った際、注入した構造体の方のインスタンスが呼ばれます

これによってRandomIdGeneratorUUIDGeneratorに差し替えたいってなったら、
コンテナの注入設定を変更すれば良い訳ですね。これがDIの魅力なのではと感じています。

インターフェースを利用する例
// IDを生成するインターフェース
type IdGenerator interface {
	Generate() string
}

// IdGeneratorを実装する構造体1
type RandomIdGenerator struct{}

func NewRandomIdGenerator() *RandomIdGenerator {
	return &RandomIdGenerator{}
}

func (r *RandomIdGenerator) Generate() string {
	s := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
	b := make([]byte, 16)
	_, err := rand.Read(b)
	if err != nil {
		return ""
	}
	for i := range b {
		b[i] = s[int(b[i])%len(s)]
	}
	return string(b)
}

// IdGeneratorを実装する構造体2
type UUIDGenerator struct{}

func NewUUIDGenerator() *UUIDGenerator {
	return &UUIDGenerator{}
}

func (u *UUIDGenerator) Generate() string {
	return uuid.New().String()
}

// generatorを使ってIDを生成する構造体
type IdCard struct {
	id string
}

func NewIdCard(generator IdGenerator) *IdCard {
	return &IdCard{id: generator.Generate()}
}

func (u *IdCard) Id() string {
	return u.id
}

func main() {
	app := fx.New(
		fx.Provide(
			NewIdCard,
			// (a)
			// fx.Annotate(
			// 	NewRandomIdGenerator,
			// 	fx.As(new(IdGenerator)),
			// ),
			// (b)
			fx.Annotate(
				NewUUIDGenerator,
				fx.As(new(IdGenerator)),
			),
		),
		fx.Invoke(func(card *IdCard) {
			fmt.Println(card.Id())
		}),
	)
	app.Run()
}

フックを利用する例

fx.Hook構造体のOnStartOnStopはアプリケーションの開始に必要な処理を記述し、例えばサーバの起動が想定されていると思います。

func NewDatabase(lc fx.Lifecycle, logger Logger) *DatabaseImpl {
	db := &DatabaseImpl{}
	lc.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			if err := db.Conn(); err != nil {
				logger.Error(err.Error())
				return err
			}
			logger.Info("connected to database")
			return nil
		},
	})
	return db
}

以下のコード例では、アプリケーションの起動時にデータベースの接続チェックをするということを仮の内容で示します。

フックを利用する例
type Logger interface {
	Info(msg string)
	Error(msg string)
}

type LoggerImpl struct{}

func (l *LoggerImpl) Info(msg string) {
	fmt.Println("INFO: " + msg)
}

func (l *LoggerImpl) Error(msg string) {
	fmt.Println("ERROR: " + msg)
}

func NewLogger() *LoggerImpl {
	return &LoggerImpl{}
}

type Database interface {
	Conn() error
}

type DatabaseImpl struct{}

func (db *DatabaseImpl) Conn() error {
	is_success := false
	if is_success {
		return nil
	}
	return fmt.Errorf("failed to connect to database")
}

func NewDatabase(lc fx.Lifecycle, logger Logger) *DatabaseImpl {
	db := &DatabaseImpl{}
	lc.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			if err := db.Conn(); err != nil {
				logger.Error(err.Error())
				return err
			}
			logger.Info("connected to database")
			return nil
		},
	})
	return db
}

func main() {
	app := fx.New(
		fx.Provide(
			fx.Annotate(
				NewLogger,
				fx.As(new(Logger)),
			),
			fx.Annotate(
				NewDatabase,
				fx.As(new(Database)),
			),
		),
		fx.Invoke(func(db Database) {}),
	)
	app.Run()
}

パラメータと結果

利用するファクトリにおいて、ある結果とどのオブジェクトを利用するかということについては、タグで表現します。この内groupというタグは特殊な意味を持っており、該当するものをスライスとして引数で呼び出せます。
これを設定するためには、fx.ResultTagsまたはfx.ParamTagsが提供されています。

以下のコードでは、MovieMusicの結果およびPlayerの呼び出しをhobbyとしてタグ付けし、実際に初期化時にMovieMusicHobbyerとして解釈されスライスとして引数に利用される例を示します。

スライスで呼ぶ例
type Hobbyer interface {
	Play()
}

type Movie struct {
	Name string
}

func NewMovie() *Movie {
	return &Movie{Name: "インセプション"}
}

func (m *Movie) Play() {
	fmt.Println("Playing movie: ", m.Name)
}

type Music struct {
	Name string
}

func NewMusic() *Music {
	return &Music{Name: "アジカン"}
}

func (m *Music) Play() {
	fmt.Println("Playing music: ", m.Name)
}

type Player struct {
	Hobbies []Hobbyer
}

func NewPlayer(hobbies []Hobbyer) *Player {
	return &Player{Hobbies: hobbies}
}

func (p *Player) Play() {
	for _, h := range p.Hobbies {
		h.Play()
	}
}

func main() {
	app := fx.New(
		fx.Provide(
			fx.Annotate(
				NewMovie,
				fx.As(new(Hobbyer)),
				fx.ResultTags(`group:"hobby"`),
			),
			fx.Annotate(
				NewMusic,
				fx.As(new(Hobbyer)),
				fx.ResultTags(`group:"hobby"`),
			),
			fx.Annotate(
				NewPlayer,
				fx.ParamTags(`group:"hobby"`),
			),
		),
		fx.Invoke(func(p *Player) {
			p.Play()
		}),
	)
	app.Run()
}

以下のコードは、どのオブジェクトを呼び出したいかを指定する方法を示しています。上のコードと大体同じですが、スライスではなく単独のHobbyerインターフェースを引数にしています。ここでは、nameとしてそれぞれmoviemusicがタグ付けされており、実際にはmovieのみが呼び出される例を示します。

Movieを呼び出す例
type Hobbyer interface {
	Play()
}

type Movie struct {
	Name string
}

func NewMovie() *Movie {
	return &Movie{Name: "インセプション"}
}

func (m *Movie) Play() {
	fmt.Println("Playing movie: ", m.Name)
}

type Music struct {
	Name string
}

func NewMusic() *Music {
	return &Music{Name: "アジカン"}
}

func (m *Music) Play() {
	fmt.Println("Playing music: ", m.Name)
}

type Player struct {
	hobby Hobbyer
}

func NewPlayer(hobby Hobbyer) *Player {
	return &Player{hobby: hobby}
}

func (p *Player) Play() {
	p.hobby.Play()
}

func main() {
	app := fx.New(
		fx.Provide(
			fx.Annotate(
				NewMovie,
				fx.As(new(Hobbyer)),
				fx.ResultTags(`name:"movie"`),
			),
			fx.Annotate(
				NewMusic,
				fx.As(new(Hobbyer)),
				fx.ResultTags(`name:"music"`),
			),
			fx.Annotate(
				NewPlayer,
				fx.ParamTags(`name:"movie"`),
			),
		),
		fx.Invoke(func(p *Player) {
			p.Play()
		}),
	)
	app.Run()
}

…とここまで書いておいてなのですが、この値と結果はfx.Inおよびfx.Out埋め込み構造体で表現できます。というより、どうもざっと公式ドキュメントを見た感じではこの方法が比較的Uberのおすすめらしいです。

type MovieResult struct {
	fx.Out
	Movie Hobbyer `name:"movie"`
}

type PlayerParams struct {
	fx.In
	Hobby Hobbyer `name:"movie"`
}

注意点として、アノテーションのAsは使えず、フィールドは大文字(パブリック)でなければいけません。

以下は値・結果構造体を利用した、上記と等価なコードの例を示します。

値・結果構造体の利用例
type Hobbyer interface {
	Play()
}

type Movie struct {
	Name string
}

type MovieResult struct {
	fx.Out
	Movie Hobbyer `name:"movie"`
}

func NewMovie() MovieResult {
	return MovieResult{Movie: &Movie{Name: "インセプション"}}
}

func (m *Movie) Play() {
	fmt.Println("Playing movie: ", m.Name)
}

type Music struct {
	Name string
}

type MusicResult struct {
	fx.Out
	Music Hobbyer `name:"music"`
}

func NewMusic() MusicResult {
	return MusicResult{Music: &Music{Name: "アジカン"}}
}

func (m *Music) Play() {
	fmt.Println("Playing music: ", m.Name)
}

type Player struct {
	hobby Hobbyer
}

type PlayerParams struct {
	fx.In
	Hobby Hobbyer `name:"movie"`
}

func NewPlayer(params PlayerParams) *Player {
	return &Player{hobby: params.Hobby}
}

func (p *Player) Play() {
	p.hobby.Play()
}

func main() {
	app := fx.New(
		fx.Provide(
			fx.Annotate(
				NewMovie,
			),
			fx.Annotate(
				NewMusic,
			),
			fx.Annotate(
				NewPlayer,
			),
		),
		fx.Invoke(func(p *Player) {
			p.Play()
		}),
	)
	app.Run()
}

デコレート

fx.Decorateはライフサイクルとしてfx.Providefx.Invokeの間にある段階のもので、恐らくInvoke以降に実際に使いたいオブジェクトの設定をしたい用としてあるのだと思います。

一応公式ドキュメントでは強制終了があることを示す為ですが、SQLの接続を試行する例の説明が載っていました。
注意点として、fx.Annotateを付加できますが、注釈済みであれば修飾できません。

以下のコードではSportsPlayerSportsの部分だけを変更して、実際にそれがInvokeで呼び出される例を示します。

fx.Decorate
type Sports struct {
	Name string
}

func NewSports() *Sports {
	return &Sports{Name: "Football"}
}

type Person struct {
	Name string
}

func NewPerson() *Person {
	return &Person{Name: "John"}
}

type SportsPlayer struct {
	person *Person
	sports *Sports
}

func NewSportsPlayer(p *Person, s *Sports) *SportsPlayer {
	return &SportsPlayer{person: p, sports: s}
}

func (p *SportsPlayer) Say() {
	fmt.Printf("I am %s, I like %s\n", p.person.Name, p.sports.Name)
}

func main() {
	app := fx.New(
		fx.Provide(
			NewSports,
			NewPerson,
			NewSportsPlayer,
		),
		fx.Decorate(func(sp *SportsPlayer) *SportsPlayer {
			sp.sports = &Sports{Name: "Basketball"}
			return sp
		}),
		fx.Invoke(func(p *Person, s *Sports, player *SportsPlayer) {
			// Person: John, Sports: Football
			// I am John, I like Basketball
			fmt.Printf("Person: %s, Sports: %s\n", p.Name, s.Name)
			player.Say()
		}),
	)
	app.Run()
}

おわりに

以上基本的な部分にはなりますが、現在の機能などに関して私なりの解釈を記載しました。
少しでも皆さんのご参考になれば幸いです。

参考リソース

Discussion