kanna

kanna / di

kanna-di

v0.0.7 Go 1.26 pkg.go.dev

CI codecov Go Reference License: MIT Release English

kanna-di は、コンテナ構造体のコンストラクタを生成します。各フィールドの値は、プロバイダ(フィールドの型と同じパッケージにある関数、またはコンテナと同じパッケージにある関数)と、それらがさらに必要とするプロバイダを、普通の Go のコードとして順に呼び出して得られます。

kanna コードジェネレータファミリーの 1 つです。Go 1.26 以上が必要です。実行時にインポートするものはありません。生成コードが呼び出すのは自分で書いた関数だけで、それ以外は何も呼び出しません。

インストール

go get -tool github.com/go-kanna/di/cmd/kanna-di

使い方

層に分かれたアプリケーションを考えます。ドメインがリポジトリとメール送信者を宣言し、ユースケースがそれらに依存し、インフラストラクチャがそれらを実装し、ハンドラがユースケースを呼び出します。各層は、コンストラクタを型と同じパッケージに置きます:

// package user, the domain
type Repository interface {
	Save(ctx context.Context, u User) error
	Find(ctx context.Context, id ID) (User, error)
}

// package mail, the domain
type Sender interface {
	Send(ctx context.Context, m Message) error
}

// package usecase
func NewRegisterUser(repo user.Repository, mailer mail.Sender) *RegisterUser
func NewGetUser(repo user.Repository) *GetUser

// package postgres, the infrastructure
func NewUserRepository(db *sql.DB) *UserRepository

// package sendgrid, an external API
func NewSender(cfg config.Config) *Sender

// package handler
func NewUser(registerUser *usecase.RegisterUser, getUser *usecase.GetUser) *User

// package config
func Load() (Config, error)

コンポジションルートには、プログラムを構成するものを保持する構造体がすでにあります。ハンドラとルーティングを持つサーバです。これに //kanna:container を付けます。コンポジションルートでしか決められないことは、その構造体のそばに書きます。ドメインの各インターフェースにどの実装を使うかと、標準ライブラリが提供するものの開き方です。

package main

//go:generate go tool kanna-di

// server serves the shop's HTTP API.
//
//kanna:container
type server struct {
	_ user.Repository `di:"with=postgres.NewUserRepository"`
	_ mail.Sender     `di:"with=sendgrid.NewSender"`

	users *handler.User
}

func (s *server) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /users", s.users.Register)
	mux.HandleFunc("GET /users/{id}", s.users.Get)
	return mux
}

func openDB(cfg config.Config) (*sql.DB, func(), error) {
	db, err := sql.Open("pgx", cfg.DSN)
	if err != nil {
		return nil, nil, err
	}
	return db, func() { db.Close() }, nil
}

go generate ./... を実行すると、コンテナと同じディレクトリに di_gen.go が書き出されます:

// Code generated by kanna-di. DO NOT EDIT.

package main

import (
	"example.com/shop/internal/config"
	"example.com/shop/internal/handler"
	"example.com/shop/internal/infra/postgres"
	"example.com/shop/internal/infra/sendgrid"
	"example.com/shop/internal/usecase"
)

// newServer initializes dependencies and constructs server.
func newServer() (*server, func(), error) {
	cfg, err := config.Load()
	if err != nil {
		return nil, nil, err
	}
	db, cleanup, err := openDB(cfg)
	if err != nil {
		return nil, nil, err
	}
	repo := postgres.NewUserRepository(db)
	mailer := sendgrid.NewSender(cfg)
	registerUser := usecase.NewRegisterUser(repo, mailer)
	getUser := usecase.NewGetUser(repo)
	users := handler.NewUser(registerUser, getUser)

	return &server{
		users: users,
	}, cleanup, nil
}
func main() {
	s, cleanup, err := newServer()
	if err != nil {
		log.Fatal(err)
	}
	defer cleanup()

	log.Print(http.ListenAndServe(":8080", s.routes()))
}

*usecase.RegisterUser は、型と同じパッケージにある関数 usecase.NewRegisterUser から得られます。*sql.DB は、コンテナと同じパッケージにある openDB から得られます。ドメインのインターフェースである user.Repository と mail.Sender は、コンテナが指定した実装から得られます。config.Config は一度だけ読み込まれ、openDB と sendgrid.NewSender で共有されます。登録するものはありません。コンストラクタのパラメータが変わると、次の go generate がそれを呼び出すコードを変更します。

プロバイダ

プロバイダは、最初の戻り値がパッケージで宣言された型(構造体、インターフェース、その他の名前付き型)またはそのポインタであるトップレベル関数です:

戻り値 コンストラクタ
T
T, error エラーも返す。プロバイダのエラーをそのまま渡す
T, func() クリーンアップも返す。クリーンアップを参照
T, func(), error その両方

可変長パラメータには何も渡しません。プロバイダはコンストラクタごとに 1 回呼び出され、その戻り値を必要とするものはすべてそれを共有します。string や []byte のプロバイダは決して探しません。別の理由でこれらを返す関数がプロバイダと誤認されないようにするためです。このような値には type DSN string のように独自の型を与えるか、di:"arg=<name>" で受け取ってください。NewDeps に対する MustNewDeps のように、ラップする関数と同じパッケージにある must 版は選ばれず、ラップされる関数が使われます。panic ではなくエラーがコンストラクタに届くようにするためです。

kanna-di は必要な型ごとに、次の順に探して最初に見つかったものを使います:

  1. コンストラクタの引数(di:"arg")
  2. コンテナがその型に選んだプロバイダ(di:"with=...")
  3. コンテナが埋め込んでいるもの: 埋め込んだ、または _ として宣言した構造体の公開フィールド、もしくはその場で組み立てるコンテナの with=(コンテナを参照)
  4. コンテナのパッケージにある関数(公開か非公開かは問わない)
  5. その型を宣言するパッケージの公開関数(そのパッケージが自分のモジュールまたは go.work に含まれる場合)

どれも見つからなければ生成を止めます。同じレベルで 2 つ見つかった場合も同じです。エラーは、必要になった経緯をフィールドまでさかのぼって示します:

$ go generate
./server.go:11:2: error: nothing provides mail.Sender, which usecase.NewRegisterUser needs
  via server.users → handler.NewUser → usecase.NewRegisterUser
  return one from a function in package main, take it with di:"arg", or choose a provider with di:"with=..."
./server.go:11:2: error: 2 functions in package usecase return *usecase.GetUser
  usecase.NewGetUser (../../internal/usecase/get_user.go:12:6)
  usecase.NewCachedGetUser (../../internal/usecase/get_user.go:30:6)
  via server.users → handler.NewUser
  choose one with _ *usecase.GetUser `di:"with=usecase.NewGetUser"`
./server.go:11:2: error: dependency cycle: usecase.NewRegisterUser → mail.NewWelcome → usecase.NewRegisterUser
  via server.users → handler.NewUser

それ以外の場所にある関数は、コンテナが指定した場合にのみ使われます。testutil パッケージのヘルパーや、同じ型を返すフィクスチャがあっても、コンテナが受け取るものは何も変わりません。標準ライブラリや他のモジュールも探しません。io の関数のうち io.Writer を返すのは MultiWriter だけで、これは引数なしで呼び出すとどこにも書き込みません。このような型には、コンテナと同じパッケージに関数を書くか(sql.Open に DSN が必要なように、ほとんどはいずれにせよ設定が必要です)、with= で選んでください。

プロバイダの選択

di:"with=<ref>" は、フィールドの型のプロバイダをコンテナ全体に対して選びます:

<ref> 指すもの
NewUserRepository コンテナのパッケージにある関数
postgres.NewUserRepository postgres という名前のパッケージにある関数。対象は自分のモジュール内のパッケージと、その型を宣言するパッケージ
infra/postgres.NewUserRepository 同上。そのようなパッケージが 2 つあるときに区別できるだけのインポートパスを書く

プロバイダの戻り値は、その型に代入可能でありさえすれば構いません。インターフェースに実装を与えられるのはこのためです。名前付きフィールドでは、with= は値の格納も行います。_ では選ぶだけで、構造体の場合はそのフィールドがコンテナに値を提供します(コンテナを参照)。

./server.go:9:2: error: package postgres has no function NewUserRepositry (field _)
  did you mean NewUserRepository?
./server.go:9:2: error: postgres.NewOrderRepository returns *postgres.OrderRepository, which does not implement user.Repository (missing method Find) (field _)

コンテナ

//kanna:container を付けた構造体は、タグで別の指定をしない限り、すべてのフィールドが解決されます:

フィールド 名前付き _
タグなし フィールドの型を解決し、値を格納する 公開フィールドを持つ構造体なら解決するが、何も格納しない。それ以外はエラー
di:"with=<ref>" ref で解決する。ref はコンテナ全体でその型に使われる その型に ref を選ぶ。何も格納しない
di:"arg" 型にちなんだ名前のパラメータとして受け取り、格納する パラメータとして受け取るだけ
di:"arg=<name>" 同上。パラメータ名を明示する 同上。パラメータ名を明示する
di:"-" フィールドには何もしない エラー

タグのない名前付きフィールドや埋め込みフィールドのうち、型がフィールドのない構造体であるもの(そのポインタは含まない)は、ゼロ値のままになります。ゼロ値がその型の持つ唯一の値だからです。todov1.UnimplementedTodoServiceServer を埋め込む gRPC サーバに、そのためのプロバイダは必要ありません。

パラメータは名前を保ち、生成ファイルがその名前で必要とするインポートのほうがリネームされます。明示した名前が、パッケージのトップレベルで宣言されている名前や、Go があらかじめ宣言している名前である場合はエラーです。

コンテナが受け取る、または選んでいるのに、何にも必要とされていないものには警告が出ます:

./server.go:12:2: warning: nothing in server needs the context.Context it takes with di:"arg"
./server.go:9:2: warning: nothing in server needs user.Repository, so its with= chooses nothing

埋め込まれた構造体やそのポインタは、他のフィールドと同じように解決され(di:"arg" を付けた場合はパラメータとして受け取られ)、格納されます。その公開フィールドは、昇格したものも含めて、コンテナの他の部分に値を提供します。ただし、その構造体自体を組み立てるために必要なものには使えません。Go と同じく、浅いフィールドは同じ型の深いフィールドを隠します。同じ深さに 2 つある場合は、その型が必要になったときにエラーになります。

//kanna:container
type App struct {
	infra.Deps `di:"arg"` // its DB and Cache serve the providers of Users

	Users *handler.User
}

_ フィールドの構造体やそのポインタも、同じようにコンテナに値を提供しますが、格納はされません。そのためコンテナは、その構造体も、埋め込みなら昇格するはずのフィールドやメソッドも持ちません。解決されるのは、その構造体かそのフィールドのいずれかが必要になったときです。タグがなければ他のフィールドと同じように解決され、di:"arg" や with= があれば、それによって受け取られるか選ばれます。ユースケースは、インフラストラクチャがまとめたものをこの方法で利用します:

//kanna:container
type DeleteTodo struct {
	_ *infra.Deps // infra.NewDeps provides it; its Transactor and Todos fill the fields below

	transactor tx.Transactor
	todos      repository.Todo
}
func NewDeleteTodo() (*DeleteTodo, func(), error) {
	deps, cleanup, err := infra.NewDeps()
	if err != nil {
		return nil, nil, err
	}
	transactor := deps.Transactor
	todos := deps.Todos

	return &DeleteTodo{
		transactor: transactor,
		todos:      todos,
	}, cleanup, nil
}

同じパッケージのコンテナ(それ自体に //kanna:container が付いた構造体)をタグなしで埋め込むと、そのコンテナは自身のコンストラクタではなく、このコンストラクタの一部として、ここでフィールドごとに組み立てられます。そのコンテナが引数として受け取るものは、このコンストラクタも受け取ります。ただし、このコンストラクタ自身がその型を受け取る場合は、そちらが使われます。そのコンテナの with= は、このコンテナ自身の with= の次に、このコンテナで使われます。テストがサーバを書き直さずに分解できるのは、この仕組みによるものです(テストを参照)。別のパッケージのコンテナは、非公開フィールドをここから設定できないので、代わりにそのコンストラクタで組み立てられます。コンテナ型の名前付きフィールドも同様です。

ディレクティブにはオプションを指定できます:

ディレクティブ 効果
//kanna:container name=<ident> コンストラクタの名前を指定する(デフォルトは New に構造体名を続けたもの、非公開の構造体では new に構造体名を続けたもの)
//kanna:container returns=<type> コンストラクタが返すものを宣言する: 値で返すなら構造体、またはそれが実装するインターフェース(デフォルトはポインタ)
//kanna:container must エラーを返す代わりに panic する MustNewServer(newServer に対しては mustNewServer)も生成する

ジェネリックな構造体はコンテナにできません。コンストラクタに型引数を選ぶ手段がないからです。di タグがあるのにディレクティブがない構造体には警告が出ます。ディレクティブのスペルミスも同様です。

ディレクティブは、コメントマーカーに密着させて書いてください。Go が //kanna:… をディレクティブとして扱うのは、この 2 つが隣接している場合のみです。スペースを入れると、// kanna:container はドキュメントコメントの一部のままになり、go doc や pkg.go.dev に表示されます。kanna-di もこの形式には従わず、期待する書き方を指摘します。

クリーンアップ

何かを開くプロバイダは閉じ方を返し、コンストラクタはそれらすべてをまとめた 1 つのクリーンアップを返します:

func newServer() (*server, func(), error) {
	// ...
	db, cleanup, err := openDB(cfg)
	if err != nil {
		return nil, nil, err
	}
	cache, cleanup2, err := openCache(cfg)
	if err != nil {
		cleanup()
		return nil, nil, err
	}
	// ...
	cleanupAll := func() {
		cleanup2()
		cleanup()
	}

	return &server{
		// ...
	}, cleanupAll, nil
}

クリーンアップは、プロバイダが実行された順序の逆順に実行されます。プロバイダが失敗すると、それより前に実行されたプロバイダのクリーンアップを実行してから、エラーを返します。must 版もクリーンアップを返します。コンストラクタが引数として受け取るものや、埋め込んだ、または _ として宣言した構造体の中に見つけたものは呼び出し側のものであり、決して閉じられません。io.Closer を実装しているという理由で閉じられるものもありません。

テスト

_test.go ファイルで宣言されたコンテナも生成の対象で、同じディレクトリの di_gen_test.go に、そのファイルが宣言するパッケージ(パッケージ自体、またはその外部 _test パッケージ)のコードとして生成されます。次のコンテナはパッケージ自体にあり、サーバを埋め込んでいるので、サーバそのものです。ただし、リポジトリと送信者を with= で選ぶ代わりに引数として受け取る形で、ここで組み立てられます。すると *sql.DB も config.Config も必要なくなるので、openDB と config.Load は呼び出されず、newTestServer はクリーンアップもエラーも返しません。

//kanna:container
type testServer struct {
	*server

	_ user.Repository `di:"arg"`
	_ mail.Sender     `di:"arg"`
}

func TestRegisterUser(t *testing.T) {
	s := newTestServer(memory.NewUserRepository(), &fakeSender{})
	srv := httptest.NewServer(s.routes())
	defer srv.Close()
	// ...
}

このテストは、初回の実行で newTestServer が書き出される前からそれを呼び出しています。kanna-di は読み込んだパッケージを、これから書き出す内容を組み込んだ状態でもう一度型チェックするので、まだ存在しないコンストラクタや、これから受け取るものや返すものが変わるコンストラクタの呼び出しがあっても止まりません。その時点で間違っているものは報告され、何も書き出されません。これから書き出すコンストラクタと合わない呼び出しや、コンストラクタがないために隠れていた間違い(コンストラクタが返すものにないメソッドなど)です。

テスト用コンテナのパッケージの関数には、その _test.go ファイルの関数も含まれます。_test.go ファイルで宣言されたプロバイダは、そのパッケージのテストにのみコンパイルされるので、そのパッケージのテスト(パッケージ内と外部の両方)のコンテナにだけ値を提供し、それ以外には提供しません。1 つのディレクトリでは、2 つのテストパッケージのどちらか一方にしかテスト用コンテナを置けません。両方に置くと di_gen_test.go を共有することになるからです。

フラグ

kanna-di [flags] [packages]
フラグ 意味
-tags <list> カンマ区切りのビルドタグ
-check 書き出す代わりに、出力が最新であることを検証する。不要な出力が残っていないことも含む
-v 書き出した、削除した、変更しなかったファイルを一覧表示する
-version バージョンを表示する

引数のパッケージは、コンテナのある場所を示すもので、プロバイダの取得元を示すものではありません。デフォルトはカレントディレクトリのパッケージなので、//go:generate go tool kanna-di に引数は不要です。成功した実行は、警告以外何も出力しません。すべての構造体から //kanna:container がなくなったパッケージの di_gen.go と di_gen_test.go は、実行時に読み込んだパッケージのコードがそのコンストラクタをまだ呼び出していない限り、削除されます。どちらの名前でも、生成コードのヘッダがないファイルには決して触れません。削除を行う go generate は open ...: no such file or directory で終わります。これは go generate 自身のエラーで、ジェネレータを実行する前にパッケージのファイルを列挙しているためです。ファイルは意図どおり削除されています(golang/go#36068)。

サンプル

example は、上で述べた層(ドメイン、ユースケース、インフラストラクチャ、ハンドラ)からなる小さなショップの依存関係を組み立てます。プロバイダは、インターフェースへのバインド、引数、埋め込みコンテナから見つけます。クリーンアップ、must 版、リポジトリをメモリ上のものに差し替えるテスト用コンテナも使っています。CI はこれを再生成し、出力が変わる場合は失敗します。

開発

make test       # unit tests
make lint       # golangci-lint
make example    # regenerate the example, then vet, test, and run it

ライセンス

MIT