Files
2026-05-24 11:42:38 +08:00

11 KiB
Raw Permalink Blame History

tags, create time
tags create time
go
dependency-injection
di
architecture
testing
refactoring
2026-04-29 15:30

Go 依赖注入(Dependency Injection)

概述

系统梳理 Go 语言中依赖注入的实践模式、选型决策和常见陷阱。Go 的 DI 哲学与 Java Spring 等框架截然不同——它推崇显式构造器注入,不依赖反射、IoC 容器或运行时魔法。

一、为什么 Go 不需要 IoC 容器?

[!question] 思考:Spring 的 BeanFactory 在 Go 里是什么?

在 Go 中,构造函数就是 IoC 容器。调用者一眼就能看到类型需要哪些依赖,不需要查看注册表、配置文件或注解。这就是 Go "显式优于隐式" 的设计哲学。

Java/Spring 的典型风格 vs Go 风格对比:

// ❌ Java Spring —— 隐式绑定,运行时查找
@Service
public class OrderService {
    @Autowired
    private OrderRepository repository;  // 谁也不知道从哪里注入来的
}
// ✅ Go —— 显式构造,编译期检查
type OrderService struct {
	repo OrderRepository  // 结构体字段只是声明
}

func NewOrderService(repo OrderRepository) *OrderService {
	return &OrderService{repo: repo}  // 依赖关系写得一清二楚
}
维度 Spring IoC 容器 Go 手动注入
依赖可见性 通过注解 + 容器配置 构造函数签名直接暴露
编译时检查 部分(缺 Bean 可能运行时才报错) 全部(少传参数直接编译失败)
调试难度 启动失败时 stack trace 深不见底 普通函数调用,panic 信息直观
性能开销 启动时大量反射初始化 零额外开销
测试友好度 需 MockBean/@SpringBootTest 配合 直接传 mock 构造即可

二、构造器注入:DI 的最基本形式

核心原则:结构体不自己创建依赖,而是通过构造函数接收。

2.1 反模式 vs 推荐模式

// ❌ 反模式:服务内部自行创建依赖(紧耦合,不可替换)
type OrderService struct {
	db *sql.DB  // 硬编码的具体类型
}

func NewOrderService() *OrderService {
	conn, _ := sql.Open("postgres", dsn)  // 隐藏了副作用和错误处理
	return &OrderService{db: conn}
}

问题:

  1. dsn 从哪来?对调用方来说是隐形依赖。
  2. 单元测试必须启动真实数据库,无法 mock。
  3. 想换成 Redis 做缓存?需要改源码。
// ✅ 推荐:外部注入依赖,依赖抽象化
type OrderService struct {
	repo   OrderRepository
	logger *slog.Logger
	cache  Cache
}

func NewOrderService(
	repo OrderRepository,
	logger *slog.Logger,
	cache Cache,
) *OrderService {
	return &OrderService{repo: repo, logger: logger, cache: cache}
}

三个好处:

  • 可测试:传入 mock Repo,无需启动 DB。
  • 可见:构造函数签名 = 完整的依赖清单,IDE 自动补全提示。
  • 灵活:同一 service 可按场景注入不同组合。

2.2 为什么用接口而非具体类型?

// Service 依赖的是接口,不是具体实现
type OrderRepository interface {
	ListByUserID(ctx context.Context, userID int64) ([]*Order, error)
	Create(ctx context.Context, o *Order) error
}

这实现了 依赖倒置原则:

  • 高层模块(service)不依赖低层模块(repository 的具体实现)。
  • 两者都依赖于抽象(interface)。
  • 切换实现只需改 Composition Root 一行代码,业务逻辑零改动。

[!tip] YAGNI 接口原则

不要一开始就给所有 struct 加接口。只在必要时定义接口:

情况 是否定义接口
需要跨包引用 ✅ 必须
需要用 mock 做单元测试 ✅ 必须
只在一个包内使用,无独立测试 ❌ 不需要

三、可选依赖:Options 模式

当依赖增多到 4~5 个以上时,构造函数参数变得臃肿。Go 社区的标准解法是函数选项模式:

type UserService struct {
	repo       UserRepository
	logger     *slog.Logger
	cache      Cache
	enableLog  bool
	maxRetries int
}

// Option 是修改 UserService 字段的函数
type Option func(*UserService)

func WithLogger(logger *slog.Logger) Option {
	return func(s *UserService) { s.logger = logger }
}

func WithCache(cache Cache) Option {
	return func(s *UserService) { s.cache = cache }
}

func WithMaxRetries(n int) Option {
	return func(s *UserService) { s.maxRetries = n }
}

// 必选 + 可选分离
func NewUserService(repo UserRepository, opts ...Option) *UserService {
	s := &UserService{
		repo:       repo,
		enableLog:  true,       // 合理默认值
		maxRetries: 3,
	}
	for _, opt := range opts {
		opt(s)
	}
	return s
}

// 调用点清晰可读
svc := NewUserService(repo,
	WithLogger(logger),
	WithCache(newRedisCache()),
)

[!info] Options 模式的权衡

  • 优点:向后兼容(新增选项不影响旧调用方),调用点像声明式 DSL。
  • 缺点:每个选项多写一个函数;超过 6~8 个选项时需考虑拆分 service。

四、Composition Root:在哪里组装依赖?

Composition Root(组合根目录)是 DI 的核心概念——在整个应用入口处将依赖组装在一起。Go 项目中它就是 cmd/main.go:

package main

func main() {
	cfg := loadConfig()

	// ── 基础设施层 ──
	db := initPostgres(cfg.DSN)
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	redisClient := initRedis(cfg.RedisURL)

	// ── 数据访问层(接口 ←→ 实现对接)──
	userRepo := repository.NewPostgresUserRepo(db)
	orderRepo := repository.NewPostgresOrderRepo(db)

	// ── 业务逻辑层 ──
	userSvc := service.NewUserService(userRepo,
		service.WithLogger(logger),
	)
	orderSvc := service.NewOrderService(orderRepo, userRepo,
		service.WithLogger(logger),
		service.WithCache(redisClient),
	)

	// ── 表现层 ──
	router := handler.NewRouter(userSvc, orderSvc)

	log.Printf("server starting on :%s", cfg.Port)
	http.ListenAndServe(":"+cfg.Port, router)
}

[!warning] 常见陷阱

  • 不要在 service 内部 new 依赖:一旦某个地方绕过了构造器,整个 DI 链条就断了。
  • 不要全局变量存依赖:var db *sql.DB 会导致包级状态污染,并发问题和测试隔离问题接踵而来。
  • 不要在 service 之间循环调用:orderSvc 注入了 userRepo,那 userSvc 就不应该再注入 orderSvc。
  • 不要为了 DI 而 DI:工具函数包不需要接口+注入——过度设计也是反模式。

五、Wire:手动 DI vs 代码生成

Go 生态中有 fx(Uber)、wire 等 DI 工具,但社区普遍推荐小中型项目手动组装。

维度 手动组装 wire(编译时代码生成) fx(运行时间单解析)
学习成本 零 中等(理解 build tags + 注释标注) 高(App 生命周期 + 回调规则)
调试难度 低 低(生成的代码即普通 Go 代码) 中高(运行时 panic,堆栈深)
启动速度 无开销 无开销 有反射开销
适用规模 < 50 个组件 50+ 组件,频繁变更依赖图 较少推荐,社区认可度低于 wire

5.1 Wire 的工作原理

Wire 不是运行时框架,它在编译期生成初始化代码,生成的文件和手写的没有任何区别:

# 安装
go install github.com/google/wire/cmd/wire@latest

编写 injector(注意 //go:build wireinject build tag):

// go:build wireinject
package main

// InitializeApp 组装完整的 App 依赖图
func InitializeApp(cfg Config) (*App, error) {
	wire.Build(
		initPostgres,
		initRedis,
		slog.New,

		repository.NewPostgresUserRepo,
		repository.NewPostgresOrderRepo,

		service.NewUserService,
		service.NewOrderService,

		handler.NewRouter,

		App.New,
	)
	return nil, nil
}

运行 wire:

$ wire ./cmd/app
# → 生成 cmd/app/inject.wire_gen.go

生成的文件大致是:

// Code generated by wire; DO NOT EDIT.
package main

func InitializeApp(cfg Config) (*App, error) {
	db := initPostgres(cfg.DSN)
	redisClient := initRedis(cfg.RedisURL)
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	userRepo := repository.NewPostgresUserRepo(db)
	orderRepo := repository.NewPostgresOrderRepo(db)
	userSvc := service.NewUserService(userRepo, nil /* no logger option */)
	orderSvc := service.NewOrderService(orderRepo, userRepo, nil, nil)
	router := handler.NewRouter(userSvc, orderSvc)
	app := NewApp(router)
	return app, nil
}

[!summary] Wire 的使用时机

  • 依赖图 < 20 个组件 → 手写,不需要 wire。
  • 依赖图 20 ~ 50 个组件 → 手写 + options 模式完全够用。
  • 依赖图 > 50 个组件 且团队多人协作 → 考虑 wire 自动生成。
  • 不想引入任何额外工具链 → 手写,永远是最安全的选择。

六、DI 在测试中的价值

DI 的最大受益者是单元测试——mock 替代真实依赖,测试变成快速、确定性的纯内存操作:

// ── Mock 实现 ──
type mockUserRepo struct {
	mu    sync.Mutex
	users map[int64]*User
}

func (m *mockUserRepo) ByID(ctx context.Context, id int64) (*User, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	u, ok := m.users[id]
	if !ok {
		return nil, ErrNotFound
	}
	// 返回副本,防止测试间相互篡改
	cp := *u
	return &cp, nil
}

func (m *mockUserRepo) Save(ctx context.Context, u *User) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.users[u.ID] = u
	return nil
}

// ── 单元测试:毫秒级执行,不依赖外部环境 ──
func TestCreateOrder(t *testing.T) {
	repo := &mockUserRepo{
		users: map[int64]*User{1: {ID: 1, Name: "alice"}},
	}
	svc := NewOrderService(repo)

	order, err := svc.CreateOrder(context.Background(), 1, "widget", 2)
	require.NoError(t, err)
	assert.Equal(t, "alice", order.OwnerName)
	assert.Equal(t, 2, order.Quantity)
}

[!success] 最佳实践

  • 单元测试:全部用 mock,确保快速(ms 级)、确定性、CI 稳定。
  • 集成测试:用真实 DB + testcontainers-go,覆盖端到端链路。
  • 不要混用:同一个包的测试要么全 mock,要么全真实——混合会带来难以复现的 flaky test。

七、DI 在避免循环依赖中的作用

依赖注入不仅是装配技巧,还是解决循环依赖的核心手段(详见 Go 工程模块化 第四节):

handler → service → repository → model → handler ❌

传统写法中,model 被多层共用导致循环 import。通过 DI 可以打破:

// 1. domain 层只定义接口
type UserRepository interface {
	Save(ctx context.Context, u *User) error
}

// 2. service 依赖接口而非具体 repo
type UserService struct {
	repo UserRepository
}

// 3. infrastructure 层实现接口
type PostgresUserRepo struct { db *sqlx.DB }

// 4. main.go 中桥接接口与实现
//    (没有任何一层产生循环 import)
userRepo := &PostgresUserRepo{db: db}
userSvc := NewUserService(userRepo)

关联笔记