深入 Spring(00):从手动组装到可验证的容器实验
订单服务能够计算金额并保存订单,是否必须使用 Spring?可以先直接创建三个对象,调用结果与容器装配后的结果相同。差异出现在依赖由谁选择、对象在何时创建、失败在何处暴露,以及关闭时由谁执行清理。理解这些差异,比记住一个启动注解更适合作为源码阅读的起点。
本篇固定一个可检验的问题:把同一份订单业务交给容器装配,业务结果、缺依赖时的异常与资源终态分别怎样变化?示例使用内存仓储,仅运行单线程调用。实验不涉及 HTTP、事务、数据库或 Spring Boot。
冻结版本与复现入口
教学工程采用 Spring Framework 6.2.11、JDK 21,对应 release tag v6.2.11,完整源码 SHA 为 4c134254642d88e058aa004bdaf44168e1be7bb2。官方发布记录与版本提交用于核对坐标和源码。
Framework 6.x 最低要求 Java 17,采用 Jakarta 命名空间。本系列运行 JDK 21;操作系统默认的 Java 8 无法编译本实验。6.2 已于 2026 年 6 月结束开源支持,因此这里固定它作为机制研究版本。生产版本选择需要另外检查支持周期与依赖兼容性。框架概览、官方版本政策
在线 reference/6.2/ 文档按分支更新,读取时已显示 6.2.19。正文引用内部方法时使用上述 SHA,避免用滚动文档中的新增行为解释旧二进制。依赖树与实际 JDK 输出收录在工程 evidence/environment/;应用运行通过不代表上游 Spring Gradle 工程已经编译或测试。
下载全系列实验工程(52篇),解压后进入工程目录。仓库内对应位置是 examples/spring-framework-lab/。
1 | |
运行前将 JAVA_HOME 指向本机 JDK 21。Maven Wrapper 首次使用及解析依赖需要访问官方制品仓库;已有完整缓存时可在单篇 Maven 命令中加 -o。单篇入口退出码非零表示编译、运行或行为断言失败,不能仅凭控制台出现过 PASS 判定整次运行成功。
订单对象图先独立成立
OrderService 接收仓储和价格策略,数量必须大于零,单价固定为 12.50。金额采用 BigDecimal,避免二进制浮点数影响等值判断。价格策略计算金额,仓储保存金额,服务协调两次普通方法调用。
1 | |
完整 import 和其余类在下载工程中。服务没有调用 getBean(),也不读取配置文件。构造器描述它完成工作需要哪些协作者;调用者负责提供实例。这样的边界使业务能够在普通 Java 环境中运行,也使装配失败与业务计算失败具有不同的定位入口。
手动组装只需明确三条引用:
1 | |
这一次返回 25.00,仓储保存一条记录。服务持有的仓储与创建时传入的仓储是同一个对象;没有隐藏的服务定位器。对象图中每条边都是实际字段引用,类之间存在声明上的依赖,并不自动意味着实例之间已经建立引用。
业务对象保持显式构造参数,会暴露一项约束:服务无法在缺少仓储时完成构造。容器保留这项约束,只把“在启动代码中挑选对象并调用构造器”转移到配置与依赖解析流程。依赖注入契约
把组装责任交给容器
同一份业务类可注册进 AnnotationConfigApplicationContext:
1 | |
register() 提交类的定义;refresh() 组织启动工作;getBean() 获取可使用的实例。把三个方法写在一起很容易忽略中间状态:定义已经注册时,普通业务实例可能尚未创建。下一篇用计数器把这两个状态分开。
本例 OrderService 只有一个构造器,注解上下文中的相关处理器能够选择它,无须额外标记 @Autowired。但这个结论带有环境条件:换成只注册定义的最小 DefaultListableBeanFactory,并不自动获得注解上下文的所有处理器。注册方式与处理器集合都属于实验条件。单构造器规则
AnnotationConfigApplicationContext 的无参构造器建立 reader 和 scanner;显式注册通过 reader 生成定义。随后 AbstractApplicationContext.refresh() 调用工厂后置处理器、注册实例后置处理器,并在 finishBeanFactoryInitialization() 阶段创建剩余非延迟单例。固定版本注解上下文、启动实现
这里可观察到一次状态变化:定义进入工厂后,构造器解析与实例创建把它变成服务对象。决策分支是必要参数是否存在合格候选;外部后果是应用能够执行订单操作,或者在 refresh() 返回前抛出异常。注解只是输入,处理器与创建过程才使输入产生行为。
正常结果与缺依赖反例
Chapter00 在同一次运行中比较手动服务和容器服务。它同时检查金额和保存次数,防止“金额相同但没有真正保存”漏过验证。原始输出节选如下:
1 | |
数量校验位于业务模型中。因此手动创建并不会失去这项约束,容器创建也不会自动增加事务性。内存仓储使用 ArrayList,没有实现并发控制;此次单线程结果不能证明多请求安全,更不能用作数据库提交的证据。
故障组省去 OrderRepository,仍注册服务与价格策略:
1 | |
fails 是工程中的检查辅助方法:执行操作、验证异常类型,不符合预期就抛 AssertionError。实际外层异常是 UnsatisfiedDependencyException,信息指向 orderService 的构造器参数 0,缺少 OrderRepository 类型的合格候选。它在普通非延迟单例初始化时暴露,未进入 place()。
因此诊断从“服务构造所需的引用”开始,查看定义是否注册、类型是否匹配、候选是否被限定。盲目补一个 @Autowired 无法产生缺失的仓储定义。多个候选与延迟解析会改变失败条件,第 05、06 篇分别检验这些分支。
关闭后检查资源终态
仓储实现 DisposableBean,destroy() 将 closed 标记置为 true。此标记用于验证销毁回调,示例没有真实连接或文件句柄。退出 try-with-resources 后,实验继续检查先前取得的对象:
1 | |
手动仓储仍由手动组装代码持有,必须显式调用其 destroy()。上下文关闭只管理已纳入其销毁机制的对象。一个 Java 引用在关闭后仍然存在,不表示该对象的外部资源仍可使用;本例 save() 会拒绝向已关闭仓储写入。销毁回调契约
源码中的 doClose() 执行关闭事件、生命周期停止与 destroyBeans(),后者调用工厂的单例销毁。refresh() 的内层失败处理也会销毁已创建单例并取消 active 状态,但它的覆盖范围有明确边界;第 03 篇用不同失败阶段验证,不能扩大成任意启动错误下所有资源都会被回收。固定版本关闭与失败路径
上游 GenericApplicationContextTests 中的 refreshWithRuntimeFailureOnBeanCreationDisposeExistingBeans 检查普通 Bean 创建失败后,先前初始化成功的 Bean 已被销毁;另一个测试覆盖 SmartInitializingSingleton 回调失败。本批读取了这些测试,执行的是下载工程的独立实验,未运行上游测试任务。对应上游测试
模式提炼:组装入口与生命周期所有者
构造器注入让业务对象声明依赖,组合根集中选择实现和连接引用。手动启动代码与 Spring 配置都可以充当组合根。它解决装配决策散落在业务方法中的问题;代价是需要另一个入口维护对象图。很小的程序可保持手动组装,组件和生命周期增加后再采用容器,业务类无需因此承担查找服务的职责。
生命周期所有者需要同时知道对象何时可以使用、何时必须释放。只统一创建而没有统一关闭,容易留下线程或连接。容器为注册的可销毁单例提供关闭路径,但 prototype、外部创建资源与未完成注册的对象仍需另外明确责任;这些条件将在后续生命周期篇扩展。
| 模式 | 本例机制 | 适用条件 | 代价与边界 |
|---|---|---|---|
| 组合根 | register与构造参数连接对象图 | 装配变化多于业务算法变化 | 配置错误成为启动问题;手动代码也能实现 |
| 生命周期所有者 | close触发受管理对象的destroy | 有需要释放的长期资源 | 只覆盖纳入管理的对象与对应销毁契约 |
练习与判断依据
把数量改成 0,预计在哪里失败?答案是 PricePolicy.total() 的业务校验;对象图仍然可以构造,失败不属于启动阶段。实验已经检查非正数量抛 IllegalArgumentException。
删除仓储注册,随后给服务构造器补 @Autowired,能否恢复启动?不能。注入声明没有补充仓储候选,仍应检查缺定义的根因。实验验证了未注册仓储时的失败;添加多余注解的改写留作读者实验,不能把它当成本批运行输出。
关闭上下文后,保留的 managedRepository 引用会不会变成 null?不会。销毁回调改变了对象状态,Java 引用仍指向对象。验证释放应检查资源或标记的终态,而不是等待引用消失。
阅读路线与证据范围
主线00–43依次覆盖容器、代理、事务、Web、并发与综合验收;E01–E08分别研究生态集成和实现边界。完整下载包包含各篇代码、模块说明和原始实验记录。独立模块必须使用其README中的POM或脚本,不能用根工程的run-all.sh代替。
本地Chapter00在JDK21、Spring6.2.11上通过11条断言,原始输出位于工程evidence/00/local-20261002/。本篇只覆盖内存装配、异常与关闭,不使用数据库、网络或并发。其他章节分别运行真实HTTP、数据库、响应式事务、迁移与AOT JVM;各自结论以对应程序、版本和日志为界。全系列共有862条行为断言,上游Gradle/JUnit与GraalVM原生编译未运行。
