golang.yandex/di

Dependency injection для Go на дженерик-методах.

Регистрируйте сервисы, резолвите их через s.Get[T]() — контейнер сам создаст, запустит, остановит и проверит граф. Без кодогенерации и без зависимостей.

go get golang.yandex/di

Эта страница — одно приложение, которое читается сверху вниз: небольшой HTTP-сервис с базой данных, кэшем перед ней, мейлером в фоне и обработчиком, который создаётся на каждый запрос. Каждый блок кода — это файл из examples/guide в репозитории. Go-тулчейн компилирует и тестирует их при каждом изменении, так что вы видите ровно то, что работает.

Устройство приложения

Каждый пакет владеет своими сервисами и предоставляет одну функцию, Module, которая их регистрирует. Больше в пакете ничего особенного: конструкторы — обычные функции, типы — обычные типы, и единственный файл, знающий весь граф, — это main.

examples/guide
cmd/api/main.go    собирает модули, проверяет граф, запускает
internal/config/   настройки, зарегистрированные как значение
internal/storage/  база данных и хранилище поверх неё
internal/cache/    кэш, обёрнутый вокруг хранилища
internal/mail/     фоновый воркер
internal/api/      HTTP-сервер и его обработчики

Отсюда всё начинается: main

Читать приложение стоит именно отсюда. main создаёт логгер, регистрирует его как сервис — чтобы любой конструктор мог принять его параметром, — проверяет граф и запускает всё. Само приложение — это список модулей, которые оно применяет: модуль — это функция, которую пакет экспортирует, чтобы зарегистрировать свои сервисы в скоупе. Каждый следующий шаг разбирает один из этих модулей, в том порядке, в котором их применяет Use.

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 применяет модули по порядку и приписывает каждую регистрацию тому модулю, который её сделал, — именно это называет ошибка, когда два модуля сталкиваются. Повторная регистрация ключа без 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()) }

Конструкторы и модуль

newDB и newPGStore принимают параметрами то, что им нужно, и возвращают то, что создают; newDB может завершиться ошибкой. Ни тот, ни другой не импортирует контейнер. Этому приложению нигде не нужна общая форма, Provide, принимающая замыкание над скоупом; но обе формы свободно сочетаются, когда она всё-таки понадобится.

Module передаёт их через Wire. Аргумент типа — это ключ, под которым отдаётся сервис: *db для соединения и интерфейс Store для хранилища, поскольку *pgStore присваиваем этому интерфейсу. Параметры конструктора, переданного в Wire, — это его зависимости, и именно так контейнер знает граф ещё до того, как хоть что-то создано. Хуки типизированы значением, которое получают, и выполняются при старте и остановке приложения.

Приватность здесь — приватность самого Go. Ключи — это типы, поэтому *db, который может назвать только этот пакет, — сервис, который только этот пакет может зарезолвить. Пакет экспортирует свой контракт, Store и User, и свой 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)
}

Фоновые воркеры

Воркер, зарегистрированный через Go, живёт ровно столько, сколько его сервис: запускается в собственной горутине, когда сервис стартует, отменяется по 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 в скоупе приложения, создаются по одному разу на скоуп запроса — из синглтонов и из значений самого запроса одинаково — и останавливаются вместе с ним. Обработчик добирается до своего скоупа через di.FromContext или через dihttp.Handle, который делает это за метод типа-обработчика.

Тип-обработчик покрывает один ресурс, по методу на маршрут, поэтому его зависимости объявляются один раз. dihttp.Handle((*users).show) резолвит тип из скоупа запроса и вызывает метод; выражение метода называет и то и другое, так что аргумент типа не нужен. users объявлен Scoped, потому что ему нужен вызывающий; health ничего не берёт из запроса и является обычным синглтоном, а 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(). Маркер обязателен: повторная регистрация без него отклоняется, поэтому тест не сможет случайно пройти на боевой конфигурации.

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)
	}
}

Обёртка тестируется через те же модули, без единой заглушки: два обращения, одно попадание. Это внутренний тест, потому что назвать собственный кэш может только сам пакет 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)
	}
}

Как увидеть граф

Ещё до того, как что-то создано, Explain рисует то, что объявили конструкторы, переданные в Wire: пунктирные рёбра, обёртку над хранилищем и то, какой сервис объявляет тот, о котором вы спросили. После создания он рисует сплошными линиями то, что произошло на самом деле, а следом — кому это понадобилось. 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 к значению: времена жизни, фазы, циклы и завершение работы, с диаграммами.