GoとDependency Injectionの現在

tl;dr

・Goの依存性注入は普通に行われるが、DIツールはまだ観測範囲では浸透していない。
・直近出たGoogle製Go向けDIツール「Wire」はシンプルなAPIやツール作成で有用だが、依存オブジェクトの設定が複雑化すると表現性に限界がくる
・Goにおいて、DIツールはある種のフレームワークと認識して慎重に採用すべき

前提:Goの依存性注入と課題

Goのコードを書く際、特に一定規模を超えたAPIを書く際は、依存するオブジェクトというのが増える。DBクライアントやロガーや各種ビジネスロジックを呼び出すサービス層などがそれに該当する。

レイヤー化されたパッケージ構成の下、こうした依存オブジェクトをトップダウンに注入していくやり方は見通しがよく、テスト時にモックのAPIクライアントを差し込みやすかったりと、テスタビリティを向上させる。ざっくり依存性注入が行われるようなレイヤー化された構成で、なんらかのデータをGetするハンドラーを書くと、次のようになるだろう。

// ドメイン層のインターフェース定義
package repository

type DataRepository interface {
	Get(ctx context.Context) (*Data, error)
}

package service

type Service interface {
	GetData(ctx context.Context) (*Data, error)
}

// 永続化層
package persistence 

type repoImpl struct {
	db *sql.DB
}

func NewRepository(db *sql.DB) repository.DataRepository {
	return &repoImpl{
		db: db,
	}
}

func (r *repoImpl) Get(ctx context.Context) (*Data, error) {
	return nil, nil
}

// アプリケーション層
package application

type serviceImpl struct {
	repo repository.DataRepository
}

func NewService(repository.DataRepository) service.Service {
	return &serviceImpl
}

func (s *serviceImpl) GetData(ctx context.Context) (*Data, error) {
	return s.repo.Get(ctx)
}

// プレゼンテーション層
package controller

type Controller struct {
	service service.Service
}

func NewController(s service.Serivce) *Controller {
	return &Controller{
		service: s,
	}
}

func (ctrl *Controller) GetDataHandler(w http.ResponseWriter, r *http.Request) {
	return ctrl.service.GetData(r.Context())
}

// 他、外部クライアント初期化処理
package config

type DBConfig struct {
	DSN string
}

func NewDB(cfg *DBConfig) *sql.DB {
	cnn, _ := sql.Open("mysql", cfg.DSN)
	return cnn
}

// main.go
func main() {
	// 依存オブジェクトをハンドラーに注入していく
	dbClient := config.NewDB(&config.DBConfig{
		DSN: "xxx",
	})
	repo := persistence.NewRepository(dbClient)
	service := application.NewService(repo)
	ctrl := NewController(service)
}

しかし、その依存オブジェクトを書き連ねていくうち、初期化関数は依存性注入のための処理によって肥大化する。この肥大化は、依存関係を視認しづらくする上、チームに新しく参加した人にはかなり抵抗感を生むという点で問題だ。場合によっては100行を余裕で超えたりするため、バカにならない。こういう感じの処理が延々と続く。

func main() {
    // ... 
    
    // Artwork Repository
    awRepo := persistence.NewArtworkRepository()

    // Youtube Data API Repository
    youtubeRepo := external.YoutubeRepositoryRepository(os.Getenv("YOUTUBE_DATA_API_KEY"))
    twitterRepo := external.NewTwitterRepository(&values.TwitterAccessInfo{
        os.Getenv("TWITTER_CONSUMER_KEY"),
        os.Getenv("TWITTER_CONSUMER_SECRET"),
        os.Getenv("TWITTER_ACCESS_TOKEN"),
        os.Getenv("TWITTER_ACCESS_TOKEN_SECRET"),
    })

    // Streaming Service
    strepo := persistence.NewStreamingRepository()
    streamingService := application.NewStreamingService(strepo, taskRepo, youtubeRepo, searchRepo, pingrepo, tm)
    streamingDependency := &streaming.Dependency{
        StreamingService: streamingService,
    }

    // Authentication Service
    userRepository := persistence.NewUserRepository()
    authService := application.NewAuthService(userRepository)
    authDependency := &auth.Dependency{
        AuthService: authService,
    }

    // ... 
}


DIツール「Wire」の登場

さて、そんな殺伐とした平成最後の初期化処理に颯爽と登場したのが、Google公式が作ったDIツール「Wire」だ。

元々はgo-cloudという、これまたGoogleが作ったマルチクラウド環境向けの汎用CDK(Cloud Developer Kit)の中に、しれっと、本当になんの告知もなく紛れ込んでたツールだった。気づいた人の間では「なんだこれは」とちょっとだけ話題になった。

程なくして、このツールはgoogle/wireとして別の公開パッケージとして利用できるようになった。Goの公式ブログにも記事がある。

このツールの意義は、依存関係さえ定義しておけば、前述のような複雑化しがちな依存性注入処理のコードを自動生成できるというものだ。おまけに依存関係が正しいか、コンパイル時にチェックしたり、依存グラフを可視化できる。

実際にgo-cloud内部ではGCPへのクライアントを初期化するために、次のようなコードがある。

package gcpcloud // import "gocloud.dev/gcp/gcpcloud"

import (
	"github.com/google/wire"
	"gocloud.dev/gcp"
	"gocloud.dev/gcp/cloudsql"
	"gocloud.dev/runtimevar/runtimeconfigurator"
	"gocloud.dev/server/sdserver"
)

// GCP is a Wire provider set that includes all Google Cloud Platform services
// in this repository and authenticates using Application Default Credentials.
var GCP = wire.NewSet(Services, gcp.DefaultIdentity)

// Services is a Wire provider set that includes the default wiring for all
// Google Cloud Platform services in this repository, but does not include
// credentials. Individual services may require additional configuration.
var Services = wire.NewSet(
	cloudsql.CertSourceSet,
	gcp.DefaultTransport,
	gcp.NewHTTPClient,
	runtimeconfigurator.Dial,
	sdserver.Set)

概念としては2つ、Provider(DIされる部品)とInjector(Providerをまとめあげ、DI処理を作るための関数)がある。詳しくはこの記事を読むと良い

関数として使うのは大体4つくらいで、

・wire.NewSet:依存オブジェクトの個別の初期化処理をバインドする
・wire.Value:関数ではなく構造体をバインドする
・wire.InterfaceValue:インターフェースに具体的な実装をバインド
・wire.Build:各SetやBuild結果から初期化のための関数を自動生成

このあたりの関数を知っておけば、先ほどのような長ったらしい処理を手で書く必要がなくなる。嬉しいね。

なお、この記事の中では「DIツール」という表現に留めており、俗にいう「DIコンテナ」とは表現してない。Wireの役割はInjectorというオブジェクトの初期化関数の提供であって、依存するオブジェクトを内包する巨大なコンテナの提供ではない。事実、Wireのリポジトリには一度も「container」なんてワードは出てこない。

Wireの有用性

実用のためのコンセプトはこの記事を読めばなんとなくわかると思う。

単に初期化関数をバインドするだけでなく、構造体を結果として初期化したり、

// Providers

type Foo int
type Bar int

func ProvideFoo() Foo {
    // ...
}

func ProvideBar() Bar {
    // ...
}

type FooBar struct {
    Foo Foo
    Bar Bar
}

var Set = wire.NewSet(
    ProvideFoo,
    ProvideBar,
    FooBar{})
// Generated injector

func injectFooBar() FooBar {
    foo := ProvideFoo()
    bar := ProvideBar()
    fooBar := FooBar{
        Foo: foo,
        Bar: bar,
    }
    return fooBar
}

DBクライアントやファイルリーダーを初期化し、cleanup処理としてCloseする処理を戻り値として受け取るようなものも初期化関数のパーツとして定義できる。

// 第2戻り値にcleanup関数を指定できる。
// (Object, func(), error)という戻り値のパターンがcleanup処理が絡む時遵守必須の形式
func provideFile(log Logger, path Path) (*os.File, func(), error) {
    f, err := os.Open(string(path))
    if err != nil {
        return nil, nil, err
    }
    cleanup := func() {
        if err := f.Close(); err != nil {
            log.Log(err)
        }
    }
    return f, cleanup, nil
}

仮に依存関係が不整合を起こしていて、足りない依存オブジェクトがあれば、wireコマンドによる自動生成処理の過程でエラーがでる。(ここではDBクライアント生成に当たって接続設定が渡されなかったケースを発生させてみた)

$ [~/go/src/github.com/timakin/misc/wiretest:(git)master] wire
github.com/timakin/misc/wiretest: generate failed
/Users/seiji.takahashi/go/src/github.com/timakin/misc/wiretest/wire.go:17:1: inject BuildRepository: no provider found for *github.com/timakin/misc/wiretest.DBConfig
	needed by *database/sql.DB in provider set "RepoSet" (/Users/seiji.takahashi/go/src/github.com/timakin/misc/wiretest/main.go:9:15)
	needed by github.com/timakin/misc/wiretest.Repository in provider set "RepoSet" (/Users/seiji.takahashi/go/src/github.com/timakin/misc/wiretest/main.go:9:15)
wire: at least one generate failure

また、依存関係を自動生成するだけでなく、どんな依存グラフがあるかも可視化できる。

$ [~/go/src/github.com/timakin/misc/wiretest:(git)master] wire show

"github.com/timakin/misc/wiretest".RepoSet
Outputs given *github.com/timakin/misc/wiretest.DBConfig:
	*database/sql.DB
		at /Users/seiji.takahashi/go/src/github.com/timakin/misc/wiretest/main.go:60:6
	github.com/timakin/misc/wiretest.Repository
		at /Users/seiji.takahashi/go/src/github.com/timakin/misc/wiretest/main.go:70:6
Injectors:
	"github.com/timakin/misc/wiretest".BuildRepository
	"github.com/timakin/misc/wiretest".BuildRepository2
	"github.com/timakin/misc/wiretest".InitializeService

以上のような記事や例から、

・コンパイルチェックの丁寧さ
・依存グラフの可視化
・生成される関数の見通しの良さ
・シグネチャの必要十分性

というメリットを強く感じた。これは、通常のパッケージ作成やある程度マイクロに分断された(= 責務が細かくコードの複雑性も抑えられた)APIを作っていく上で、非常にパワーを発揮するツールだろう。

Wireの課題

Wireはシンプルかつ強力なツールだということがお分りいただけたと思う。しかしWireの恩恵を得るには、強い制約を守る必要がある。

僕の場合、DB呼び出しするリポジトリ層の初期化をWireに任せようとして、少し戸惑った。仮に、このようなMaster, Slave DBを呼び出す単純なRepositoryのコードがあったとしよう。

type DataRepository interface {
	Get(ctx context.Context) (*Data, error)
	Set(ctx context.Context, *Data) error
}

type repoImpl struct {
	master, slave *sql.DB
}

func NewDataRepository(master, slave *sql.DB) DataRepository {
	return &repoImpl{
		master: master,
		slave:  slave,
	}
}

func (r *repoImpl) Get(ctx context.Context) (*Data, error) {
	// r.slaveを使ってSlave DBからDataを取得
}

func (r *repoImpl) Set(ctx context.Context, *Data) error {
	// r.masterを使ってMaster DBからDataを入力
}

このコードでは、NewDataRepositoryをProviderとして利用できない。問題はいくつかある。

------------------------------

課題1: Providerの引数の型制約

ここではmaster, slaveという2つの*sql.DBをNewDataRepositoryに渡している。Wireではこの引数の持ち方は不正となる。

Wireは引数と同じ型の戻り値を持つProviderをSetにすることで、依存関係の解決を行う。そのため、仮に*sql.DBを返す2つの関数をProviderに指定しても、どちらのProviderの戻り値をmasterとslaveに渡せばいいのか、Wireは判別できない

これを解決するためには、

1. masterとslaveを型として区別できるようにする
2. そもそも複数の同じ型の引数を渡さない

というどちらかの手段を取れば良い。1の場合は、以下のようにsql.DBを独自定義すれば良い。(*sql.DBのようなポインタは独自型に指定できないため、sql.DBとしている。

type MasterDB *sql.DB
type SlaveDB *sql.DB

このように、共通で参照されるような型は、区別できるように独自型を定義しましょう、的な話が公式のベストプラクティスに書いてある。

Distinguishing Types
If you need to inject a common type like string, create a new string type to avoid conflicts with other providers. For example:

type MySQLConnectionString string

これで型の紐付けによる依存関係の解決ができるとはいえ、DBクライアントの初期化処理を、DB参照先ごとに書かなければならず非常に渋い。

そこで、「2. そもそも複数の同じ型の引数を渡さない」を手段として実行してみる。まず、*sql.DBを元の型として受け取れるようにしつつ、CQRSのコンセプトに基づき、RepositoryをMasterとSlaveのために分割する。こうすれば、同じ型の引数を複数受け取る必要はなくなる。

type DataRepository interface {
	Set(ctx context.Context, *Data) error
}

type DataQueryService interface {
	Get(ctx context.Context) (*Data, error)
}

type repoImpl struct {
	master *sql.DB
}

type queryImpl struct {
	slave *sql.DB
}

func NewDataRepository(master *sql.DB) DataRepository {
	return &repoImpl{
		master: master,
	}
}

func NewDataQueryService(slave *sql.DB) DataQueryService {
	return &queryImpl{
		slave:  slave,
	}
}

func (r *queryImpl) Get(ctx context.Context) (*Data, error) {
	// r.slaveを使ってSlave DBからDataを取得
}

func (r *repoImpl) Set(ctx context.Context, *Data) error {
	// r.masterを使ってMaster DBからDataを入力
}

こうすればデータの取得と書き込みの責務が分かれるし、リファクタとしても良い。ProviderからSetを作る場合は、以下のようになる。

var RepoSet = wire.NewSet(NewDataRepository, NewDB)
var QueryServiceSet = wire.NewSet(NewDataQueryService, NewDB)

これで一件落着、となればいいのだが、そうはいかない。

------------------------------

課題2: Config周りの制約

課題1をクリアしたことで、DBクライアントの構造体さえ別になっていれば、引数に独自型を作らなくても済むことがわかった。だが、違うDB参照先があるということは、違う接続先情報(Config)が存在しなくてはならない。

普通にサービスを開発するとき、環境変数から接続情報を読みだすが、バラバラに読み出すわけではない。構造体として、ものすごく簡素にまとめたとして、このような構造体になるだろう。

type DBConfig struct {
	DSN string
}

type AllDBConfig struct {
	MasterConfig *DBConfig
	SlaveConfig  *DBConfig
}

そして、DBConfigを使った*sql.DBの初期化をする関数が次のように(雑に)定義されていたとする。

func NewDB(cfg *DBConfig) *sql.DB {
	cnn, _ := sql.Open("mysql", cfg.DSN)
	return cnn
}

しかし、ここでまた型制約の問題が出てくる。どっちの*DBConfigをDBクライアントの初期化Providerに渡せばいいか、Wireが判別できるようにしなくてはいけない。ここで、「とりあえず独自型を定義して渡せば良い!」という発想だと、NewDBがConfigの多重化にしたがって、NewMasterDBというように同じ数だけ多重定義していかなければならないことを見逃してしまう。この時、少しでも共通化しようものなら、以下のようなキャスト祭りとなり大変しんどい。

type DBConfig struct {
	DSN string
}

type MasterDBConfig DBConfig
type SlaveDBConfig DBConfig

type AllDBConfig struct {
	MasterConfig *MasterDBConfig
	SlaveConfig  *SlaveDBConfig
}

func NewDB(cfg *DBConfig) *sql.DB {
	cnn, _ := sql.Open("mysql", cfg.DSN)
	return cnn
}

func NewMasterDB(cfg *MasterDBConfig) *sql.DB {
	return NewDB((*sql.DB)(cfg))
}

func NewSlaveDB(cfg *SlaveDBConfig) *sql.DB {
	return NewDB((*sql.DB)(cfg))
}

では、独自型の定義以外にこれを避ける方法はあるのか?答えは、ある。構造体のフィールドそれぞれのGetterを定義すれば良い。

type AllDBConfig struct {
	MasterConfig *DBConfig
	SlaveConfig  *DBConfig
}

func GetMasterConfig(ac *AllDBConfig) *DBConfig {
	return ac.MasterConfig
}

func GetSlaveConfig(ac *AllDBConfig) *DBConfig {
	return ac.SlaveConfig
}

func NewDB(cfg *DBConfig) *sql.DB {
	cnn, _ := sql.Open("mysql", cfg.DSN)
	return cnn
}

これなら、キャストを避けつつ接続情報を利用したSetを作ることができるしかし、あまりに不恰好だ。しかもMaster/Slaveとかだけではなく、MySQL、Redis、ファイルストレージなど、接続先の多様化(に従ったConfigの多様化)が起これば、その数だけフィールドの取得関数を定義する必要がある。祭りだね。

さて、いきなりこんな不恰好な方法を見せられたら普通違う方法はないのか?と考えると思うが、試してみてダメだった方向性というのがある。
フィールドのどれを使うか、Setの中で指定できれば良いのでは」という考えだ。しかし、これは2019年2月上旬現在、できない。

別の方法として、AllDBConfigを引数として受け取って、Buildに渡そうとしても、できない。wire.Valueでなんとかしようとしたが、変数は渡せないので、それも無理。

func BuildRepository(cfg *AllDBConfig) Repository {
	wire.Build(
		cfg.MasterConfig, // これは無理
		RepoSet,
	)
	return nil
}

func BuildRepository(cfg *AllConfig) Repository {
	wire.Build(
		wire.Value((*DBConfig)(cfg.MasterConfig)), // これも無理
		RepoSet,
	)
	return nil
}

この問題は、より広い表現をすれば「ProviderとしてInjectするフィールドを、構造体から選べない」という問題にある。同じ問題にぶつかった人はいて、公式のIssueでもまだ解決されていない。しかもこれは「型が違うフィールド参照」の話で、同じ型を使う複数のMySQLのConfigの取り扱いとかはカバーできてない。

(なお、このIssueの下の方のコメントを見ると、「Discussed with @vangent offline.」って書いてあって、社会性の大切さを感じることができる。)

これを解決するには、どのフィールドをInjectに使うかを、型情報だけでなくフィールドタグ等で指定できるようにする必要がある。つまり、可能性はあるが、今現在ではそれはできずにいる、というわけだ。

結論:GoでのDIツールの導入について

最後に、以上のようにWireを通じて、GoプロジェクトへのDIツール導入を見てきたが、課題と解決方向性の整理には結構苦労した

Goの強みは、シンプルかつ強力なインターフェースだ。アプリケーションフレームワークさえいらない、net/httpで事足りる言語はなかなかない。

しかし、Wireを使う過程では真逆のコンセプトを感じた。一見依存関係を簡素化するように見えて、少し手のかかるリファクタリングやフィールド呼び出し関数の定義など、作業が必要だった。そして気づいた事実は、「DIツールという名前の、ある種のアプリケーションフレームワークなのだ」ということだった。

Dagger 2-inspiredだし、Android開発におけるDagger 2もアノテーションによってシンプルに見せてはいるが、フレームワークと表現しても過言ではないくらい、中身を理解するのは難しい。

したがって、Wireの導入は、Goのシンプルさが気に入ってnet/httpでAPIを書いてきた人にとっては、不安が生じると思う。

もちろん、開発者の方々がそういうコンセプトで作ってないことは明らかだ。事実公式ドキュメントのFAQにも、次のようにある。

Should I use Wire for small applications?
------
Probably not. Wire is designed to automate more intricate setup code found in larger applications. For small applications, hand-wiring dependencies is simpler.

しかし、これはコンセプトであって現実ではないGoにおける依存性注入は、どこかで不都合を許容しなければならない状態にある。その解決策としてWireは可能性に溢れ、シンプルなケースであれば使うこともできる。用意されてる機能に対して、必要な知識も少なく、非常に優れたツールであることは間違いない。

ただ、ある程度の規模を越えると、ただのツールではなく一種のアプリケーションフレームワークのような制約となることを、注意されたい。

この記事が気に入ったら、サポートをしてみませんか?気軽にクリエイターを支援できます。

感謝です!シェアもして頂けると嬉しいです!✧\\٩(‘ω’)و //✧
34

timakin

Software Engineer.

#エンジニア 系記事まとめ

noteに投稿されたエンジニア系の記事のまとめ。コーディングTIPSよりは、考察や意見などを中心に。
1つ のマガジンに含まれています
コメントを投稿するには、 ログイン または 会員登録 をする必要があります。