Skip to content

依赖注入

依赖注入的英文是 Dependency Injection,简称 DI。它的核心不是 @Inject,而是:对象不在内部创建或查找自己的协作者,而是从外部接收依赖。

DI 是实现控制反转的一种方式,但两者不是同义词。IoC 描述控制权的归属,DI 描述对象获得依赖的方式。

先发现“依赖”

一个类的方法能运行,不代表这个类能独立工作:

kotlin
class UserService {
    fun register(name: String) {
        // 保存用户
        // 发送欢迎消息
    }
}

为了完成这两个步骤,它可能需要数据库和消息发送器。数据库与消息发送器就是它的依赖。

最直接的写法,是在内部创建依赖:

kotlin
class UserService {
    private val database = Database("jdbc:prod")
    private val sender = EmailSender()
}

现在请思考:

  • 测试时怎样换成内存数据库?
  • 将邮件改成短信时,要不要修改 UserService
  • 数据库地址为什么被业务类知道?
  • 多个服务各自创建数据库,会不会重复连接?

问题不在于 new 本身,而在于 UserService 同时承担了“注册用户”和“决定基础设施实现”的职责。

下一步通常是把创建移到外部:

kotlin
class UserService(
    private val database: Database,
    private val sender: MessageSender,
)

这时即使完全不用框架,也已经在做依赖注入:

kotlin
val service = UserService(database, emailSender)

Rain 做的是进一步自动化应用级组装,而不是发明“把参数传给构造器”这件事。

重要发现

依赖注入首先是一种对象设计方法,其次才是框架功能。一个类是否容易注入,往往也反映它是否职责清楚、是否容易测试。

DI 与 Service Locator

kotlin
class UserService {
    private val repository = GlobalContext.get(UserRepository::class.java)
}

这段代码从容器拿到了对象,但 UserService 主动知道并访问全局容器。这种模式通常称为 Service Locator。

它的问题包括:

  • 依赖没有出现在构造器和公开结构中;
  • 不执行查找代码,很难知道对象真正需要什么;
  • 测试必须准备全局容器;
  • 业务类与容器 API 耦合;
  • 缺少依赖时,错误容易延迟到运行阶段。

Rain 暴露 DiContext.getBean() 是必要的,因为框架扩展、动态类型和基础设施确实可能需要主动查找对象。但普通业务服务应优先接收依赖本身,而不是到处访问上下文。

构造器注入

kotlin
class UserService(
    private val repository: UserRepository,
    private val eventBus: EventBus,
)

构造器注入通常最值得优先选择:

  • 依赖一眼可见;
  • 对象创建完成后便处于可用状态;
  • 必需依赖可以用非空类型表达;
  • 测试可以直接传入替代实现;
  • 字段可以声明为 val
  • 更容易暴露循环依赖和职责过多。

Rain 默认尝试使用 Kotlin 类的公开主构造器。普通主构造器注入不需要标记 @Inject;由于没有构造器注解或可见性修饰符,也不需要显式写出 constructor 关键字。无参构造器和显式标记 @Inject 的其他构造器也会参与选择,Kotlin 可空参数与默认参数则会参与注入决策:

kotlin
class ReportService(
    private val clock: Clock,
    private val formatter: Formatter = DefaultFormatter(),
)

如果可选参数没有从容器读到值,Rain 可以保留 Kotlin 默认值。但不要把真正必需的依赖伪装成默认参数,只为了绕过启动错误。

字段与属性注入

kotlin
class UserController {
    @Inject
    lateinit var userService: UserService
}

字段注入适合框架创建后再处理的对象,也能减少构造器代码。但它有明显代价:

  • 对象在构造完成与注入完成之间可能处于不完整状态;
  • 依赖不如构造器直观;
  • lateinit 错误会在访问时才暴露;
  • 直接实例化对象时容易漏掉注入步骤;
  • 测试需要容器、反射或额外赋值。

Rain 支持字段、属性 Setter 和已有对象的 injectBean()。这是一项可用能力,不代表所有业务类都应依赖字段注入。通常应优先使用构造器注入,在框架适配层和特殊生命周期对象中再酌情使用字段注入。

配置注入

配置也是对象运行所需的外部依赖:

kotlin
class HttpServer(
    @Config("server.port") private val port: Int,
)

Rain 将 Bean 和配置统一为可读取的数据来源。注入点既可以从 DI 上下文读取对象,也可以从 ConfigManager 读取配置并转换为目标类型。

统一模型减少了调用方的区别处理,但配置路径错误、类型转换失败与 Bean 缺失一样,可能直接造成对象创建失败。

可选依赖

可空类型应表达真正可选的能力:

kotlin
class MetricsReporter(
    private val exporter: MetricsExporter?,
)

如果没有 exporter 时应用仍能正常工作,可空是合理的。反之,如果没有数据库就无法工作,把 Database 改成 Database? 只会把启动期错误推迟到业务运行期。

依赖倒置、DI 与 IoC

依赖倒置原则强调高层模块与低层模块都依赖抽象:

kotlin
interface MessageSender {
    fun send(message: String)
}

class NotificationService(
    private val sender: MessageSender,
)

class SmtpMessageSender : MessageSender {
    override fun send(message: String) = Unit
}

这里包含三个不同层次:

概念回答的问题
依赖倒置模块应该依赖什么方向?
依赖注入对象如何获得依赖?
控制反转谁掌握创建、调用与生命周期控制权?

NotificationService 依赖接口,这是依赖倒置;Rain 把实现传入构造器,这是依赖注入;对象创建与选择交给容器,这是控制反转。

也可以对具体类进行依赖注入,但不一定符合依赖倒置;也可以手工把接口实现传入构造器,这符合 DI 与依赖倒置,却不需要 IoC 容器。

容器不能替代设计

构造器参数过多通常意味着职责过重;循环依赖通常意味着模块边界不清;跨层依赖会让容器自动组装出一个结构不合理的对象图。

容器能够把对象连接起来,却无法判断这些对象是否应该连接。DI 减少的是组装成本,不是架构设计责任。

一步一步比较三种写法

对象内部创建

kotlin
class ReportService {
    private val repository = MysqlReportRepository()
}

依赖隐藏,替换困难,但代码最直接。

手工注入

kotlin
class ReportService(
    private val repository: ReportRepository,
)

val service = ReportService(mysqlRepository)

依赖清楚、容易测试;组装代码由应用自己维护。

容器注入

kotlin
class ReportService(
    private val repository: ReportRepository,
)

依赖仍然清楚,应用级组装交给 Rain;代价是实现选择和创建错误更多发生在运行时。

这三种方式没有绝对胜负。对象数量少时手工注入通常足够;组件多、扩展多、生命周期复杂时,容器自动组装更有价值。

动手实验

可以尝试写两个不启动 Rain 的测试:

kotlin
class FakeRepository : UserRepository
class FakeEventBus : EventBus

val service = UserService(FakeRepository(), FakeEventBus())

如果业务类很难脱离容器构造,检查它是否直接访问了 DiContext、全局变量或静态单例。这个实验能帮助你区分“使用容器组装”和“业务代码依赖容器”。

基于 Apache License 2.0 发布