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

377 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [go, dependency-injection, di, architecture, testing, refactoring]
create time: 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
// ❌ Java Spring —— 隐式绑定,运行时查找
@Service
public class OrderService {
@Autowired
private OrderRepository repository; // 谁也不知道从哪里注入来的
}
```
```go
// ✅ 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 推荐模式
```go
// ❌ 反模式:服务内部自行创建依赖(紧耦合,不可替换)
type OrderService struct {
db *sql.DB // 硬编码的具体类型
}
func NewOrderService() *OrderService {
conn, _ := sql.Open("postgres", dsn) // 隐藏了副作用和错误处理
return &OrderService{db: conn}
}
```
问题:
1. `dsn` 从哪来?对调用方来说是隐形依赖。
2. 单元测试必须启动真实数据库,无法 mock。
3. 想换成 Redis 做缓存?需要改源码。
```go
// ✅ 推荐:外部注入依赖,依赖抽象化
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 为什么用接口而非具体类型?
```go
// Service 依赖的是接口,不是具体实现
type OrderRepository interface {
ListByUserID(ctx context.Context, userID int64) ([]*Order, error)
Create(ctx context.Context, o *Order) error
}
```
这实现了 **[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)**:
- 高层模块(service)不依赖低层模块(repository 的具体实现)。
- 两者都依赖于**抽象**(interface)。
- 切换实现只需改 Composition Root 一行代码,业务逻辑零改动。
> [!tip] YAGNI 接口原则
>
> 不要一开始就给所有 struct 加接口。**只在必要时定义接口**:
>
> | 情况 | 是否定义接口 |
> |------|-------------|
> | 需要跨包引用 | ✅ 必须 |
> | 需要用 mock 做单元测试 | ✅ 必须 |
> | 只在一个包内使用,无独立测试 | ❌ 不需要 |
## 三、可选依赖:Options 模式
当依赖增多到 4~5 个以上时,构造函数参数变得臃肿。Go 社区的标准解法是**函数选项模式**:
```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`:
```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 不是运行时框架,它在**编译期生成初始化代码**,生成的文件和手写的没有任何区别:
```bash
# 安装
go install github.com/google/wire/cmd/wire@latest
```
编写 injector(注意 `//go:build wireinject` build tag):
```go
// 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:
```bash
$ wire ./cmd/app
# → 生成 cmd/app/inject.wire_gen.go
```
生成的文件大致是:
```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 替代真实依赖,测试变成快速、确定性的纯内存操作:
```go
// ── 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`](https://github.com/testcontainers/testcontainers-go),覆盖端到端链路。
> - **不要混用**:同一个包的测试要么全 mock,要么全真实——混合会带来难以复现的 flaky test。
## 七、DI 在避免循环依赖中的作用
依赖注入不仅是装配技巧,还是解决**循环依赖**的核心手段(详见 [[Go 工程模块化]] 第四节):
```
handler → service → repository → model → handler ❌
```
传统写法中,`model` 被多层共用导致循环 import。通过 DI 可以打破:
```go
// 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)
```
## 关联笔记
- [[Go 工程模块化]]