Go-依赖注入

依赖注入(Dependency Injection,DI)是后端开发绕不开的核心概念之一。你可能听过它能"解耦""易测试""可扩展",也可能在 Go 里看到 samber/dowire 这类工具,但仍不确定: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)) }

这会带来什么问题?
  1. 强耦合UserService 绑死 StdLogger / InMemoryUserRepo
    • 想换成 ZapLogger 或 MySQL Repo?必须改 UserService
  2. 难测试 :很难注入 mock/stub
    • 单元测试不得不依赖真实实现,测试边界变大。
  3. 依赖链复杂时会失控 :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 的核心思路是:

  1. 建一个 Injector 容器
  2. Provide 注册"如何构建某个依赖"
  3. 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:怎么选(实用决策)

优先级(经验排序)

  1. 手动 DI(构造函数/参数):默认选择,最清晰、最 Go
  2. wire(生成装配代码):依赖复杂但你仍希望"显式代码、编译期失败"
  3. 容器(如 samber/do:需要运行时插件化/动态组合/统一生命周期管理

你可能适合上容器的信号

  • 依赖树很深,手动组装维护成本明显上升
  • 需要按环境动态替换实现(且组合多)
  • 需要统一资源生命周期、关闭顺序、单例缓存策略

你可能不该上容器的信号

  • 项目规模不大,依赖链很浅
  • 团队更看重显式与可读性(Go 常见)
  • 希望"问题尽量编译期暴露"

10. 总结(记住这 4 句话就够了)

  1. DI 的本质:组件不自己创建依赖,依赖由外部注入。
  2. DI 的价值:解耦、可替换、易测试、可维护。
  3. 容器不是 DI 的必要条件:手动传参就是 DI,而且在 Go 里常常更好。
  4. 用容器要克制 :把装配集中在 composition root,避免业务代码到处 Invoke 变 Service Locator。
相关推荐
Java面试题总结1 小时前
Go 泛型中的 [0]func(T)
开发语言·后端·golang
小二·1 小时前
Go 语言系统编程与云原生开发实战(第19篇)
开发语言·云原生·golang
LSL666_1 小时前
5 Redis通用命令
java·开发语言·redis·命令
rannn_1111 小时前
【Redis|基础篇】初识、Redis的安装与启动、Redis命令、Java客户端
java·redis·后端·缓存·nosql
zh_xuan1 小时前
kotlin let函数
开发语言·kotlin
小老鼠不吃猫1 小时前
Qt C++稳定职业规划
开发语言·c++·qt
qq_401700411 小时前
嵌入式C语言设计模式
c语言·开发语言·设计模式
minh_coo1 小时前
Spring单元测试之反射利器:ReflectionTestUtils
java·后端·spring·单元测试·intellij-idea
二十画~书生2 小时前
【2025年全国大学生电子设计大赛-国二】超声信标定位系统 (J 题)
开发语言·javascript·经验分享·ecmascript·硬件工程