Skip to content

框架模块开发实战

本页面向在 Rain 之上开发框架、插件或基础设施模块的作者。我们实现一个最小的“命令框架”:应用用 @Command 声明命令处理器,框架在启动期发现并注册它们,再由一个应用服务开放调用入口。

这个例子刻意覆盖 Rain 框架开发的完整路径:

text
公开 API

业务类声明 @Command

AppLoader 扫描并生成 LoadItem

CommandLoader 建立运行时注册表

ApplicationService 开放和关闭资源

测试验证发现、调用与生命周期

1. 拆分 API 与实现

一个可复用模块至少应区分“应用编译时需要的 API”和“框架运行时实现”:

text
command-api/
└─ src/main/...
   ├─ CommandContext
   └─ CommandHandler

command-core/
└─ src/main/...
   ├─ Command
   ├─ CommandLoader
   ├─ CommandRegistry
   └─ CommandRuntime

这样业务模块只依赖稳定协议,不会直接依赖扫描实现、容器内部类型或服务器实现。Rain 自身的 tools:apimodule:*enhance:* 也采用类似的依赖方向。

API 模块依赖 Rain API:

kotlin
dependencies {
    api("com.IceCreamQAQ.Rain:api:<version>")
}

实现模块再引入 Application:

kotlin
dependencies {
    api(project(":command-api"))
    implementation("com.IceCreamQAQ.Rain:application:<version>")
}

2. 设计稳定的公开协议

先定义应用真正需要理解的类型。不要把 LoadItemDiContext 或 ASM 类型暴露到业务 API。

kotlin
data class CommandContext(
    val name: String,
    val arguments: List<String>,
)

fun interface CommandHandler {
    fun handle(context: CommandContext): Any?
}

然后在核心模块定义声明注解。@LoadBy 把应用使用的声明方式与扫描实现连接起来,因此这个注解和 Loader 应位于同一个可发布模块,避免 API 模块反向依赖实现:

kotlin
@LoadBy(CommandLoader::class)
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Command(val value: String)

为什么 Command 不放在 API 模块

@Command 直接引用 CommandLoader,两者天然属于同一个声明与发现机制。真正可独立复用的 CommandContextCommandHandler 留在 API 模块;应用使用注解时依赖 command-core。不要为了形式上的分层制造循环依赖。

3. 建立运行时注册表

注册表只负责运行时状态,不负责扫描:

kotlin
class CommandRegistry {
    private val handlers = LinkedHashMap<String, CommandHandler>()

    fun register(name: String, handler: CommandHandler) {
        require(name !in handlers) { "命令重复: $name" }
        handlers[name] = handler
    }

    fun invoke(name: String, arguments: List<String>): Any? {
        val handler = handlers[name] ?: error("命令不存在: $name")
        return handler.handle(CommandContext(name, arguments))
    }

    fun names(): Set<String> = handlers.keys.toSet()
}

这里显式拒绝重名,而不是依赖扫描顺序覆盖。框架扩展应主动定义冲突语义,因为类扫描顺序不应成为业务契约。

4. 用 Loader 连接声明与运行时

CommandLoader 接收所有由 @Command 产生的 LoadItem

kotlin
class CommandLoader(
    private val context: DiContext,
    private val registry: CommandRegistry,
) : Loader {
    override fun priority() = 30

    override fun load(items: Collection<LoadItem>) {
        items.sortedBy { it.clazz.name }.forEach { item ->
            val command = item.annotation as? Command
                ?: error("CommandLoader 收到非 @Command 项: ${item.clazz.name}")

            require(CommandHandler::class.java.isAssignableFrom(item.clazz)) {
                "@Command 类型必须实现 CommandHandler: ${item.clazz.name}"
            }

            @Suppress("UNCHECKED_CAST")
            val handlerType = item.clazz as Class<out CommandHandler>
            val handler = context.getBean(handlerType)
                ?: error("无法创建命令处理器: ${handlerType.name}")

            registry.register(command.value, handler)
        }
    }
}

这个 Loader 做了框架实现必须承担的四类校验:

  1. 验证触发来源确实是预期注解。
  2. 验证业务类型满足公开协议。
  3. 验证容器能够创建处理器。
  4. 让注册顺序和冲突行为保持确定。

不要把错误拖到第一次命令调用。Loader 阶段失败能让应用在启动时立即暴露错误配置。

5. 让基础设施成为 Bean

CommandRegistry 是普通具体类,处于 rain.scanPackages 时可以被容器直接创建。如果框架包已经通过 conf/module/*.properties 加入扫描范围,不需要额外 Module。

在实现模块中创建:

text
src/main/resources/conf/module/command.properties

内容为:

properties
rain.scanPackages=com.example.command

ConfigImpl 会枚举 classpath 中所有 conf/module 资源并合并。应用引入 JAR 后,框架包就进入扫描范围;应用配置仍可继续追加自己的业务包。

何时使用 rain.modules

只有初始化必须发生在类扫描前时,才把 Module 实现写入 rain.modules。普通 Registry、Loader 和 ApplicationService 依靠扫描与构造器注入即可。

6. 管理运行时资源

如果命令框架需要监听终端、Socket 或消息队列,应把资源交给 ApplicationService,而不是在 Loader 中启动线程。

kotlin
class CommandRuntime(
    private val registry: CommandRegistry,
    @Config("command.enabled") private val enabled: Boolean,
) : ApplicationService {
    private var server: CommandServer? = null

    override fun priority() = 100

    override fun start() {
        if (!enabled) return
        server = CommandServer(registry).also { it.start() }
    }

    override fun stop() {
        server?.close()
        server = null
    }
}

模块默认配置可以放在同一个 command.properties

properties
rain.scanPackages=com.example.command
command.enabled=true

应用可在 conf/application.properties 或运行模式目录中覆盖 command.enabled

7. 应用如何使用模块

业务代码只实现公开协议:

kotlin
@Command("hello")
class HelloCommand(
    private val greetingService: GreetingService,
) : CommandHandler {
    override fun handle(context: CommandContext): String {
        val target = context.arguments.firstOrNull() ?: "world"
        return greetingService.greet(target)
    }
}

应用入口仍然只启动 Rain:

kotlin
fun main() {
    FullStackApplicationLauncher.launch()
}

启动时会发生:

  1. 模块配置把框架包和应用入口包加入扫描范围。
  2. AppLoader 发现 HelloCommand 上的 @Command
  3. 容器创建 CommandLoaderCommandRegistry 和业务处理器。
  4. Loader 把 hello 注册到 Registry。
  5. CommandRuntime.start() 在所有 Loader 完成后开放服务。

8. 测试扩展模块

框架模块至少需要三层测试。

纯单元测试

直接测试 Registry 的重复、缺失和调用语义,不启动 Rain。

kotlin
@Test
fun `registry invokes handler`() {
    val registry = CommandRegistry()
    registry.register("hello") { context -> context.arguments.joinToString() }

    assertEquals("Rain", registry.invoke("hello", listOf("Rain")))
}

Loader 契约测试

构造最小 LoadItem 集合,验证非法类型、重复名称和容器缺失时能在加载阶段失败。这里不应只测试正常路径。

完整启动测试

使用 RainTest 或独立测试应用验证:

  • conf/module 资源能从测试 classpath 被发现;
  • 应用业务包确实进入 rain.scanPackages
  • Loader 在 Runtime 之前执行;
  • stop() 能关闭线程和端口;
  • 完整启动器下没有 ClassLoader 类型冲突。

涉及 Hook、Enchanter 或 ClassTransformer 时,普通单元测试不够,必须覆盖 FullStackApplicationLauncher 建立的 AppClassloader。

9. 设计检查表

发布 Rain 扩展前逐项确认:

  • 公开边界:业务 API 是否避免暴露 ContextImpl、ASM 和实现类?
  • 发现协议:功能应由注解、接口还是父类触发?
  • 扫描边界:模块是否只加入必要包,而不是扩大到依赖根包?
  • 创建规则:被发现类是否必须是 Bean?接口和抽象类型如何处理?
  • 冲突规则:重复名称、重复注册和多个实现如何决定?
  • 顺序规则:Loader 与 ApplicationService 的优先级是否有明确含义?
  • 失败时机:可否在启动阶段完成类型、配置和依赖验证?
  • 资源归属:后台线程和外部连接是否由 ApplicationService 关闭?
  • 类加载边界:是否错误地让父加载器提前加载待增强类?
  • 测试层次:纯逻辑、Loader 契约和完整启动是否都有覆盖?

10. 进一步扩展

完成这个基础模块后,可以按需求继续演进:

  • @AutoBind 允许应用替换 CommandRegistry 或传输实现。
  • BeanFactory<CommandClient> 按名称创建远程客户端。
  • 用 EventBus 发布命令执行成功和失败事件。
  • 用 Job 定期刷新远端命令元数据。
  • ProcessProvider 把同一套命令处理器接入 Controller 调用链。
  • @HookBy 实现命令方法的审计或事务边界。

不要一次引入全部扩展机制。先选择最小的稳定协议,再在真实需求出现时增加下一层。完整入口可回到扩展点总览选择合适机制。

基于 Apache License 2.0 发布