在 NewUserService 这类构造函数中直接调用 sql.Open 是典型的反模式:测试无法注入替身,跨环境也无法复用。根源是依赖注入未落实:组件只声明所需依赖,创建与组装由调用方负责。在 Go 中,多数场景的依赖注入不需要库:构造函数传参,main 中逐个创建、按序组装。Java 式注解扫描加运行时容器在 Go 生态中很少见,标准库与主流开源项目的"依赖注入"都相当朴素:装配代码显式可读,启动路径才可预测、可调试。

手工装配并非适用于所有规模。服务增至数十个、需要优雅停机、健康检查与请求级作用域时,main 会膨胀为难以维护的脆弱清单——新增依赖,下游每层调用点都要修改签名,此时才应引入工具。google/wire、uber-go/dig、uber-go/fx、samber/do 覆盖了从手工装配到容器框架的连续区间:编译期代码生成、运行时反射容器、反射之上的应用框架、泛型服务容器。基本原则:使用能解决问题的最简单手段,避免过度设计。

手工装配的写法

依赖应一律通过构造函数注入,构造函数内部不应自行创建或查找依赖:

// ✅ 依赖显式声明,测试时可整体替换
type UserService struct {
    db     UserStore
    mailer Mailer
    logger *slog.Logger
}

func NewUserService(db UserStore, mailer Mailer, logger *slog.Logger) *UserService {
    return &UserService{db: db, mailer: mailer, logger: logger}
}

// ❌ 构造函数自行建立数据库连接:测试无法替换,也无法跨环境复用
func NewUserService() *UserService {
    db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    return &UserService{db: db}
}

main(或启动入口)是唯一的组合根(composition root),装配按"配置 → 基础设施 → 仓库 → 服务 → 传输"自底向上排列。 依赖必须按序初始化:

func main() {
    cfg := LoadConfig()
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    db, err := postgres.Connect(cfg.DatabaseURL) // 基础设施
    if err != nil {
        logger.Error("database connection failed", "error", err)
        os.Exit(1)
    }
    defer db.Close()

    userRepo := postgres.NewUserRepository(db)          // 仓库
    userSvc := service.NewUserService(userRepo, logger) // 服务
    handler := http.NewHandler(userSvc, logger)         // 传输
    // ...启动 HTTP server
}

手工装配就是普通 Go 代码:有编译期类型检查,支持断点与 IDE 跳转,不依赖任何框架机制。依赖图浅、服务少的项目,这是成本最低的方案。

接口定义在消费方,构造函数返回具体类型。 这是手工 DI 可行的基础——结构体与接口 所述 "accept interfaces, return structs" 在装配场景是硬性约束:注入参数声明为接口,替换实现时无需修改业务代码。

何时引入 DI 库

服务少于 10 个、依赖图分层清晰时,手工装配即为正确选择,不应引入任何库。 DI 框架解决的是"装配代码失控",而非"装配"本身;其收益随服务数量与互连程度增长,应在增长出现后再兑现。

出现以下信号时,再认真评估引入 DI 库:

  • 服务 15~20 个以上,新增服务需修改 main 中一长串调用点;
  • 需要生命周期管理:健康检查、优雅停机、受控的启动/停止顺序;
  • 需要惰性初始化(lazy)或请求级作用域(scoped container);
  • logger、tracer 等横切依赖需要穿透每个构造函数。

选型看三个维度:错误暴露时机(编译期还是启动时)、装配是否显式、框架重量;四个工具在这一连续区间上各占一档。

wire,编译期生成装配代码

wire 在编译期解析依赖图,生成纯 Go 的构造函数调用序列——没有运行时容器,没有反射,零运行时开销。开发者编写 provider 与 injector 声明,wire ./... 生成 wire_gen.go;依赖图不完整,代码生成阶段即报错:

// providers.go:provider 编组成 set,interface 绑定必须显式声明
var ServiceSet = wire.NewSet(
    NewUserService,
    wire.Bind(new(UserStore), new(*PostgresUserStore)),
)

// wire.go://go:build wireinject 保证桩代码不进二进制
//go:build wireinject

func InitializeApp() (*App, error) {
    wire.Build(InfraSet, ServiceSet, NewApp)
    return nil, nil // 函数体由 wire 生成,这里只是占位
}

生成结果与手写装配代码一致,顺序与类型检查相同:

// wire_gen.go —— 自动生成,不应手工修改
func InitializeApp() (*App, error) {
    config := NewConfig()
    database, err := NewDatabase(config)
    if err != nil {
        return nil, err
    }
    store := NewPostgresUserStore(database)
    userService := NewUserService(store)
    return NewApp(userService), nil
}

三条硬性规则://go:build wireinject 必须是 injector 文件第一行;wire_gen.go 应提交进版本库但不应手工修改;构造函数签名变化后必须重跑 wire ./...,并在 CI 中用 wire check ./... 做编译期校验,确认生成代码未过期。遗漏 build tag 会使桩文件与生成文件重复定义同一函数,导致编译失败。

代价明确:换取编译期报错,多一个构建步骤;wire 不提供惰性加载与生命周期钩子,清理靠 provider 返回 func(),按构造逆序串成 cleanup 链;健康检查与优雅停机需自行实现。同一类型不允许有两个 provider,应使用命名类型区分(type PrimaryDSN string);接口绑定必须显式 wire.Bind,隐式满足行不通。google/wire 已于 2025 年 8 月归档,仓库只读、不再更新——功能完备,现有项目可继续使用,但不应期待修复与新特性。

dig,运行时反射容器

dig 用反射在运行时解析依赖图:注册 provider,按需 Invoke;构造函数惰性执行,每个类型在容器内只创建一次(容器内单例)。没有代码生成,代价是类型不匹配要到启动时才暴露:

func main() {
    c := dig.New()
    must(c.Provide(NewConfig))
    must(c.Provide(NewDatabase)) // 惰性:被需要时才真正执行
    must(c.Provide(NewUserService))

    err := c.Invoke(func(svc *UserService) { // dig 沿参数类型解析整条链
        svc.Run()
    })
    if err != nil {
        log.Fatal(err) // 错误附带依赖路径,便于定位失败点
    }
}

构造函数返回 error(而不是 panic)表示构造失败,dig 会将错误连同触发解析的依赖路径一起包装,便于定位失败点;Provide 阶段还能提前查出畸形的构造函数。

依赖较多时,应将参数聚合到嵌入 dig.In 的参数对象,用 tag 表达 name / optional / group:同类型多实例用 dig.Name("primary") 消歧;可选依赖标 optional:"true";多个实现聚合为一个切片用 group:"routes"。组内顺序不保证,需要顺序应从单一构造函数返回显式切片:

type HandlerParams struct {
    dig.In

    Logger *zap.Logger
    DB     *sql.DB
    Cache  *redis.Client  `optional:"true"` // 未提供则为零值
    DBRO   *sql.DB        `name:"readonly"` // 命名依赖
    Routes []http.Handler `group:"routes"`  // 值组
}

func NewHandler(p HandlerParams) *Handler { /* ... */ }

容器只应存在于组合根,不应作为参数传入业务代码。 否则即退化为服务定位器(service locator)反模式——依赖重新隐式,DI 带来的可测试性随之失效。dig 没有生命周期,需要 OnStart/OnStop 应使用 fx;dig.As 可将具体实现按接口注册,消费方只见接口。

fx,dig 之上增加生命周期

fx 在 dig 之上补充长运行服务需要的三项能力:生命周期钩子、模块系统、信号感知的 Run 循环。Provide/Invoke 与 dig 同构(fx.In/fx.Out 就是 dig.In/dig.Out 的再导出),掌握 dig 即可理解 fx 的装配:

func main() {
    fx.New(
        fx.Provide(NewConfig, NewDatabase, NewHTTPServer),
        fx.Invoke(RegisterRoutes), // Invoke 是触发器,未被引用的构造函数不会执行
    ).Run() // 阻塞直到 SIGINT/SIGTERM,再按逆序执行 OnStop
}

生命周期通过注入 fx.Lifecycle 注册:OnStart 必须快速返回,阻塞工作应放入 goroutine;OnStop 按依赖逆序执行,负责优雅关闭。 OnStart 阻塞会使后续钩子与整个启动流程挂起;钩子拿到的 ctx 受启动/停止超时约束(默认 15 秒),必须尊重取消:

func NewHTTPServer(lc fx.Lifecycle, cfg *Config) *http.Server {
    srv := &http.Server{Addr: cfg.Addr}
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            ln, err := net.Listen("tcp", srv.Addr)
            if err != nil {
                return err
            }
            go srv.Serve(ln) // 阻塞逻辑放入 goroutine,钩子立即返回
            return nil
        },
        OnStop: func(ctx context.Context) error { return srv.Shutdown(ctx) },
    })
    return srv
}

模块应按业务域划分而非按分层:一个 fx.Module 拥有该域的 provider、decorator 与生命周期,是可整体迁移的重用单元。预构建值(配置、命令行参数)用 fx.Supply,不应为此编写空构造函数;fx.Annotate 可不写 fx.Out 结构体直接打上 name/group/As 标签。

代价:fx 学习曲线最陡,fx.Annotate、fx.Decorate、fxevent 都需要学习;它接管启动、停止与信号处理,要求代码按其方式组织。长运行服务(HTTP server、worker)收益明显;CLI 工具或自管生命周期的宿主,使用裸 dig 更合适。上线前在 CI 中用 fx.New(...).Err() 校验依赖图,缺失 provider 与循环依赖在部署前即暴露。

do,泛型服务容器

do 用泛型(Go 1.18+)实现无反射、无代码生成的类型安全容器,核心 API 只有 Provide / Invoke / Shutdown。服务默认惰性创建,类型错误编译期即暴露——没有 build tag,也没有生成文件要维护:

injector := do.New(infrastructure.Package, service.Package) // do.Package 按域编组

do.Provide(injector, NewConfig)
do.Provide(injector, NewUserService) // provider 内部用 MustInvoke 获取依赖

svc := do.MustInvoke[*UserService](injector) // 惰性:首次请求才创建

provider 内部一律用 do.MustInvoke 系列获取依赖。MustInvoke 出错会 panic,但 do 会在外层 Invoke 处 recover 并还原为 error,因此既避免每层 if err != nil 样板,也不会泄漏 panic。同类型多实例用 ProvideNamed,瞬时对象用 ProvideTransient;实现 Shutdowner / HealthChecker 接口的服务会被自动识别,优雅停机与健康检查无需自行搭建:

server := do.MustInvoke[*http.Server](injector)
go server.ListenAndServe()

// 收到信号后触发全量优雅停机
_ = injector.ShutdownOnSignalsWithContext(ctx, os.Interrupt)

do 的定位是轻量:没有 fx 的模块与装饰器体系,也不生成代码,但单例/瞬时/惰性/作用域/容器克隆能力齐全。排查装配问题时,ExplainInjector 与自带 Web 界面可直接展示注入计划:

测试中如何替换依赖

DI 的验收标准只有一条:测试中不修改业务代码,即可替换任意依赖。 四种方案的替换方式如下:

  • 手工和 wire:直接 new。wire 生成的是普通构造函数,测试中手动注入 mock 即可:svc := NewUserService(mockStore, testCache)。
  • dig:每个测试新建容器,用 Decorate 覆盖实现、Invoke 驱动被测对象;CI 中可加 DryRun(true) 校验图完整性。
  • fx:fxtest + fx.Replace / fx.Populate。用 mock 替换真实 provider,再从依赖图中取出被测对象:
func TestUserService(t *testing.T) {
    var svc *UserService
    app := fxtest.New(t,
        fx.Provide(NewMockUserStore), // 替换真实实现
        fx.Provide(NewUserService),
        fx.Populate(&svc),            // 从依赖图中取出被测对象
    )
    app.RequireStart()
    defer app.RequireStop()
    // ...断言 svc 行为
}
  • do:克隆容器 + OverrideValue。从生产配置克隆隔离容器,只覆盖需要 mock 的边界,尤其适合集成测试:
testInjector := do.New()
do.OverrideValue[UserStore](testInjector, &MockUserStore{users: testUsers})
svc := do.MustInvoke[*UserService](testInjector)

mock 的边界永远是接口而非具体类型——testify 实战 中的 mock 三件套在此适用;配置类依赖的组织与注入见 CLI 与配置管理。

速查清单

关键规则:

  • 服务依赖应通过构造函数注入;不应使用全局变量或 init() 装配服务。
  • main 应为唯一组合根;容器不应外传,否则会退化为服务定位器。
  • 接口应定义在消费方;依赖图应保持浅平,A→B→C→D→E 这类深链表明设计存在问题。
  • 每个应用应使用一个容器;请求级隔离用作用域,不应每请求新建容器。
  • 构造函数应保持低开销且无副作用,副作用交给生命周期钩子处理。
  • 规模参考:少于 10 个服务手工装配;10~20 个评估 DI 库;超过 20 个或需生命周期管理时引入 DI 库。

四工具对比:

维度手工google/wireuber-go/diguber-go/fxsamber/do
解析时机编译期(手写)编译期(代码生成)运行时(反射)运行时(反射)编译期(泛型)
错误暴露go buildwire ./... 失败首次 Invoke / 启动启动时编译期
代码生成无wire_gen.go(需提交)无无无
显式程度最显式显式(声明式图)隐式(反射解析)隐式(反射解析)显式(类型参数)
惰性加载手动无(全量急切)内置内置内置(默认)
生命周期手写 defercleanup 函数(逆序)无OnStart/OnStop 钩子内置接口(停机/健康检查)
信号处理手写无无app.Run() 内置ShutdownOnSignals
适用规模< 10 服务中大型大型(不要框架时)大型长运行服务任意规模
学习曲线零中中高低
测试替换直接传 mock直接传 mock新建容器 + Decoratefxtest + Replace/Populate克隆 + OverrideValue

选型建议:小型项目手工装配;依赖图大且需要编译期安全选 wire(已归档);长运行服务需要生命周期选 fx;需要容器能力但不引入框架选 do。