总结:框架为什么会出现,又为什么越来越重要
学完前面的概念后,我们最后做一次完整梳理。
这一页不再专门解释某个注解或某个容器接口,而是尝试回答一个更大的问题:
明明程序只需要按照顺序执行代码,为什么后来会出现“框架”?为什么行业发展越深入,框架反而越来越复杂、越来越深入,也越来越难以从现代软件工程中移除?
要回答这个问题,不能只看一段代码是否能运行,而要观察软件如何从一个人的小程序,逐渐变成由许多人长期维护、持续变化、运行在复杂环境中的工程系统。
1. 最初,程序只是明确的步骤
一个最简单的程序往往非常容易理解:
fun main() {
val name = readln()
println("Hello, $name")
}它有一个入口、一条主要执行路径和少量数据。开发者能够在脑中完整模拟它的运行:先读输入,再输出结果。
这种程序不需要 IoC 容器,也不需要组件扫描、生命周期、事件总线和字节码增强。加入这些机制反而会使它更难理解。
所以,框架并不是因为普通代码“不够高级”才出现。框架出现的根本原因是:软件规模、协作人数、运行时间和变化频率不断增长,原本局部而明确的问题开始变成重复的系统性问题。
2. 第一次增长:代码开始重复
假设我们需要写多个网络服务。每个服务都需要:
- 读取配置;
- 打开端口;
- 接收请求;
- 解析参数;
- 调用业务逻辑;
- 捕获异常;
- 记录日志;
- 返回响应;
- 在程序关闭时释放资源。
如果每个项目都从头编写这些步骤,大量代码会重复。于是开发者开始提取工具函数和公共库:
val request = HttpUtil.parse(input)
val response = JsonUtil.encode(result)这是非常自然的第一步:把重复的局部能力做成库。
但随着项目继续发展,人们发现重复的不只是某个函数,而是整个程序骨架。
每个服务都在重复相似的启动流程、请求流程、异常流程和关闭流程。真正变化的部分,可能只是:
fun createUser(command: CreateUserCommand): User于是新的问题出现了:能否让公共代码掌握稳定流程,业务代码只提供变化部分?
这就是框架开始出现的位置。
3. 库与框架的分界
使用库时,是应用调用库:
应用代码 ──调用──> 工具库使用框架时,框架掌握主要流程,并在约定位置调用应用:
框架流程 ──回调──> 应用组件例如,业务代码只声明一个事件监听器:
@EventListener
class UserListener {
@SubscribeEvent
fun onCreated(event: UserCreated) = Unit
}而框架负责:
- 扫描这个类;
- 识别监听器注解;
- 创建监听器实例;
- 注入它需要的对象;
- 找到监听方法;
- 将方法注册到事件总线;
- 在事件出现时调用它;
- 处理调用中的异常。
业务开发者只表达“当用户创建后执行这段逻辑”,没有重复实现事件基础设施。
这就是控制反转最朴素的来源:稳定流程属于框架,变化逻辑属于应用。
4. 第二次增长:对象关系开始复杂
当应用只有两三个对象时,入口可以手工组装:
val database = Database(config)
val repository = UserRepository(database)
val service = UserService(repository)当应用拥有数百个服务、仓库、监听器、任务和适配器后,手工组装会遇到新的系统性问题:
- 谁负责创建每个对象?
- 同一个对象是否应该复用?
- 接口有多个实现时选择哪个?
- 测试中如何替换实现?
- 对象的创建顺序如何确定?
- 配置值如何进入对象?
- 插件如何接入一个编译时并不知道它的应用?
对象之间的关系逐渐形成一张很大的图:
Controller
└─ Service
├─ Repository
│ └─ DatabasePool
├─ EventBus
└─ PermissionService
├─ UserProvider
└─ PolicyProvider此时,依赖注入容器开始承担“应用级对象图组装器”的角色。
它并不是为了禁止 new,而是为了集中管理那些:
- 生命周期较长;
- 被多个模块共享;
- 需要配置和注入;
- 需要接口替换;
- 需要框架发现;
- 需要统一启动和停止的对象。
Rain 的 DiContext、ClassContext、BeanCreator 与 BeanInjector 就是在回答这些问题。
5. 第三次增长:功能不再只来自核心团队
早期程序通常由一个团队掌握全部代码。后来,应用越来越依赖模块、插件和第三方集成。
框架不可能提前知道未来会出现哪些业务类型,但它可以提前规定扩展协议:
@LoadBy(MyFeatureLoader::class)
annotation class MyFeature只要模块遵循协议,Rain 就可以在扫描阶段发现它,并把信息交给对应 Loader。
这改变了软件的组织方式:
过去:中央入口知道所有模块
现在:模块声明自己,框架根据协议发现模块中央代码不必随着每个插件增加而修改。框架成为多个独立模块之间的共同运行规则。
这种能力越深入,框架就越不只是“帮助写业务的工具”,而逐渐成为应用的内部操作系统:
- 它发现组件;
- 它分配对象;
- 它连接模块;
- 它调度任务;
- 它传递事件;
- 它管理启动和关闭;
- 它规定扩展如何进入系统。
这里的“操作系统”是帮助理解的比喻,并不是说框架真的替代 JVM 或系统内核。它表达的是:框架开始管理应用内部大量公共资源和运行规则。
6. 第四次增长:非业务问题超过业务代码
一个生产应用需要处理的事情远不止业务计算:
- 配置与多环境覆盖;
- 日志和链路追踪;
- 权限与认证;
- 事务;
- 并发和任务调度;
- 超时、重试和熔断;
- 监控与健康检查;
- 启动、停止和资源回收;
- 测试环境与生产环境差异;
- 模块加载与版本兼容;
- 序列化、网络协议和错误转换。
这些问题被称为“横切关注点”或“基础设施问题”:它们会穿过很多业务模块,但又不属于某一个具体业务。
如果每个业务方法都亲自处理它们,代码会变成:
fun createOrder(command: CreateOrder): Order {
checkPermission()
startTrace()
beginTransaction()
try {
val result = doCreateOrder(command)
commitTransaction()
recordMetrics()
return result
} catch (error: Throwable) {
rollbackTransaction()
recordError(error)
throw error
} finally {
endTrace()
}
}真正的业务逻辑被大量基础设施代码包围。
框架继续深入,是因为行业不断尝试把这些稳定而重复的机制放到统一边界中。代理、拦截器、处理链、事件、生命周期和字节码增强,都是这种探索的结果。
Rain 的 Controller 过程模型、EventBus、Job、Hook 和 AppClassloader,分别尝试为不同类型的横切与运行时问题提供统一机制。
7. 为什么框架会越来越“深”
框架最初可能只是帮我们处理请求。后来它开始管理对象、配置、线程、事务、事件、类加载和测试环境。为什么会这样?
因为问题之间并不独立
例如一个任务调度器需要:
- 从容器获得任务实例;
- 从配置读取执行周期;
- 在应用启动时注册;
- 在应用停止时取消;
- 发生异常时发布事件;
- 在测试中使用替代环境。
任务调度已经同时依赖 DI、配置、生命周期、事件和测试基础设施。
框架能力越多,它们之间的协作就越深。一个统一框架能够让这些机制共享相同的对象模型、配置模型和生命周期,从而避免每个模块各自发明一套规则。
因为使用者要求更高的一致性
当一个应用拥有几十个模块时,“每个模块都能工作”还不够。开发者还希望:
- 所有模块用相同方式读取配置;
- 所有模块能注入同一批服务;
- 所有模块遵循同一启动顺序;
- 所有异常能进入统一处理链;
- 所有扩展都能被测试框架发现;
- 所有资源都能在关闭时释放。
为了提供一致性,框架必然要进入更多运行阶段。
因为工程问题需要统一治理
单个开发者可以记住自己创建了哪些对象。大型团队无法依赖每个人都记住所有隐含约定。
框架把约定编码成可重复执行的机制:
不是“记得注册监听器”
而是“所有 @EventListener 都由 Loader 注册”
不是“记得关闭每个服务”
而是“所有 ApplicationService 都进入统一停止流程”框架越深入,越能够把团队经验从口头约定变成程序规则。
8. 为什么现代工程越来越难完全丢弃框架
“不可丢弃”不应该理解为任何程序都必须使用某个具体框架。一个小工具完全可以不使用 Rain、Spring 或其他应用框架。
更准确的说法是:当软件进入长期运行、多人协作、模块众多、持续变化的阶段后,它必然需要某种框架化结构。
即使团队宣布“我们不用框架”,通常也会逐渐自己写出:
- 全局对象注册表;
- 配置加载器;
- 模块初始化接口;
- 事件分发器;
- 请求处理链;
- 插件扫描器;
- 测试启动器;
- 关闭钩子。
这些东西组合起来,就是一个内部框架,只是它可能没有被称为框架,也未必经过充分设计和验证。
框架保存了行业经验
成熟框架不仅保存代码,还保存大量失败经验:
- 什么阶段创建对象更安全;
- 启动失败如何报告;
- 多实现如何消除歧义;
- 资源怎样关闭;
- 扩展怎样排序;
- 测试怎样复用运行环境;
- 如何避免每个模块互相直接依赖。
完全丢弃框架,往往意味着团队需要重新经历这些问题。
生态建立在共同协议上
数据库、网络、监控、测试和部署工具之所以能够组合,是因为它们共享框架协议。
一个模块只要知道如何成为 Bean、如何读取配置、如何注册生命周期,就能接入许多应用。框架提供共同语言,使生态组件不必针对每个项目单独适配。
人员协作需要可预期结构
新成员进入项目时,如果知道框架约定,就能预测:
- 服务通常在哪里创建;
- 配置从哪里进入;
- 监听器如何注册;
- 应用如何启动;
- 测试怎样获得依赖。
没有共同结构时,每个项目、每个模块甚至每个开发者都可能采用不同方式。
因此,框架的重要性不仅来自运行时能力,也来自它提供的团队沟通结构。
9. 但框架不是免费的
框架越深入,它带来的代价也越大:
- 程序行为更依赖运行时约定;
- 错误可能从编译期移动到启动期;
- 调用关系不再全部显式可见;
- 调试需要理解容器、扫描和生命周期;
- 自定义类加载与增强会增加工具链复杂度;
- 团队可能为了“符合框架”而写出不自然的业务模型;
- 版本升级可能影响大量模块。
所以,框架的目标不应是接管一切,而应是接管那些稳定、重复、需要统一治理的部分。
下面是一个有用的判断:
| 问题 | 更适合框架统一处理 | 更适合业务显式处理 |
|---|---|---|
| 对象图组装 | 应用级共享服务 | 方法内部临时对象 |
| 生命周期 | 数据库池、服务器、任务中心 | 普通值对象 |
| 扩展发现 | 监听器、任务、Controller | 单次明确调用 |
| 横切能力 | 日志、权限、统一异常流程 | 独特业务步骤 |
| 配置 | 环境相关外部输入 | 业务计算产生的局部值 |
框架与普通代码不是二选一。一个健康应用通常同时存在:
- 由框架管理的应用级组件;
- 由业务对象直接创建的局部对象;
- 由构造器显式表达的依赖;
- 由框架自动完成的重复组装。
10. Rain 在这条演进路线中的位置
可以把 Rain 的主要模块放回前面的历史问题中理解:
| 行业逐渐遇到的问题 | Rain 的回答 |
|---|---|
| 应用如何统一启动和停止 | Application、ApplicationService |
| 对象如何创建和连接 | DiContext、ClassContext、DI |
| 配置如何进入对象 | ConfigManager、@Config |
| 接口如何找到实现 | @AutoBind、命名绑定 |
| 模块如何声明扩展 | @LoadBy、Loader |
| 模块如何低耦合通信 | EventBus |
| 定时工作如何统一调度 | Job 模块 |
| 请求式流程如何抽象 | Controller 模块 |
| 横切行为如何进入方法 | Hook 与类加载增强 |
| 测试如何运行在相同环境 | rain-test |
这些模块不是互不相关的工具集合。它们共享应用扫描、DI 容器、生命周期和类加载环境,共同组成 Rain 的应用模型。
11. 用一条请求理解完整框架
假设一个外部请求最终调用 UserController.create()。表面上只是一个方法调用,背后可能经历:
应用启动
↓
配置系统确定扫描包和运行模式
↓
AppLoader 扫描 UserController、UserService 等类型
↓
DiContext 建立类型上下文和接口绑定
↓
ControllerLoader 发现并注册 Action
↓
ApplicationService 启动服务器
↓
请求进入 Router
↓
Before 过程执行权限检查
↓
ActionInvoker 调用 UserController.create()
↓
UserController 使用注入的 UserService
↓
UserService 使用 Repository 保存数据
↓
EventBus 发布 UserCreated
↓
监听器执行审计或通知
↓
After 过程整理结果
↓
响应返回框架的意义,是让这些阶段拥有共同规则。业务开发者不必在每个 Action 中重新编写扫描、注入、权限、事件和异常处理机制。
进阶开发者则需要理解这些阶段,因为定制框架、排查启动错误和设计新模块时,必须知道自己的代码进入了哪一层。
12. 新人应该建立的完整知识地图
可以把目前学到的概念整理成六层。
第一层:普通对象
类通过构造器接收数据和协作者。即使没有框架,仍应尽量保持职责清晰、依赖显式。
第二层:依赖关系
对象不是孤立存在的。构造器参数表达直接依赖,接口表达可替换边界,多实现需要选择规则。
第三层:容器
容器读取依赖关系,创建并保存应用级对象,形成对象图。Rain 使用 DiContext 与 ClassContext 完成这些工作。
第四层:控制反转
框架不只创建对象,还掌握扫描、调用时机、扩展注册和生命周期。应用提供组件,框架驱动稳定流程。
第五层:模块与横切能力
事件、任务、Controller、配置和 Hook 依赖共同的容器与生命周期,相互组合成完整应用。
第六层:工程治理
框架把团队经验编码成统一规则,使模块可接入、行为可预测、测试可复用、生态可组合。
理解这六层后,再看到一个框架注解时,就不会只问“怎么写”,而会继续追问:
- 它在哪个阶段被发现?
- 它改变了对象创建,还是改变了方法调用?
- 它由哪个 Loader 或服务处理?
- 它依赖哪些容器状态?
- 它失败时会在哪个阶段报错?
- 如果不用它,需要手工实现哪些流程?
这组问题是从“框架使用者”走向“框架理解者”的关键。
13. 对 Rain 新手的学习顺序
建议按下面顺序实践:
- 先写普通 Kotlin 类:使用构造器明确传入依赖。
- 手工组装一次:在
main中亲自创建完整对象图。 - 再让 Rain 组装:比较手工创建与容器创建的差异。
- 加入接口与两个实现:理解命名绑定和候选选择。
- 加入配置注入:观察外部环境如何进入对象。
- 加入事件监听器:理解扫描、Loader 和回调。
- 加入 ApplicationService:理解启动与停止控制权。
- 加入任务或 Controller:观察模块如何共享容器。
- 编写普通单元测试:手工传入 fake 依赖。
- 编写 RainTest:比较纯对象测试和完整上下文测试。
- 最后研究 Hook:先理解类加载,再使用字节码增强。
不要从最复杂的增强机制开始。框架越深入,越需要前面的对象设计、依赖关系和生命周期知识作为基础。
14. 最后的边界:使用框架,但不要失去普通代码
框架最危险的误用,是让所有业务逻辑都只能在框架环境中理解和运行。
一个良好的 Rain 业务类仍然应该像普通 Kotlin 类:
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
}
}Rain 负责在真实应用中提供 repository 和 eventBus。测试也可以手工传入 fake 实现。
这代表一种健康关系:
业务对象表达业务和依赖
框架负责应用级组装与运行而不是:
业务对象到处查找框架状态
没有框架便无法构造和测试框架越来越重要,不代表业务代码应该越来越框架化。恰恰相反,框架越强,越应该帮助业务代码保持简单。
15. 最终总结
框架诞生于重复:许多程序不断重复相同的启动、组装、调用和关闭流程。
框架成长于规模:对象、模块、团队和运行环境增加后,需要统一管理依赖、配置、生命周期和扩展。
框架深入于协作:事件、任务、请求、测试和增强必须共享同一套应用规则,才能稳定组合。
框架难以丢弃,是因为现代软件最终总会需要某种框架化结构。放弃现成框架,往往不是消灭框架,而是开始编写自己的框架。
但框架也不是目的。框架真正应该实现的是:
把重复、稳定、需要统一治理的机制集中起来,让业务代码专注表达变化,并让开发者能够预测整个系统如何运行。
理解 Rain,也就是理解这条边界:
- 容器组装对象,但不替你设计对象;
- Loader 发现扩展,但不应隐藏普通业务流程;
- 生命周期统一服务,但仍需要明确资源关系;
- Hook 提供底层能力,但不应代替清晰设计;
- 框架减少重复,但不会消灭复杂度。
当你能够看见框架替你完成的工作,也能够看见这些自动化背后的代价时,你就不再只是会使用一个框架,而是开始真正理解框架为什么存在。