依赖注入(Dependency Injection,DI)是后端开发绕不开的核心概念之一。你可能听过它能"解耦""易测试""可扩展",也可能在 Go 里看到 samber/do、wire 这类工具,但仍不确定:DI 到底是什么?它解决什么问题?什么时候该用容器?
这篇文章用 通俗比喻 + 代码对比 + 工程实践 把 DI 讲清楚,并给出 Go 的手动 DI 与 samber/do 容器示例。
1. 最通俗但准确的定义
依赖注入(DI):
一个组件需要用到的"依赖"(例如 Logger、Repo、DB、HTTP Client),不由它自己在内部创建,而是由外部把依赖"传进来"(注入)。
DI 的核心是**"依赖从外部来"**,而不是"必须用接口"或"必须用框架"。
2. DI、IoC、容器:三者关系别混了
很多人把这三个词混用,建议这样理解:
- IoC(控制反转) :一种设计思想/原则
- "对象怎么被创建、怎么被组装"的控制权,从业务代码反转到外部。
- DI(依赖注入) :IoC 的一种常见实现方式
- 通过构造函数/函数参数/Setter/字段,把依赖传入。
- 容器(Container) :实现 DI/IoC 的工具
- 帮你管理创建、生命周期、依赖解析、缓存等。
一句话总结:
IoC 是思想,DI 是手段,容器是工具。
3. 生活比喻:自己造装备 vs 别人给你发装备
把业务组件想象成"打怪的角色"(比如 UserService):
- 不用 DI :你要自己挖矿、炼铁、造剑(在代码里
new依赖)- 结果是耦合高,改装备要改角色代码。
- 用 DI :装备由"外部系统"发给你(注入依赖)
- 角色只负责用装备打怪,不负责造装备。
4. 不用 DI 的典型问题(先看反例)
下面是常见的"依赖写死在内部"的写法:UserService 直接创建日志和仓库。
写法 A:业务类自己创建依赖(高耦合)
go
package main
import "fmt"
// ---- 具体实现:日志 ----
type StdLogger struct{}
func (l *StdLogger) Info(msg string) { fmt.Println("[INFO]", msg) }
// ---- 具体实现:仓库 ----
type InMemoryUserRepo struct{}
func (r *InMemoryUserRepo) FindNameByID(id int) string {
if id == 1 { return "Alice" }
return ""
}
// ---- 业务服务:内部"硬编码"依赖 ----
type UserService struct {
log *StdLogger
repo *InMemoryUserRepo
}
func NewUserService() *UserService {
return &UserService{
log: &StdLogger{},
repo: &InMemoryUserRepo{},
}
}
func (s *UserService) Greet(id int) string {
s.log.Info("loading user")
return "hello " + s.repo.FindNameByID(id)
}
func main() {
svc := NewUserService()
fmt.Println(svc.Greet(1))
}
这会带来什么问题?
- 强耦合 :
UserService绑死StdLogger/InMemoryUserRepo- 想换成
ZapLogger或 MySQL Repo?必须改UserService。
- 想换成
- 难测试 :很难注入 mock/stub
- 单元测试不得不依赖真实实现,测试边界变大。
- 依赖链复杂时会失控 :Repo 又依赖 DB,DB 又依赖配置......
NewUserService变成"上帝函数"。
5. 手动 DI:最推荐的入门与 Go 常用实践
DI 的本质就是:把依赖作为参数传进去 。Go 里最常见、最清晰的方式是构造函数注入(或函数参数注入)。
写法 B:构造函数注入(手动 DI)
go
package main
import "fmt"
// 1) 依赖用"抽象"表达(通常是接口),便于替换与测试
type Logger interface {
Info(msg string)
}
type UserRepo interface {
FindNameByID(id int) string
}
// 2) 具体实现
type StdLogger struct{}
func (l *StdLogger) Info(msg string) { fmt.Println("[INFO]", msg) }
type InMemoryUserRepo struct{}
func (r *InMemoryUserRepo) FindNameByID(id int) string {
if id == 1 { return "Alice" }
return ""
}
// 3) 业务服务:只声明依赖,不创建依赖
type UserService struct {
log Logger
repo UserRepo
}
func NewUserService(log Logger, repo UserRepo) *UserService {
return &UserService{log: log, repo: repo}
}
func (s *UserService) Greet(id int) string {
s.log.Info("loading user")
return "hello " + s.repo.FindNameByID(id)
}
func main() {
// 4) 依赖在外部创建并注入(composition root)
log := &StdLogger{}
repo := &InMemoryUserRepo{}
svc := NewUserService(log, repo)
fmt.Println(svc.Greet(1))
}
这就是 DI 的核心收益
UserService不再关心依赖怎么创建,只关心怎么使用。- 换实现时只改"组装处",业务代码不动。
- 单测时可以注入 MockRepo/MockLogger。
重要澄清:DI 不要求你一定用接口。
但在需要替换实现、隔离测试时,用接口往往更合适。
6. 单元测试为什么会因为 DI 变简单?
没有 DI 时,你很难把 Repo 换成测试替身;有 DI 之后,测试可以这样写:
go
type FakeRepo struct{}
func (r *FakeRepo) FindNameByID(id int) string { return "TestUser" }
type NopLogger struct{}
func (l *NopLogger) Info(msg string) {}
func ExampleUserService() {
svc := NewUserService(&NopLogger{}, &FakeRepo{})
_ = svc.Greet(123) // 不需要真实 DB、真实日志
}
测试关注点回到"业务逻辑是否正确",而不是"依赖环境能不能跑起来"。
7. 什么时候需要 DI 容器?(以及它的代价)
手动 DI 很清晰,但当项目变大时,你可能会遇到:
- 依赖链很深:A 依赖 B,B 依赖 C、D......
- 生命周期管理复杂:单例/每请求/可关闭资源(DB、MQ)
- 组件可插拔:不同环境/不同部署组合不同实现
这时可以考虑容器或代码生成工具来做"组装"。
但容器也有代价:
- 依赖变"隐式":不看构造函数参数,可能不知道一个服务需要什么
- 运行时才报错:缺依赖、循环依赖可能启动才炸
- 调试链变长:追对象怎么来的更费劲
- 容器用不好会变成 Service Locator(到处 Invoke/Resolve),反而更糟
Go 工程里常见建议是:
- 默认手动 DI(显式传参)
- 依赖很复杂时 再考虑:
wire(编译期生成装配代码)- 或容器(运行时解析)如
samber/do
8. samber/do:用容器做 DI(运行时解析)
samber/do 的核心思路是:
- 建一个
Injector容器 Provide注册"如何构建某个依赖"Invoke获取实例(容器会解析并构建依赖链)
下面给出一个完整、可读的示例(风格偏"集中组装"):
go
package main
import (
"fmt"
"github.com/samber/do"
)
// ---- 抽象 ----
type Logger interface{ Info(string) }
type UserRepo interface{ FindNameByID(int) string }
// ---- 实现 ----
type StdLogger struct{}
func (l *StdLogger) Info(msg string) { fmt.Println("[INFO]", msg) }
type InMemoryUserRepo struct{}
func (r *InMemoryUserRepo) FindNameByID(id int) string {
if id == 1 { return "Alice" }
return ""
}
// ---- 业务服务 ----
type UserService struct {
log Logger
repo UserRepo
}
func (s *UserService) Greet(id int) string {
s.log.Info("loading user")
return "hello " + s.repo.FindNameByID(id)
}
func main() {
inj := do.New()
// 1) 注册 Logger
do.Provide(inj, func(i *do.Injector) (Logger, error) {
return &StdLogger{}, nil
})
// 2) 注册 UserRepo
do.Provide(inj, func(i *do.Injector) (UserRepo, error) {
return &InMemoryUserRepo{}, nil
})
// 3) 注册 UserService(从容器拿依赖并组装)
do.Provide(inj, func(i *do.Injector) (*UserService, error) {
log := do.MustInvoke[Logger](i)
repo := do.MustInvoke[UserRepo](i)
return &UserService{log: log, repo: repo}, nil
})
// 4) 获取并使用
svc := do.MustInvoke[*UserService](inj)
fmt.Println(svc.Greet(1))
}
关键工程建议:把 "Provide/组装"集中在 Composition Root
composition root 通常就是 main() 或 cmd/xxx/main.go、初始化层。
尽量避免在业务逻辑里到处写:
go
repo := do.MustInvoke[UserRepo](inj)
这会让业务代码"反向依赖容器",逐渐滑向 Service Locator 模式,降低可测试性和可读性。
9. 手动 DI vs 容器 DI vs wire:怎么选(实用决策)
优先级(经验排序)
- 手动 DI(构造函数/参数):默认选择,最清晰、最 Go
- wire(生成装配代码):依赖复杂但你仍希望"显式代码、编译期失败"
- 容器(如
samber/do):需要运行时插件化/动态组合/统一生命周期管理
你可能适合上容器的信号
- 依赖树很深,手动组装维护成本明显上升
- 需要按环境动态替换实现(且组合多)
- 需要统一资源生命周期、关闭顺序、单例缓存策略
你可能不该上容器的信号
- 项目规模不大,依赖链很浅
- 团队更看重显式与可读性(Go 常见)
- 希望"问题尽量编译期暴露"
10. 总结(记住这 4 句话就够了)
- DI 的本质:组件不自己创建依赖,依赖由外部注入。
- DI 的价值:解耦、可替换、易测试、可维护。
- 容器不是 DI 的必要条件:手动传参就是 DI,而且在 Go 里常常更好。
- 用容器要克制 :把装配集中在 composition root,避免业务代码到处
Invoke变 Service Locator。