Skip to content

设计与实践

正确使用 Rain 的关键不是尽可能把所有东西交给容器,而是保持依赖清晰、扫描边界明确,并让业务对象在可能的情况下脱离容器也能测试。

从一个逐渐变坏的类开始

kotlin
class UserService {
    private val context = GlobalContext.current
    private val database = context.getBean(Database::class.java)!!

    fun register(name: String) {
        database.save(name)
        context.getBean(EventBus::class.java)!!
            .post(UserCreated(name))
    }
}

它可以工作,但阅读者必须进入方法内部才能发现全部依赖。测试还要准备全局上下文。

第一步不是增加更多注解,而是让依赖显式:

kotlin
class UserService(
    private val database: Database,
    private val eventBus: EventBus,
) {
    fun register(name: String) {
        database.save(name)
        eventBus.post(UserCreated(name))
    }
}

现在 Rain 可以组装它,普通测试也可以手工构造它。这是实践建议背后的共同目标:让框架承担组装,不让业务对象依赖框架才能生存。

优先构造器注入

把必需依赖放进构造器,用非空类型表达“没有它对象就不成立”:

kotlin
class OrderService(
    private val repository: OrderRepository,
    private val eventBus: EventBus,
)

字段注入主要用于框架回调对象、JavaBean 兼容或确有创建后注入需求的场景。

业务代码避免访问容器

下面的代码应主要留在框架边界、动态工厂和基础设施中:

kotlin
context.getBean(SomeType::class.java)

普通服务应接收 SomeType 本身。这样测试时可以直接构造对象,不必启动完整 Rain 应用。

只为真正边界提取接口

接口适合业务边界、多实现策略、外部系统适配和测试替换:

kotlin
interface PaymentGateway
interface MessageSender
interface ObjectStorage

纯内部计算类、值对象和稳定实现直接依赖具体类型往往更清晰。不要为了形式上的“面向接口”给每个类机械创建接口。

控制扫描范围

properties
rain.scanPackages=com.example.application,com.example.plugin

扫描范围应覆盖应用组件,但不要粗暴扩展到所有依赖。包边界在 Rain 中既是发现边界,也是容器默认信任边界。

为多实现建立稳定名称

kotlin
@AutoBind
interface Storage

@Named("local")
class LocalStorage : Storage

@Named("s3")
class S3Storage : Storage

注入点应明确选择名称,或注入集合后按业务规则排序。不要依赖实现被扫描到的偶然顺序。

不要用可空掩盖容器错误

可空依赖必须代表业务上真正可选的能力。把所有注入点改成可空只会让错误从应用启动阶段延迟到业务运行阶段。

Loader 用于框架扩展

Loader 适合实现“发现一批声明并建立基础设施”,例如注册监听器、任务、Controller 或插件。

普通业务编排仍应放在清晰的服务方法中,不要为了减少显式调用把所有流程隐藏到扫描阶段。

谨慎使用字节码增强

如果普通组合、装饰器或显式调用已经足够,就没有必要为了减少几行代码新增 Transformer。增强最适合解决横切关注点和框架级约定,而不是替代正常业务设计。

待增强业务类也不应在完整启动器建立 AppClassloader 之前被父类加载器提前加载。

完整示例

下面的示例同时体现依赖倒置、依赖注入、自动绑定和事件解耦:

kotlin
@AutoBind
interface OrderRepository {
    fun save(order: Order)
}

class JdbcOrderRepository(
    private val database: Database,
) : OrderRepository {
    override fun save(order: Order) {
        database.execute("...")
    }
}

class OrderService(
    private val repository: OrderRepository,
    private val eventBus: EventBus,
) {
    fun create(command: CreateOrder): Order {
        val order = Order(command)
        repository.save(order)
        eventBus.post(OrderCreated(order.id))
        return order
    }
}

data class OrderCreated(val orderId: Long) : Event

@EventListener
class OrderAuditListener(
    private val auditLog: AuditLog,
) {
    @SubscribeEvent
    fun onCreated(event: OrderCreated) {
        auditLog.write("order created: ${event.orderId}")
    }
}

在这个模型中:

  • OrderService 不创建数据库实现;
  • 高层业务依赖 OrderRepository 抽象;
  • Rain 创建并注入完整对象图;
  • @AutoBind 让接口发现具体实现;
  • 事件总线把核心业务与审计副作用解耦;
  • 监听器由 Loader 发现并注册。

测试仍然可以脱离容器:

kotlin
val service = OrderService(fakeRepository, fakeEventBus)

一个使用 DI 容器设计良好的类,通常也应该能够在不启动容器的情况下进行单元测试。容器负责应用组装,不应成为每个业务对象无法摆脱的运行环境。

最终检查清单

设计一个 Rain 组件时,可以依次检查:

  1. 它真的是应用级组件,还是普通临时对象?
  2. 必需依赖是否全部出现在构造器中?
  3. 是否直接访问了本可注入的 DiContext
  4. 接口是否代表真正的替换边界?
  5. 多实现是否拥有稳定且明确的名称?
  6. 可空依赖是否真的可选?
  7. 扫描范围是否足够小且清晰?
  8. Loader 是否只承担基础设施注册?
  9. 字节码增强是否确实优于显式设计?
  10. 组件能否脱离完整容器进行测试?

当这些问题有清楚答案时,IoC 带来的是秩序;当边界模糊时,IoC 只会把复杂度隐藏到运行时。

建议的学习实验

如果你是第一次学习框架,可以按顺序完成这些小实验:

  1. 写两个类,先在 main 中手工创建和连接它们。
  2. 把依赖从类内部 new 改为构造器参数,不使用 Rain。
  3. 再加入 Rain,让容器创建同样的对象图。
  4. 给接口增加第二个实现,观察为什么需要名称或明确选择规则。
  5. 写一个 @EventListener,思考是谁扫描并注册了它。
  6. 写一个普通 JUnit 测试,手工传入 fake 实现。
  7. 再写一个 @RainTest,比较“单元测试”和“应用上下文测试”的边界。
  8. 故意删除一个依赖或写错配置名,观察错误在什么阶段出现。

完成这些实验后,你掌握的不只是 Rain 的写法,还会开始形成分析其他框架的通用方法。

基于 Apache License 2.0 发布