1. 依赖注入基础:概念、方式与必要性

依赖注入(Dependency Injection,DI)是面向对象设计中实现控制反转(IoC)的核心技术手段。它通过将对象的依赖关系从内部创建转移到外部注入,从而解耦组件、提升可测试性与可维护性。本章从最基础的概念出发,逐步拆解 DI 的三种实现方式,并说明为什么现代 Go 项目(尤其是接口装配场景)离不开它。

1.1 什么是依赖注入

依赖注入的核心思想是:一个对象不应自己创建它所依赖的其他对象,而应由外部容器或框架在运行时将依赖传递进来。在 Go 中,依赖通常表现为接口或结构体指针。

关键区别:传统代码中,对象内部直接 new 或 make 依赖;DI 模式下,依赖通过参数、字段或方法传入,对象只负责使用。

下面是一个简单的对比示例:

// 传统方式:对象内部创建依赖
type UserService struct{}
func (s *UserService) GetUser(id int) *User {
    repo := &UserRepository{}  // 硬编码依赖
    return repo.FindByID(id)
}

// DI 方式:依赖由外部注入
type UserService struct {
    repo UserRepositoryInterface  // 依赖接口
}
func NewUserService(repo UserRepositoryInterface) *UserService {
    return &UserService{repo: repo}
}
func (s *UserService) GetUser(id int) *User {
    return s.repo.FindByID(id)
}

1.2 控制反转(IoC)概念

控制反转是 DI 背后的设计原则。它反转了谁控制依赖的创建与生命周期:

  • 传统方式:高层模块主动创建低层模块,控制权在调用方。
  • IoC 方式:控制权交给 IoC 容器或注入点,调用方只声明“我需要什么”,由外部决定“给什么”。
一句话记忆:“别打电话给我们,我们会打给你。”——好莱坞原则。对象不再主动寻找依赖,而是等待依赖被注入。
控制反转(IoC)概念对比 传统方式:正向控制 UserService UserRepository 创建 高层模块主动创建低层模块 控制权在调用方内部 依赖关系硬编码,难以替换 IoC 方式:控制反转 UserService IoC 容器 注入 UserRepository 依赖 容器负责创建和注入依赖 对象只声明需求,不负责创建 依赖可灵活替换(Mock/实现)

1.3 依赖注入的三种方式

根据依赖注入的时机与手段,业界普遍归纳为三种方式。下表从实现机制、Go 代码示例和适用场景三个维度进行对比:

注入方式 实现机制 Go 代码示例 适用场景
构造器注入 通过构造函数参数传入依赖 NewService(repo RepoInterface) *Service 依赖必须存在,不可变;最推荐的方式
Setter 注入 通过公开的 Setter 方法设置依赖 s.SetRepo(repo RepoInterface) 依赖可选,或需要在对象创建后替换
接口注入 依赖通过接口方法参数传入 func (s *Service) Init(repo RepoInterface) 框架级回调,或需要统一注入契约

1.3.1 构造器注入(Constructor Injection)

这是 Go 社区最推崇的方式。依赖在对象创建时一次性传入,之后不可变,保证了对象的完整性与线程安全。

// 构造器注入示例
type OrderService struct {
    db     DatabaseInterface
    logger LoggerInterface
}

func NewOrderService(db DatabaseInterface, logger LoggerInterface) *OrderService {
    return &OrderService{
        db:     db,
        logger: logger,
    }
}

// 使用
db := &PostgresDB{}
logger := &StdLogger{}
orderSvc := NewOrderService(db, logger)
优点:依赖关系在编译期可见,对象创建后状态完整,适合强制依赖。
缺点:当构造函数参数过多时,代码可读性下降(可通过引入参数对象或 Wire 解决)。

1.3.2 Setter 注入(Setter Injection)

依赖通过公开的 Setter 方法在对象创建后设置。适合可选依赖或需要运行时替换的场景。

// Setter 注入示例
type CacheService struct {
    cache CacheInterface  // 可选依赖
}

func (s *CacheService) SetCache(cache CacheInterface) {
    s.cache = cache
}

// 使用
cacheSvc := &CacheService{}
cacheSvc.SetCache(&RedisCache{})  // 创建后注入
注意:Setter 注入可能导致对象在依赖未完全注入时被使用,需配合初始化检查或延迟加载。在 Go 中,构造器注入优先于 Setter 注入。

1.3.3 接口注入(Interface Injection)

依赖通过一个专门的注入接口方法传入。这种方式在 Go 中较少直接使用,更多出现在框架级别的回调或插件系统中。

// 接口注入示例
type Injector interface {
    InjectDB(db DatabaseInterface)
}

type Service struct {
    db DatabaseInterface
}

func (s *Service) InjectDB(db DatabaseInterface) {
    s.db = db
}

// 框架调用
var svc Service
if injector, ok := interface{}(&svc).(Injector); ok {
    injector.InjectDB(&PostgresDB{})
}
Go 中的实际情况:接口注入通常被构造器注入或 Setter 注入替代。Wire 等 DI 工具主要基于构造器注入工作,因为它最符合 Go 的显式编程哲学。
依赖注入三种方式对比 构造器注入 NewService(db, logger) 依赖在构造时传入 依赖不可变,线程安全 编译期可见 Go 中最推荐 Setter 注入 SetCache(redisCache) 创建后设置依赖 依赖可选,可替换 需注意未注入状态 适合可选依赖 接口注入 InjectDB(db) 通过接口方法注入 框架级回调 统一注入契约 Go 中使用较少

1.4 为什么需要依赖注入

依赖注入不是银弹,但在中大型 Go 项目中,它解决了几个核心痛点:

  1. 解耦组件:高层模块不依赖低层模块的具体实现,只依赖抽象接口。替换实现时无需修改调用方代码。
  2. 提升可测试性:可以轻松注入 Mock 对象进行单元测试,无需启动数据库、网络等真实依赖。
  3. 集中管理生命周期:依赖的创建、复用、销毁由容器统一管理,避免散落在各处。
  4. 支持接口装配:在 Wire 等工具帮助下,通过声明式配置即可完成整个对象图的装配,减少样板代码。
实战案例:一个 Web 服务中,Handler → Service → Repository 三层架构。如果没有 DI,每层都需要手动创建下层依赖;引入 DI 后,只需在 Wire 中声明一次,所有依赖自动注入。

下表总结了有/无 DI 的典型差异:

维度 无 DI(硬编码) 有 DI(接口注入)
组件耦合度 高,修改实现需改调用方 低,只依赖接口
单元测试 困难,需启动真实依赖 容易,可注入 Mock
代码复用 低,依赖绑定在具体实现 高,接口可多实现
装配复杂度 随组件数线性增长 由容器管理,复杂度可控
Go 生态适配 与 Wire、Uber Dig 等不兼容 天然适配主流 DI 工具
本章小结:依赖注入是控制反转的具体实现,通过构造器/Setter/接口三种方式将依赖从内部创建转为外部注入。在 Go 中,构造器注入是最主流的方式,也是后续 Wire 课程的核心基础。理解 DI 的本质,是掌握接口装配的第一步。