golang.yandex/di

ジェネリックメソッドで作る Go の依存性注入。

サービスを登録し、s.Get[T]()で解決すれば、あとはコンテナが生成し、起動し、停止し、グラフを検査します。コード生成なし、依存パッケージなし。

go get golang.yandex/di

このページ全体が 1 つのアプリケーションで、上から順に読めます。データベースを持つ小さな HTTP サービス、その手前のキャッシュ、バックグラウンドで動くメーラー、そしてリクエストごとに作られるハンドラです。どのコードブロックもリポジトリのexamples/guideにあるファイルそのものです。Go のツールチェーンが変更のたびにコンパイルしてテストしているので、見えているものがそのまま動くものです。

アプリケーションの形

各パッケージは自分のサービスを持ち、それらを登録するModuleという関数を 1 つだけ公開します。それ以外にパッケージに特別なところはありません。コンストラクタはただの関数、型はただの型で、グラフ全体を知っているファイルはmainだけです。

examples/guide
cmd/api/main.go    モジュールを組み、グラフを検査し、実行する
internal/config/   設定。値として登録される
internal/storage/  データベースと、その上に作られるストア
internal/cache/    ストアを包むキャッシュ
internal/mail/     バックグラウンドの worker
internal/api/      HTTP サーバとそのハンドラ

出発点は main

このアプリケーションを読むならここからです。mainはロガーを作り、それをサービスとして登録し(どのコンストラクタも引数として受け取れます)、グラフを検査して実行します。アプリケーションそのものは、それが適用するモジュールの一覧です。モジュールとは、そのパッケージのサービスをスコープに登録するためにパッケージが公開する関数です。これ以降の各ステップでは、Useが適用する順に、そのモジュールを 1 つずつ開いていきます。

cmd/api/main.go
// Command api is the application: it composes the modules, checks the graph,
// and runs until a signal arrives.
package main

import (
	"context"
	"log/slog"
	"net/http"
	"os"
	"time"

	charm "github.com/charmbracelet/log"
	"golang.yandex/di"
	"golang.yandex/di/dihttp"
	"golang.yandex/di/dislog"
	"golang.yandex/di/examples/guide/internal/api"
	"golang.yandex/di/examples/guide/internal/cache"
	"golang.yandex/di/examples/guide/internal/config"
	"golang.yandex/di/examples/guide/internal/mail"
	"golang.yandex/di/examples/guide/internal/storage"
)

func main() {
	// charmbracelet/log is an slog handler, so one logger serves both the
	// application and the container.
	logger := slog.New(charm.NewWithOptions(os.Stderr, charm.Options{
		ReportTimestamp: true,
		TimeFormat:      time.Kitchen,
	}))

	app := di.New()

	// dislog.New turns the container's lifecycle events into log lines: one
	// per constructor and per hook, so the application says what it is doing
	// as it builds, starts, drains and stops. Observers see this scope and
	// every scope under it, request scopes included, so register it first and
	// the wiring is logged from the beginning.
	app.Observe(dislog.New(logger))

	// The same logger as a service, so a constructor that wants to log takes
	// a *slog.Logger as a parameter like any other dependency.
	app.Value(logger)

	// The application, in the order it is composed. Order matters in one
	// place: cache wraps what serves storage.Store, so its module comes after
	// storage's.
	app.Use(
		config.Module,
		storage.Module,
		cache.Module,
		mail.Module,
		dihttp.Module,
		api.Module,
	)

	// Nothing has been built yet. The constructors declared their
	// dependencies, so the graph is checked here, as a request scope holding
	// an *http.Request would resolve it.
	if err := app.Validate(di.Provided[*http.Request]()).Err(); err != nil {
		logger.Error("invalid wiring", "err", err)
		os.Exit(1)
	}

	// Run starts the eager services and their hooks, waits for SIGINT or
	// SIGTERM, then stops everything in reverse order within the timeout.
	if err := app.Run(context.Background(), di.StopTimeout(10*time.Second)); err != nil {
		logger.Error("stopped with failures", "err", err)
		os.Exit(1)
	}
}

Useはモジュールを順に適用し、それぞれの登録をそれを行ったモジュールに紐づけます。2 つのモジュールが衝突したときにエラーが名指しするのがこれです。同じキーの 2 回目の登録はOverride()がなければ両方を名指しして拒否されるので、あるモジュールが別のモジュールの配線を黙って書き換えることはできません。

Validateは何も生成せずに宣言されたグラフをたどります。そのとき、リクエストスコープが何を持つことになるかを伝えておきます。誰も提供しない依存、循環、シングルトンに捕まったリクエストスコープのサービスは、最初のリクエストではなく起動時のここで失敗します。そのあとRunが eager なサービスを起動し、シグナルを待ち、タイムアウトの範囲内で逆順にすべてを停止します。

設定は値である

最初のモジュールは、すでに手元にある値をValueで登録します。ほかのキーと何も変わりません。次のステップのストアのコンストラクタは引数として受け取り、テストはOverride()で差し替え、下流のサービスはすべてそれに従います。

internal/config/config.go
// Package config reads the settings the application starts with.
package config

import (
	"cmp"
	"os"

	"golang.yandex/di"
)

type Config struct {
	Addr string
	DSN  string
}

func load() Config {
	return Config{
		Addr: env("ADDR", ":8080"),
		DSN:  env("DSN", "postgres://localhost/app"),
	}
}

func env(key, fallback string) string { return cmp.Or(os.Getenv(key), fallback) }

// Module registers the configuration as a value. A test overrides it with
// s.Value(config.Config{...}).Override() and everything downstream follows.
func Module(s *di.Scope) { s.Value(load()) }

コンストラクタとモジュール

newDBnewPGStoreは必要なものを引数で受け取り、作ったものを返します。newDBは失敗しえます。どちらもコンテナを import しません。このアプリケーションでは、スコープを受け取るクロージャを渡す汎用の形Provideはどこにも必要ありませんが、必要になれば両者は自由に混ぜられます。

ModuleWireでそれらを渡します。型引数は、そのサービスが提供されるときのキーです。接続には*db、 ストアにはインターフェースのStore*pgStoreがそれに代入可能だからです。Wireに渡したコンストラクタの引数がそのまま依存であり、コンテナが何も作らないうちにグラフを知っているのはそのためです。フックは受け取る値に対して型付けされ、アプリケーションの起動時と停止時に走ります。

プライバシーは Go のものそのままです。キーは型なので、このパッケージだけが名前を書ける*dbは、このパッケージだけが解決できるサービスになります。パッケージが公開するのは契約であるStoreUser、そしてModuleだけ。接続にはコンテナが動かすライフサイクルがありますが、それ以外は誰にも関係ありません。

internal/storage/storage.go
// Package storage owns the database connection and the store built on it.
// It exports its contract, Store and User, and its Module; the connection
// and the implementation are private. Keys are types, so a type only this
// package can name is a service only this package can resolve.
package storage

import (
	"context"
	"errors"

	"golang.yandex/di"
	"golang.yandex/di/examples/guide/internal/config"
)

// db is the database connection: private to the package, with a lifecycle
// the container runs. Its constructor takes what it needs as parameters and
// knows nothing about the container.
type db struct{ dsn string }

func newDB(cfg config.Config) (*db, error) {
	if cfg.DSN == "" {
		return nil, errors.New("storage: DSN is empty")
	}
	return &db{dsn: cfg.DSN}, nil
}

func (db *db) Ping(context.Context) error { return nil }
func (db *db) Close() error               { return nil }

// Store is what the rest of the application depends on. Handlers take the
// interface; the container serves whatever is registered for it.
type Store interface {
	Find(ctx context.Context, id string) (User, error)
	Ping(ctx context.Context) error
}

type User struct{ ID, Name string }

type pgStore struct{ db *db }

func newPGStore(db *db) *pgStore { return &pgStore{db: db} }

func (s *pgStore) Find(_ context.Context, id string) (User, error) {
	return User{ID: id, Name: "user " + id + " via " + s.db.dsn}, nil
}

func (s *pgStore) Ping(ctx context.Context) error { return s.db.Ping(ctx) }

// Module registers the package's services. Constructors are handed over as
// they are; the hooks are typed on what they receive. The Store key is
// served by the private constructor, whose result is assignable to it.
func Module(s *di.Scope) {
	s.Wire[*db](newDB).
		OnStart(func(ctx context.Context, db *db) error { return db.Ping(ctx) }).
		OnStop(func(_ context.Context, db *db) error { return db.Close() })
	s.Wire[Store](newPGStore)
}

置き換えずに包む

Wrapは、そのキーをすでに提供しているものの上に重ねます。ラッパーはその値を最初の引数で受け取り、ほかの依存はその後ろに並べます。ストアは自分の登録とフックをそのまま保ち、先に生成され、ラッパーより後に停止し、ラッパーは自分が変えないものをそのまま素通しします。唯一気をつけるのはモジュールの順序で、キャッシュのモジュールは storage の後に来ます。これはmainが適用している順序そのものです。子スコープで登録されたラッパーは、そのスコープとその子孫にだけ効きます。 storage と同じくこのパッケージもModuleだけを公開します。横断的関心事は公開された契約の上に重ねるもので、パッケージの内部の上ではありません。

internal/cache/cache.go
// Package cache puts a cache in front of the store. It replaces nothing:
// the store keeps its registration and hooks, and this wraps it. The package
// exports only its Module.
package cache

import (
	"context"
	"sync"

	"golang.yandex/di"
	"golang.yandex/di/examples/guide/internal/storage"
)

type cache struct {
	mu    sync.Mutex
	users map[string]storage.User
	hits  int
}

func newCache() *cache { return &cache{users: map[string]storage.User{}} }

// cachingStore is a Store that asks the one it wraps only on a miss, and
// forwards what it does not change.
type cachingStore struct {
	next  storage.Store
	cache *cache
}

// newCachingStore takes the store it wraps first, then its dependencies.
func newCachingStore(next storage.Store, c *cache) storage.Store {
	return &cachingStore{next: next, cache: c}
}

// hit reads the cache and counts the lookup if it was there. It is a method
// of its own because the lock must be released before the store is asked.
func (c *cache) hit(id string) (storage.User, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	user, ok := c.users[id]
	if ok {
		c.hits++
	}
	return user, ok
}

func (c *cache) put(id string, user storage.User) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.users[id] = user
}

func (s *cachingStore) Find(ctx context.Context, id string) (storage.User, error) {
	if user, ok := s.cache.hit(id); ok {
		return user, nil
	}
	user, err := s.next.Find(ctx, id)
	if err != nil {
		return user, err
	}
	s.cache.put(id, user)
	return user, nil
}

func (s *cachingStore) Ping(ctx context.Context) error { return s.next.Ping(ctx) }

// Module registers the cache and wraps whatever serves Store by now. Order
// matters: this module comes after storage's.
func Module(s *di.Scope) {
	s.Wire[*cache](newCache)
	s.Wrap[storage.Store](newCachingStore)
}

バックグラウンドの worker

Goで登録した worker はそのサービスと同じだけ生きます。サービスの起動時に専用のゴルーチンで開始され、Stopでキャンセルされ、それが依存している何かが片付けられる前に完了を待たれます。ここからエラーを返すとアプリケーションが停止します。Eagerは、メーラーが最初の利用時ではなくStartが返る時点ですでに存在していることを意味します。

internal/mail/mail.go
// Package mail sends messages from a background worker. It exports its
// contract, Mailer, and its Module; the constructor and the loop are private.
package mail

import (
	"context"
	"log/slog"

	"golang.yandex/di"
)

type Mailer struct {
	log   *slog.Logger
	queue chan string
}

// newMailer takes the logger as a parameter, like any other dependency.
func newMailer(log *slog.Logger) *Mailer {
	return &Mailer{log: log, queue: make(chan string, 64)}
}

// Send queues a message; the worker delivers it.
func (m *Mailer) Send(msg string) {
	select {
	case m.queue <- msg:
	default:
		m.log.Warn("mail: queue full, dropped", "msg", msg)
	}
}

// run delivers until ctx is cancelled, which Stop does before the services
// the mailer depends on are stopped. Cancellation means stop accepting, not
// stop finishing: what is already queued is delivered before returning, so a
// request that queued a message just before shutdown is not silently dropped.
//
// The tail is bounded by what is queued at that moment, not by the queue
// running dry. Draining until empty would let a Send racing the shutdown keep
// the worker past Stop's deadline, and a worker that does not return is
// reported as a teardown failure.
func (m *Mailer) run(ctx context.Context) error {
	for {
		select {
		case msg := <-m.queue:
			m.log.Info("mail: sent", "msg", msg)
		case <-ctx.Done():
			for range len(m.queue) {
				m.log.Info("mail: sent", "msg", <-m.queue)
			}
			return nil
		}
	}
}

// Module registers the mailer as an eager service with a worker: it exists
// once Start returns, its loop runs in its own goroutine, and Stop cancels
// the loop and waits for it.
func Module(s *di.Scope) {
	s.Wire[*Mailer](newMailer).
		Eager().
		Go(func(ctx context.Context, m *Mailer) error { return m.run(ctx) })
}

HTTP とリクエストスコープ

dihttp.Middlewareはリクエストごとに子スコープを開き、そこに*http.Requestを持たせます。アプリケーションスコープでScopedと宣言されたサービスは、シングルトンからでもリクエスト固有の値からでも同じように、リクエストスコープごとに 1 回だけ生成され、スコープとともに停止します。ハンドラはdi.FromContextで自分のスコープに辿り着くか、ハンドラ型のメソッドについてそれを代わりに行うdihttp.Handleを使います。

1 つのハンドラ型が 1 つのリソースを受け持ち、ルートごとにメソッドを持つので、依存の宣言は一度で済みます。dihttp.Handle((*users).show)はリクエストスコープからその型を解決してメソッドを呼びます。メソッド式が両方を名指しするので型引数は要りません。usersは呼び出し元を必要とするのでScopedhealthはリクエストから何も取らない普通のシングルトンで、Handleはどちらのライフタイムにも従います。このパッケージはModule以外に何も公開しません。キーは型なので、ほかの誰も名前を書けないハンドラは、ほかの誰も解決できないサービスです。

middleware 自身がリクエストごとに子を開くためのスコープを必要とするので、dihttp.Moduleがそれをサービスとして登録し、サーバはほかと同じように引数で受け取ります。OnDrainは何かが停止される前に走るので、http.Server.Shutdownが待っている間、処理中のリクエストは自分のスコープを保ったままです。

internal/api/api.go
// Package api serves HTTP. The server is a singleton with a lifecycle. A
// handler type covers one resource, with a method per route; it is Scoped
// when it needs the request, and built in the request scope the dihttp
// middleware opens, or a plain singleton when it does not.
//
// Nothing here is exported but Module. Keys are types, so these handlers are
// services only this package can name, let alone resolve.
package api

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"

	"golang.yandex/di"
	"golang.yandex/di/dihttp"
	"golang.yandex/di/examples/guide/internal/config"
	"golang.yandex/di/examples/guide/internal/mail"
	"golang.yandex/di/examples/guide/internal/storage"
)

// caller is who is making the request. It depends on the *http.Request, which
// only a request scope provides.
type caller struct{ name string }

func newCaller(r *http.Request) *caller { return &caller{name: r.Header.Get("X-User")} }

// users is built once per request, from singletons and request-scoped values
// alike, and serves every route about users.
type users struct {
	store  storage.Store
	mail   *mail.Mailer
	caller *caller
}

func newUsers(store storage.Store, m *mail.Mailer, c *caller) *users {
	return &users{store: store, mail: m, caller: c}
}

func (u *users) show(w http.ResponseWriter, r *http.Request) {
	user, err := u.store.Find(r.Context(), r.PathValue("id"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}
	fmt.Fprintln(w, user.Name)
}

func (u *users) greet(w http.ResponseWriter, r *http.Request) {
	user, err := u.store.Find(r.Context(), r.PathValue("id"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}
	u.mail.Send(u.caller.name + " greets " + user.Name)
	w.WriteHeader(http.StatusAccepted)
}

// health needs nothing from the request, so it is an ordinary singleton. It
// asks the store, which is the storage package's contract; the connection
// behind it is that package's own business.
type health struct{ store storage.Store }

func newHealth(store storage.Store) *health { return &health{store: store} }

func (h *health) check(w http.ResponseWriter, r *http.Request) {
	if err := h.store.Ping(r.Context()); err != nil {
		http.Error(w, err.Error(), http.StatusServiceUnavailable)
		return
	}
	fmt.Fprintln(w, "ok")
}

// newServer builds the routes. Each one resolves its handler from the
// request scope the middleware opens; dihttp.Module provides the middleware.
func newServer(cfg config.Config, mw dihttp.Middleware) *http.Server {
	mux := http.NewServeMux()
	mux.Handle("GET /users/{id}", dihttp.Handle((*users).show))
	mux.Handle("POST /users/{id}/greet", dihttp.Handle((*users).greet))
	mux.Handle("GET /healthz", dihttp.Handle((*health).check))
	return &http.Server{Addr: cfg.Addr, Handler: mw(mux)}
}

// Module registers the request-scoped values, the handlers and the server,
// with the hooks that bind, drain and close it.
func Module(s *di.Scope) {
	s.Wire[*caller](newCaller).Scoped()
	s.Wire[*users](newUsers).Scoped()
	s.Wire[*health](newHealth)
	s.Wire[*http.Server](newServer).
		Eager().
		OnStart(func(_ context.Context, srv *http.Server) error {
			// Bind synchronously, so a busy port fails Start; serve in the
			// background, and take the application down if serving stops.
			ln, err := net.Listen("tcp", srv.Addr)
			if err != nil {
				return err
			}
			go func() {
				if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
					s.Shutdown(err)
				}
			}()
			return nil
		}).
		// Draining runs before anything is stopped, so requests still in
		// flight keep their scopes and everything those depend on.
		OnDrain(func(ctx context.Context, srv *http.Server) error { return srv.Shutdown(ctx) }).
		OnStop(func(_ context.Context, srv *http.Server) error { return srv.Close() })
}

オーバーライドによるテスト

di.Testはモジュールを新しいスコープに配線し、テストが終わるとそれを停止します。設定をオーバーライドするだけでストアを別のデータベースに向けられますし、偽物にするならs.Value(&fake).Override()でまったく同じです。このマーカーは必須で、これがない 2 回目の登録は拒否されるため、テストが本番の配線のまま偶然通ってしまうことはありません。

internal/storage/storage_test.go
package storage_test

import (
	"strings"
	"testing"

	"golang.yandex/di"
	"golang.yandex/di/examples/guide/internal/config"
	"golang.yandex/di/examples/guide/internal/storage"
)

// The production modules, with the configuration overridden: the store is
// built against a database that dials the test DSN, and nothing else in the
// wiring changes.
func TestStoreFindsUsers(t *testing.T) {
	s := di.Test(t, config.Module, storage.Module)
	s.Value(config.Config{DSN: "sqlite://memory"}).Override()

	user, err := s.Get[storage.Store]().Find(t.Context(), "42")
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(user.Name, "sqlite://memory") {
		t.Fatalf("store was built against the wrong database: %q", user.Name)
	}
}

ラッパーは同じモジュールを通して、何も偽物にせずにテストします。2 回引いて 1 回ヒット。自分のキャッシュに名前を書けるのは cache パッケージだけなので、これは内部テストです。

internal/cache/cache_test.go
package cache

import (
	"testing"

	"golang.yandex/di"
	"golang.yandex/di/examples/guide/internal/config"
	"golang.yandex/di/examples/guide/internal/storage"
)

// An internal test, since the cache is private: only this package can name
// *cache, so only this package can read its hits.
func TestSecondLookupIsAHit(t *testing.T) {
	s := di.Test(t, config.Module, storage.Module, Module)
	store := s.Get[storage.Store]()
	for range 2 {
		if _, err := store.Find(t.Context(), "42"); err != nil {
			t.Fatal(err)
		}
	}
	if hits := s.Get[*cache]().hits; hits != 1 {
		t.Fatalf("want one hit, got %d", hits)
	}
}

グラフを見る

何も作られないうちから、ExplainWireに渡されたコンストラクタが宣言した内容を描きます。破線の辺、ストアの上のラッパー、そして尋ねたサービスを宣言しているのは誰か。生成後は実際に起きたことを実線で描き、続けてそれを必要としたものを描きます。Graphはアプリケーション全体を Graphviz DOT として出力します。これは起動時のapp.Explain[storage.Store]()で、リポジトリのテストで固定されています。

app.Explain[storage.Store]()
storage.Store: singleton wrapper in root, not built (provided at internal/cache/cache.go:70)
├╌╌ storage.Store: singleton in root, not built (provided at internal/storage/storage.go:56)
│   └╌╌ *storage.db: singleton in root, not built (provided at internal/storage/storage.go:53)
│       └╌╌ config.Config: value in root, not built (provided at internal/config/config.go:27)
└╌╌ *cache.cache: singleton in root, not built (provided at internal/cache/cache.go:69)
declared by: *api.users in root, *api.health in root

Modulesは同じ情報をサービス単位ではなくモジュール単位で読んだものです。各モジュールが何を提供し、何を必要としてそれを誰が提供するか、何を包んでいるか、そしてどのコンストラクタがクロージャか。登録内容から導かれるので、別途維持すべきマニフェストはありません。これは何も作られる前のアプリケーション全体で、これもテストで固定されています。

app.Modules()
registered directly
  provides   *slog.Logger
config.Module
  provides   config.Config
storage.Module
  provides   *storage.db, storage.Store
  needs      config.Config ← config.Module
cache.Module
  provides   *cache.cache
  wraps      storage.Store ← storage.Module
mail.Module
  provides   *mail.Mailer
  needs      *slog.Logger ← registered directly
dihttp.Module
  provides   dihttp.Middleware
  unchecked  dihttp.Middleware (closures: needs known when they run)
api.Module
  provides   *api.caller, *api.users, *api.health, *http.Server
  needs      *http.Request ← owed to a resolving scope
             storage.Store ← cache.Module
             *mail.Mailer ← mail.Module
             config.Config ← config.Module
             dihttp.Middleware ← dihttp.Module

動かす

Ctrl-C はサーバに処理中のリクエストを終わらせ、メーラーをキャンセルし、データベースを閉じます。まさにこの順序で、そして失敗したフックがあればすべて報告します。

shell
git clone https://github.com/yandex/di && cd di
go run ./examples/guide/cmd/api &
curl -H 'X-User: ada' localhost:8080/users/42

READMEが残りを扱います。グループ、オブザーバ、そしてコンテナが強制するルール。仕組みは逆向きに、Getから値までをたどります。ライフタイム、フェーズ、循環、シャットダウンを図つきで。