4. 依赖注入(DI)基础概念:控制反转(IoC)、依赖注入的三种方式
在领域驱动设计中,领域对象与基础设施之间的耦合是架构腐化的主要诱因。依赖注入(Dependency Injection, DI)与控制反转(Inversion of Control, IoC)是解决这一问题的核心手段。本章从概念出发,厘清 IoC 与 DI 的关系,并详细剖析三种注入方式在 Go 语言中的实现与适用场景。
4.1 控制反转(IoC)与依赖注入(DI)的关系
控制反转是一种设计原则,它将对象创建与依赖管理的控制权从对象内部转移到外部容器或框架。依赖注入则是 IoC 的具体实现方式——通过外部注入依赖对象,而非由对象自行创建。
| 概念 | 本质 | 在 Go 中的体现 |
|---|---|---|
| 控制反转(IoC) | 将依赖查找/创建的控制权交给外部 | 领域层定义接口,基础设施层实现接口 |
| 依赖注入(DI) | 外部主动将依赖传递给对象 | 通过参数、字段或方法传入具体实现 |
| 服务定位器(反模式) | 对象主动向容器请求依赖 | 全局单例 + 类型断言,测试困难 |
核心认知: DI 是 IoC 的一种实现,但不是唯一实现。在 Go 项目中,我们优先使用 DI 而非服务定位器,因为 DI 使依赖关系显式化,便于单元测试与领域边界隔离。
4.2 依赖注入的三种方式
Go 语言中实现 DI 主要有三种方式:构造器注入、Setter 注入和接口注入。每种方式在可测试性、不可变性和使用便捷性上各有优劣。
4.2.1 构造器注入(Constructor Injection)
通过构造函数参数传递依赖,是 Go 项目中最推荐的方式。依赖在对象创建时一次性注入,之后不可变更,保证了依赖的不可变性与线程安全。
// 领域层定义接口
type UserRepository interface {
FindByID(id string) (*User, error)
}
// 领域服务通过构造器注入依赖
type UserService struct {
repo UserRepository
}
// 构造器:依赖在创建时注入
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
// 业务方法使用注入的依赖
func (s *UserService) GetUser(id string) (*User, error) {
return s.repo.FindByID(id)
}
优点: 依赖关系显式化、对象创建后依赖不可变、易于单元测试(传入 mock 即可)。
缺点: 当依赖过多时,构造器参数列表会变长,需要配合 DI 容器管理。
缺点: 当依赖过多时,构造器参数列表会变长,需要配合 DI 容器管理。
4.2.2 Setter 注入(Setter Injection)
通过公开的 Setter 方法在对象创建后设置依赖。适用于可选依赖或需要在运行时切换实现的场景。
type UserService struct {
repo UserRepository // 私有字段
logger Logger // 可选依赖
}
// 构造器只注入必需依赖
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
// Setter 注入可选依赖
func (s *UserService) SetLogger(logger Logger) {
s.logger = logger
}
// 使用时检查依赖是否存在
func (s *UserService) GetUser(id string) (*User, error) {
if s.logger != nil {
s.logger.Log("fetching user: " + id)
}
return s.repo.FindByID(id)
}
注意: Setter 注入破坏了依赖的不可变性,对象在创建后可能处于不完整状态。在领域层中,应仅对基础设施级别的可选依赖(如日志、监控)使用 Setter 注入,核心业务依赖必须通过构造器注入。
4.2.3 接口注入(Interface Injection)
依赖方实现一个注入接口,由外部调用该接口方法完成注入。这种方式在 Go 中较少使用,因为 Go 的隐式接口实现机制使得构造器注入已经足够灵活。
// 定义注入接口
type UserRepositoryInjector interface {
InjectUserRepository(repo UserRepository)
}
// 领域服务实现注入接口
type UserService struct {
repo UserRepository
}
func (s *UserService) InjectUserRepository(repo UserRepository) {
s.repo = repo
}
// 外部调用方
func main() {
svc := &UserService{}
// 通过接口方法注入
injector := UserRepositoryInjector(svc)
injector.InjectUserRepository(NewMySQLRepository())
}
Go 实践建议: 接口注入在 Java 框架(如 Spring)中常见,但在 Go 中推荐优先使用构造器注入。接口注入增加了不必要的抽象层,且无法在编译期保证依赖已注入。
4.3 三种注入方式的选择策略
在实际的 Go 项目中,不应机械地选择某一种方式,而应根据依赖的性质和领域边界做出决策:
- 核心领域依赖(如 Repository、Domain Service):使用构造器注入,保证依赖不可变。
- 可选基础设施(如 Logger、Metrics、Tracer):使用Setter 注入,允许运行时切换或延迟注入。
- 跨层适配器(如 Event Bus、Message Queue):优先构造器注入,若需要动态注册则使用接口注入。
| 评估维度 | 构造器注入 | Setter 注入 | 接口注入 |
|---|---|---|---|
| 依赖可见性 | 显式(参数列表) | 隐式(需查看文档) | 隐式(需查看实现) |
| 不可变性 | 强(创建后不可变) | 弱(可被覆盖) | 弱(可被覆盖) |
| 测试便利性 | 高(构造时注入 mock) | 中(需额外调用 Setter) | 中(需额外调用注入方法) |
| Go 惯用程度 | 高(推荐) | 中(可选依赖) | 低(不推荐) |
| 适用场景 | 核心业务依赖 | 基础设施可选依赖 | 框架级动态注入 |
领域边界实践: 在领域层内部,所有依赖都应通过构造器注入。领域层不应感知任何 DI 容器或框架。DI 容器的组装仅在应用层(main / 启动配置)中进行,确保领域层的纯净性。