Эта страница — одно приложение, которое читается сверху вниз: небольшой HTTP-сервис с базой данных, кэшем перед ней, мейлером в фоне и обработчиком, который создаётся на каждый запрос. Каждый блок кода — это файл из examples/guide в репозитории. Go-тулчейн компилирует и тестирует их при каждом изменении, так что вы видите ровно то, что работает.
Устройство приложения
Каждый пакет владеет своими сервисами и предоставляет одну функцию, Module, которая их регистрирует. Больше в пакете ничего особенного: конструкторы — обычные функции, типы — обычные типы, и единственный файл, знающий весь граф, — это main.
cmd/api/main.go собирает модули, проверяет граф, запускает
internal/config/ настройки, зарегистрированные как значение
internal/storage/ база данных и хранилище поверх неё
internal/cache/ кэш, обёрнутый вокруг хранилища
internal/mail/ фоновый воркер
internal/api/ HTTP-сервер и его обработчики
Отсюда всё начинается: main
Читать приложение стоит именно отсюда. main создаёт логгер, регистрирует его как сервис — чтобы любой конструктор мог принять его параметром, — проверяет граф и запускает всё. Само приложение — это список модулей, которые оно применяет: модуль — это функция, которую пакет экспортирует, чтобы зарегистрировать свои сервисы в скоупе. Каждый следующий шаг разбирает один из этих модулей, в том порядке, в котором их применяет Use.
// 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() — и все сервисы ниже по графу подхватывают подмену.
// 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; у соединения есть жизненный цикл, которым управляет контейнер, а в остальном оно никого не касается.
// 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: сквозная функциональность надстраивается над экспортированным контрактом, а не над внутренностями пакета.
// 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, а не создаётся при первом обращении.
// 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 их дожидается.
// 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(). Маркер обязателен: повторная регистрация без него отклоняется, поэтому тест не сможет случайно пройти на боевой конфигурации.
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.
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]() на старте, зафиксированный тестом в репозитории.
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 rootModules — та же информация, прочитанная по модулям, а не по сервисам: что каждый предоставляет, что ему нужно и кто это отдаёт, что он оборачивает и какие из его конструкторов — замыкания. Она выводится из самих регистраций, так что нет манифеста, который пришлось бы поддерживать в актуальном виде. Это всё приложение до того, как что-либо создано, и тоже зафиксировано тестом.
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 даёт серверу завершить запросы в полёте, отменяет мейлер, закрывает базу — именно в этом порядке — и сообщает о каждом хуке, который завершился ошибкой.
git clone https://github.com/yandex/di && cd di
go run ./examples/guide/cmd/api &
curl -H 'X-User: ada' localhost:8080/users/42README покрывает остальное: группы, наблюдатели и правила, которые контейнер обеспечивает. Как это устроено идёт в обратную сторону, от Get к значению: времена жизни, фазы, циклы и завершение работы, с диаграммами.