这个页面本身就是一个应用,从上往下读:一个小型 HTTP 服务,带一个数据库、挡在它前面的一层缓存、一个在后台运行的邮件服务,以及一个按请求创建的处理器。每个代码块都是仓库中examples/guide里的一个文件。Go 工具链在每次改动时都会编译并测试它们,所以你看到的就是实际运行的代码。
应用的结构
每个包拥有自己的服务,并只暴露一个函数Module来注册它们。除此之外,包没有任何特别之处:构造函数就是普通函数,类型就是普通类型,唯一知道整张图的文件是main。
cmd/api/main.go 组合模块,检查依赖图,运行
internal/config/ 配置,注册为一个值
internal/storage/ 数据库,以及构建在它之上的存储
internal/cache/ 包在存储外面的缓存
internal/mail/ 一个后台 worker
internal/api/ HTTP 服务器及其处理器
起点:main
读这个应用就该从这里开始。main创建 logger,把它注册成一个服务——这样任何构造函数都能把它当作参数接收——然后检查图并运行。应用本身就是它所应用的那份模块清单:模块就是一个包导出的函数,用来把该包的服务注册到作用域里。后面每一步都会展开其中一个模块,顺序与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注册。它和其他键没有区别:下一步里 store 的构造函数通过参数拿到它,而测试用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)
}后台 worker
用Go注册的 worker 与它的服务同寿:服务启动时它在自己的 goroutine 里启动,由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到一个值:生命周期、阶段、循环和关闭,并配有图示。