依赖注入
依赖注入的英文是 Dependency Injection,简称 DI。它的核心不是 @Inject,而是:对象不在内部创建或查找自己的协作者,而是从外部接收依赖。
DI 是实现控制反转的一种方式,但两者不是同义词。IoC 描述控制权的归属,DI 描述对象获得依赖的方式。
先发现“依赖”
一个类的方法能运行,不代表这个类能独立工作:
class UserService {
fun register(name: String) {
// 保存用户
// 发送欢迎消息
}
}为了完成这两个步骤,它可能需要数据库和消息发送器。数据库与消息发送器就是它的依赖。
最直接的写法,是在内部创建依赖:
class UserService {
private val database = Database("jdbc:prod")
private val sender = EmailSender()
}现在请思考:
- 测试时怎样换成内存数据库?
- 将邮件改成短信时,要不要修改
UserService? - 数据库地址为什么被业务类知道?
- 多个服务各自创建数据库,会不会重复连接?
问题不在于 new 本身,而在于 UserService 同时承担了“注册用户”和“决定基础设施实现”的职责。
下一步通常是把创建移到外部:
class UserService(
private val database: Database,
private val sender: MessageSender,
)这时即使完全不用框架,也已经在做依赖注入:
val service = UserService(database, emailSender)Rain 做的是进一步自动化应用级组装,而不是发明“把参数传给构造器”这件事。
重要发现
依赖注入首先是一种对象设计方法,其次才是框架功能。一个类是否容易注入,往往也反映它是否职责清楚、是否容易测试。
DI 与 Service Locator
class UserService {
private val repository = GlobalContext.get(UserRepository::class.java)
}这段代码从容器拿到了对象,但 UserService 主动知道并访问全局容器。这种模式通常称为 Service Locator。
它的问题包括:
- 依赖没有出现在构造器和公开结构中;
- 不执行查找代码,很难知道对象真正需要什么;
- 测试必须准备全局容器;
- 业务类与容器 API 耦合;
- 缺少依赖时,错误容易延迟到运行阶段。
Rain 暴露 DiContext.getBean() 是必要的,因为框架扩展、动态类型和基础设施确实可能需要主动查找对象。但普通业务服务应优先接收依赖本身,而不是到处访问上下文。
构造器注入
class UserService(
private val repository: UserRepository,
private val eventBus: EventBus,
)构造器注入通常最值得优先选择:
- 依赖一眼可见;
- 对象创建完成后便处于可用状态;
- 必需依赖可以用非空类型表达;
- 测试可以直接传入替代实现;
- 字段可以声明为
val; - 更容易暴露循环依赖和职责过多。
Rain 默认尝试使用 Kotlin 类的公开主构造器。普通主构造器注入不需要标记 @Inject;由于没有构造器注解或可见性修饰符,也不需要显式写出 constructor 关键字。无参构造器和显式标记 @Inject 的其他构造器也会参与选择,Kotlin 可空参数与默认参数则会参与注入决策:
class ReportService(
private val clock: Clock,
private val formatter: Formatter = DefaultFormatter(),
)如果可选参数没有从容器读到值,Rain 可以保留 Kotlin 默认值。但不要把真正必需的依赖伪装成默认参数,只为了绕过启动错误。
字段与属性注入
class UserController {
@Inject
lateinit var userService: UserService
}字段注入适合框架创建后再处理的对象,也能减少构造器代码。但它有明显代价:
- 对象在构造完成与注入完成之间可能处于不完整状态;
- 依赖不如构造器直观;
lateinit错误会在访问时才暴露;- 直接实例化对象时容易漏掉注入步骤;
- 测试需要容器、反射或额外赋值。
Rain 支持字段、属性 Setter 和已有对象的 injectBean()。这是一项可用能力,不代表所有业务类都应依赖字段注入。通常应优先使用构造器注入,在框架适配层和特殊生命周期对象中再酌情使用字段注入。
配置注入
配置也是对象运行所需的外部依赖:
class HttpServer(
@Config("server.port") private val port: Int,
)Rain 将 Bean 和配置统一为可读取的数据来源。注入点既可以从 DI 上下文读取对象,也可以从 ConfigManager 读取配置并转换为目标类型。
统一模型减少了调用方的区别处理,但配置路径错误、类型转换失败与 Bean 缺失一样,可能直接造成对象创建失败。
可选依赖
可空类型应表达真正可选的能力:
class MetricsReporter(
private val exporter: MetricsExporter?,
)如果没有 exporter 时应用仍能正常工作,可空是合理的。反之,如果没有数据库就无法工作,把 Database 改成 Database? 只会把启动期错误推迟到业务运行期。
依赖倒置、DI 与 IoC
依赖倒置原则强调高层模块与低层模块都依赖抽象:
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 减少的是组装成本,不是架构设计责任。
一步一步比较三种写法
对象内部创建
class ReportService {
private val repository = MysqlReportRepository()
}依赖隐藏,替换困难,但代码最直接。
手工注入
class ReportService(
private val repository: ReportRepository,
)
val service = ReportService(mysqlRepository)依赖清楚、容易测试;组装代码由应用自己维护。
容器注入
class ReportService(
private val repository: ReportRepository,
)依赖仍然清楚,应用级组装交给 Rain;代价是实现选择和创建错误更多发生在运行时。
这三种方式没有绝对胜负。对象数量少时手工注入通常足够;组件多、扩展多、生命周期复杂时,容器自动组装更有价值。
动手实验
可以尝试写两个不启动 Rain 的测试:
class FakeRepository : UserRepository
class FakeEventBus : EventBus
val service = UserService(FakeRepository(), FakeEventBus())如果业务类很难脱离容器构造,检查它是否直接访问了 DiContext、全局变量或静态单例。这个实验能帮助你区分“使用容器组装”和“业务代码依赖容器”。