Go 基础体系 · 第 87/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 依赖注入:手工装配、Uber Fx 与 Wire 的选择

本文以 Go 1.26.4 为基准,示例依赖固定为 go.uber.org/fx v1.24.0github.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。构造函数、StartStop 三阶段分离后,部分启动失败也能回滚。

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。先读完整错误链,确认请求类型和提供类型是否精确一致;*TT 与接口是不同键。可用 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 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。