依赖注入(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:业务类自己创建依赖(高耦合)
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)
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 之后,测试可以这样写:
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获取实例(容器会解析并构建依赖链)
下面给出一个完整、可读的示例(风格偏"集中组装"):
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、初始化层。
尽量避免在业务逻辑里到处写:
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。