golang.yandex/di

基于泛型方法的 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/     一个后台 worker
internal/api/      HTTP 服务器及其处理器

起点:main

读这个应用就该从这里开始。main创建 logger,把它注册成一个服务——这样任何构造函数都能把它当作参数接收——然后检查图并运行。应用本身就是它所应用的那份模块清单:模块就是一个包导出的函数,用来把该包的服务注册到作用域里。后面每一步都会展开其中一个模块,顺序与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注册。它和其他键没有区别:下一步里 store 的构造函数通过参数拿到它,而测试用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可能失败。两者都不导入容器。这个应用里没有任何地方需要通用形式Provide, 它接收一个对作用域的闭包;不过两种形式可以自由混用。

Module通过Wire把它们交给容器。类型参数就是服务对外提供时使用的键:连接用*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 与它的服务同寿:服务启动时它在自己的 goroutine 里启动,由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)从请求作用域解析出该类型并调用方法;方法表达式同时指明了两者,所以不需要类型参数。usersScoped,因为它需要调用方;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到一个值:生命周期、阶段、循环和关闭,并配有图示。