框架模块开发实战
本页面向在 Rain 之上开发框架、插件或基础设施模块的作者。我们实现一个最小的“命令框架”:应用用 @Command 声明命令处理器,框架在启动期发现并注册它们,再由一个应用服务开放调用入口。
这个例子刻意覆盖 Rain 框架开发的完整路径:
公开 API
↓
业务类声明 @Command
↓
AppLoader 扫描并生成 LoadItem
↓
CommandLoader 建立运行时注册表
↓
ApplicationService 开放和关闭资源
↓
测试验证发现、调用与生命周期1. 拆分 API 与实现
一个可复用模块至少应区分“应用编译时需要的 API”和“框架运行时实现”:
command-api/
└─ src/main/...
├─ CommandContext
└─ CommandHandler
command-core/
└─ src/main/...
├─ Command
├─ CommandLoader
├─ CommandRegistry
└─ CommandRuntime这样业务模块只依赖稳定协议,不会直接依赖扫描实现、容器内部类型或服务器实现。Rain 自身的 tools:api、module:* 和 enhance:* 也采用类似的依赖方向。
API 模块依赖 Rain API:
dependencies {
api("com.IceCreamQAQ.Rain:api:<version>")
}实现模块再引入 Application:
dependencies {
api(project(":command-api"))
implementation("com.IceCreamQAQ.Rain:application:<version>")
}2. 设计稳定的公开协议
先定义应用真正需要理解的类型。不要把 LoadItem、DiContext 或 ASM 类型暴露到业务 API。
data class CommandContext(
val name: String,
val arguments: List<String>,
)
fun interface CommandHandler {
fun handle(context: CommandContext): Any?
}然后在核心模块定义声明注解。@LoadBy 把应用使用的声明方式与扫描实现连接起来,因此这个注解和 Loader 应位于同一个可发布模块,避免 API 模块反向依赖实现:
@LoadBy(CommandLoader::class)
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Command(val value: String)为什么 Command 不放在 API 模块
@Command 直接引用 CommandLoader,两者天然属于同一个声明与发现机制。真正可独立复用的 CommandContext 和 CommandHandler 留在 API 模块;应用使用注解时依赖 command-core。不要为了形式上的分层制造循环依赖。
3. 建立运行时注册表
注册表只负责运行时状态,不负责扫描:
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:
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 做了框架实现必须承担的四类校验:
- 验证触发来源确实是预期注解。
- 验证业务类型满足公开协议。
- 验证容器能够创建处理器。
- 让注册顺序和冲突行为保持确定。
不要把错误拖到第一次命令调用。Loader 阶段失败能让应用在启动时立即暴露错误配置。
5. 让基础设施成为 Bean
CommandRegistry 是普通具体类,处于 rain.scanPackages 时可以被容器直接创建。如果框架包已经通过 conf/module/*.properties 加入扫描范围,不需要额外 Module。
在实现模块中创建:
src/main/resources/conf/module/command.properties内容为:
rain.scanPackages=com.example.commandConfigImpl 会枚举 classpath 中所有 conf/module 资源并合并。应用引入 JAR 后,框架包就进入扫描范围;应用配置仍可继续追加自己的业务包。
何时使用 rain.modules
只有初始化必须发生在类扫描前时,才把 Module 实现写入 rain.modules。普通 Registry、Loader 和 ApplicationService 依靠扫描与构造器注入即可。
6. 管理运行时资源
如果命令框架需要监听终端、Socket 或消息队列,应把资源交给 ApplicationService,而不是在 Loader 中启动线程。
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:
rain.scanPackages=com.example.command
command.enabled=true应用可在 conf/application.properties 或运行模式目录中覆盖 command.enabled。
7. 应用如何使用模块
业务代码只实现公开协议:
@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:
fun main() {
FullStackApplicationLauncher.launch()
}启动时会发生:
- 模块配置把框架包和应用入口包加入扫描范围。
AppLoader发现HelloCommand上的@Command。- 容器创建
CommandLoader、CommandRegistry和业务处理器。 - Loader 把
hello注册到 Registry。 CommandRuntime.start()在所有 Loader 完成后开放服务。
8. 测试扩展模块
框架模块至少需要三层测试。
纯单元测试
直接测试 Registry 的重复、缺失和调用语义,不启动 Rain。
@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实现命令方法的审计或事务边界。
不要一次引入全部扩展机制。先选择最小的稳定协议,再在真实需求出现时增加下一层。完整入口可回到扩展点总览选择合适机制。