Go 基础体系 · 第 87/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go 依赖注入:手工装配、Uber Fx 与 Wire 的选择
本文以 Go 1.26.4 为基准,示例依赖固定为 go.uber.org/fx v1.24.0 与 github.com/google/wire v0.7.0。Wire 仓库已在 2025 年归档,v0.7.0 适合解释和维护存量生成代码,但新项目不应在没有接管方案时把它作为默认选择。Go 的依赖注入(Dependency Injection,DI)首先是普通参数传递:对象不在内部寻找依赖,而由更外层创建并交给它。框架只是管理装配图,不能替代清晰的包边界、资源所有权和错误契约。
1. DI 解决的是依赖可见性
假设文章服务需要仓储、时钟和发布器。若方法内部读取全局数据库、调用 time.Now 并访问全局消息客户端,调用签名看不出真实成本,测试还会互相污染。改成构造函数注入后,依赖图成为编译器可检查的代码:
type Repository interface {
Save(context.Context, Article) error
}
type Publisher interface {
Publish(context.Context, Article) error
}
type Service struct {
repo Repository
publisher Publisher
now func() time.Time
}
接口由消费方定义,只包含当前用例需要的方法。具体实现通常仍返回 *postgres.Repository 等具体类型;不要为了“可注入”给每个结构体机械创建同名接口。DI 的收益是依赖、替换点和生命周期可见,而不是接口数量更多。
2. 构造函数建立不变量
构造函数应完成纯内存装配和参数校验,不应偷偷连接网络、启动 goroutine 或注册全局变量。必需依赖使用明确参数:
func NewService(repo Repository, publisher Publisher, now func() time.Time) (*Service, error) {
if repo == nil {
return nil, errors.New("repository is required")
}
if publisher == nil {
return nil, errors.New("publisher is required")
}
if now == nil {
return nil, errors.New("clock is required")
}
return &Service{repo: repo, publisher: publisher, now: now}, nil
}
构造成功意味着对象处于可用状态。可选项较多时先考虑配置结构;只有公开 API 确实需要向后兼容地扩展选项,才使用 functional options。不要接受 map[string]any 或容器对象再按字符串取依赖,那会把编译期错误推迟到运行期。
3. 手工装配是默认基线
中小型服务最清楚的 composition root 通常位于 cmd/article-api。入口加载配置,创建长生命周期资源,再创建业务和传输层:
func buildApp(ctx context.Context, cfg Config) (*App, error) {
db, err := sql.Open("postgres", cfg.DatabaseDSN)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
repo := postgres.NewRepository(db)
service, err := article.NewService(repo, cfg.Publisher, time.Now)
if err != nil {
db.Close()
return nil, fmt.Errorf("new article service: %w", err)
}
return NewApp(db, httpapi.NewServer(cfg.Addr, service)), nil
}
这里创建数据库的代码拥有失败时关闭它的责任。装配重复只是几行明确代码时,不值得引入容器。可以把 buildApp 分成按能力命名的小 provider,但不要拆成只转发一次的层级。
4. 生命周期与所有权必须独立于类型依赖
“A 需要 B”不等于“A 负责关闭 B”。通常最外层创建数据库、客户端、Exporter 和服务器,也负责按反向顺序关闭。业务服务借用仓储,不应暴露一个会误关共享连接池的 Close。
func run(ctx context.Context, app *App) error {
errCh := make(chan error, 1)
go func() { errCh <- app.Server.ListenAndServe() }()
select {
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve HTTP: %w", err)
}
return nil
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := app.Server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown HTTP server: %w", err)
}
return app.DB.Close()
}
每个 goroutine 都有退出条件和等待点。关闭使用新的有界 context,因为接收信号的根 context 已取消;但请求执行使用原请求 context。构造函数、Start、Stop 三阶段分离后,部分启动失败也能回滚。
5. 并发安全不是容器提供的
容器可能并发调用构造函数,或者多个请求共享同一实例,但它不会让实例自动并发安全。单例数据库池本来就支持并发;包含 map、buffer 或可变配置的对象必须自行加锁、使用不可变快照或限制所有权。
context.Context 是一次调用的生命周期信号,应作为方法第一个参数向下传播,不能注入到长期 Service 字段。把启动 context 保存到单例后,请求可能继承错误的 deadline;把请求 context 放进容器则会把 request scope 扩散成隐式状态。请求级值应由 handler 明确传给 service。
6. 测试直接构造目标对象
单元测试不需要启动 DI 框架。手写 fake 能同时记录输入和模拟错误:
type fakeRepository struct {
saved []Article
err error
}
func (f *fakeRepository) Save(_ context.Context, article Article) error {
f.saved = append(f.saved, article)
return f.err
}
func TestServiceCreate(t *testing.T) {
repo := &fakeRepository{}
service, err := NewService(repo, nopPublisher{}, func() time.Time {
return time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
})
if err != nil {
t.Fatalf("NewService() error = %v", err)
}
if _, err := service.Create(context.Background(), "DI"); err != nil {
t.Fatalf("Create() error = %v", err)
}
if len(repo.saved) != 1 {
t.Fatalf("saved count = %d, want 1", len(repo.saved))
}
}
集成测试再验证完整装配能启动和关闭。单测测试业务契约,装配测试测试依赖图,两者边界不同。不要让 fake 模拟复杂 SQL、事务或消息协议;那些行为应由真实适配器测试覆盖。
7. Fx 的运行时依赖图
Fx v1.24.0 基于 dig,通过构造函数的参数和返回类型建立运行时图。fx.Provide 注册 provider,只有被 fx.Invoke 或其他已需节点触达时才实例化;构造结果在一个 App 内默认复用。
app := fx.New(
fx.Provide(
loadConfig,
openDatabase,
postgres.NewRepository,
article.NewService,
newHTTPServer,
),
fx.Invoke(registerHTTPServer),
)
startCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := app.Start(startCtx); err != nil {
return fmt.Errorf("start app: %w", err)
}
provider 返回错误时,Fx 停止构建并报告依赖路径。使用 fx.New 后仍应显式 Start/Stop,便于入口决定信号、超时和退出码;app.Run() 适合简单进程,但控制权更少。
8. Fx 生命周期 Hook 的正确边界
实现 fx.Lifecycle 的函数登记启动与停止动作。OnStart 应在资源真正可服务后返回,不能只启动 goroutine 就把同步错误丢掉:
func registerHTTPServer(lc fx.Lifecycle, server *http.Server, listener net.Listener) {
done := make(chan error, 1)
lc.Append(fx.Hook{
OnStart: func(context.Context) error {
go func() { done <- server.Serve(listener) }()
return nil
},
OnStop: func(ctx context.Context) error {
if err := server.Shutdown(ctx); err != nil {
return fmt.Errorf("shutdown HTTP server: %w", err)
}
err := <-done
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
},
})
}
监听器最好在 provider 中同步创建,这样端口占用会让启动直接失败。Hook 按启动顺序执行、按相反顺序停止;每个 Hook 都受调用方 deadline 约束。后台组件必须保存完成信号,不能 fire-and-forget。
9. FxIn、FxOut、命名值与值组
参数多时可嵌入 fx.In,一次返回多个值可嵌入 fx.Out。同类型有多个实例时使用 name;插件集合使用 group:
type HandlerParams struct {
fx.In
Logger *slog.Logger
Handlers []Route `group:"routes"`
}
type RouteResult struct {
fx.Out
Route Route `group:"routes"`
}
值组顺序不应承载语义;需要稳定顺序就给元素显式优先级并排序。name/group 是解决真实歧义的工具,不应给所有依赖贴字符串标签。重命名标签不会得到编译器重构保护,应由启动测试覆盖。
fx.Annotate 可用 fx.As 把具体实现提供为接口,用 fx.ParamTags/ResultTags 标注参数结果。优先让普通构造签名保持可读,只在不能修改第三方构造函数或需要多绑定时注解。
10. Fx 的装饰、替换与测试边界
fx.Decorate 可包装已有依赖,例如给仓储增加指标;但层层装饰会让实际类型和顺序难追踪。测试完整 App 时可用 fxtest.New,并通过 fx.Replace 替换外部资源:
func TestApplicationGraph(t *testing.T) {
app := fxtest.New(t,
ProductionModule,
fx.Replace(Config{Addr: "127.0.0.1:0"}),
fx.Replace(fx.Annotate(
&fakeRepository{},
fx.As(new(article.Repository)),
)),
)
app.RequireStart().RequireStop()
}
此类测试验证无缺失依赖、重复 provider 和生命周期死锁,不替代业务单测。生产模块不要依赖 fxtest;替换只发生在测试 composition root。
11. Wire 是生成器,不是运行时容器
Wire v0.7.0 读取 provider set,在构建期生成普通 Go 装配代码。声明文件带 wireinject build tag:
//go:build wireinject
package main
func initializeApp(ctx context.Context, cfg Config) (*App, func(), error) {
wire.Build(
openDatabase,
postgres.NewRepository,
wire.Bind(new(article.Repository), new(*postgres.Repository)),
article.NewService,
NewApp,
)
return nil, nil, nil
}
执行 go run github.com/google/wire/cmd/wire@v0.7.0 ./cmd/article-api 生成 wire_gen.go。占位返回不会进入普通构建。生成代码显式调用构造函数,运行时无反射容器;若 provider 返回 cleanup,Wire 会组合清理函数,并在后续构造失败时逆序清理已创建资源。
12. Wire 的生成所有权与归档风险
生成文件通常提交仓库,使普通消费者不必安装 Wire。CI 必须固定 v0.7.0 重生成并检查差异:
go run github.com/google/wire/cmd/wire@v0.7.0 ./cmd/article-api
gofmt -w ./cmd/article-api/wire_gen.go
git diff --exit-code -- ./cmd/article-api/wire_gen.go
go test ./...
Wire 已归档意味着不会主动适配未来语言、工具链和安全问题。存量项目应保存生成结果、固定版本并准备手工接管生成代码;新项目若图不大,首选手工装配。生成器只负责创建图,不负责运行期启动顺序、信号处理或请求 scope。
13. 选择手工、Fx 还是 Wire
手工装配适合依赖图较小、团队希望编译期直接可见和启动流程明确的服务,也是比较其他方案的基线。Fx 适合大量可选模块、插件和值组,以及多个组件需要统一生命周期的单体或平台;代价是部分错误到启动时才暴露、诊断依赖图需要理解 Fx。Wire 适合已使用它且重视生成后普通代码的存量项目,但归档状态提高了长期维护风险。
不要按构造函数数量机械选型。先观察痛点:装配冲突是否频繁、模块是否真正动态、生命周期是否复杂、团队能否诊断框架错误。一个仓库也可在外层用 Fx 管模块、模块内部手工构造,但混用必须减少而非增加理解成本。
14. 失败诊断应从依赖路径开始
手工装配错误通常是编译失败或带上下文的构造错误。Fx 常见问题包括缺失类型、同类型重复提供、依赖环、Hook 超时和 provider 返回 typed nil。先读完整错误链,确认请求类型和提供类型是否精确一致;*T、T 与接口是不同键。可用 fx.ValidateApp 在不启动资源的情况下验证图,用 fx.VisualizeError 辅助展示失败路径。
Wire 失败通常来自没有 provider、多个绑定或 cleanup 签名不合规。不要删除生成文件后盲目重跑;确认 build tag、包路径和 provider set。启动卡住时抓 goroutine dump,区分构造函数做了阻塞 I/O,还是 OnStart/OnStop 没有响应 context。
15. 性能与安全边界
手工和 Wire 的调用接近普通函数;Fx 的图构建和反射主要发生在启动期,通常不是请求热路径问题。真正的性能风险是错误 scope:每请求创建数据库连接池、重复解析模板,或让单例持有无界缓存。用 profile 和启动时长指标验证,不要因“反射”二字提前优化。
配置与秘密也应显式注入,但日志、图可视化和错误不能输出 DSN 密码或令牌。插件式 group 若来自不可信配置,要有允许列表;DI 容器不是权限边界。生产构造失败应 fail fast,编排系统再按退避策略重启,不能在 provider 内永久重试掩盖错误配置。
16. CI 与生产部署清单
CI 至少验证普通业务测试、竞态、静态检查、依赖图和生成一致性:
gofmt -w ./cmd ./internal
go vet ./...
go test ./...
go test -race ./...
go test -run '^TestApplicationGraph$' ./cmd/article-api
生产入口应记录 Go 版本、提交和模块版本;为启动和关闭设置独立超时;先停止接流量,再等待请求和 worker,最后关闭数据库与观测 exporter。每个外部资源只有一个明确所有者,每个后台 goroutine 都能停止并被等待。满足这些条件时,DI 是可读的对象图;不满足时,无论采用手工、Fx 还是 Wire,都只是把隐藏生命周期换了一个位置。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go 并发工具:errgroup、singleflight、semaphore 与 ants
- 下一篇:Go 测试生态:Testify、GoMock、Mockery 与 Testcontainers
- 延伸:Go 项目工程化:目录、依赖注入、代码生成与质量门禁
- 延伸:Go 方法与接口:隐式实现、方法集和小接口设计
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论