订单服务能够计算金额并保存订单,是否必须使用 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
2
3
4
java -version
./mvnw -version
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter00
./run-all.sh

运行前将 JAVA_HOME 指向本机 JDK 21。Maven Wrapper 首次使用及解析依赖需要访问官方制品仓库;已有完整缓存时可在单篇 Maven 命令中加 -o。单篇入口退出码非零表示编译、运行或行为断言失败,不能仅凭控制台出现过 PASS 判定整次运行成功。

订单对象图先独立成立

OrderService 接收仓储和价格策略,数量必须大于零,单价固定为 12.50。金额采用 BigDecimal,避免二进制浮点数影响等值判断。价格策略计算金额,仓储保存金额,服务协调两次普通方法调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class OrderService {
private final OrderRepository repository;
private final PricePolicy policy;

public OrderService(OrderRepository repository, PricePolicy policy) {
this.repository = Objects.requireNonNull(repository);
this.policy = Objects.requireNonNull(policy);
}

public BigDecimal place(int quantity) {
BigDecimal amount = policy.total(quantity);
repository.save(amount);
return amount;
}
}

完整 import 和其余类在下载工程中。服务没有调用 getBean(),也不读取配置文件。构造器描述它完成工作需要哪些协作者;调用者负责提供实例。这样的边界使业务能够在普通 Java 环境中运行,也使装配失败与业务计算失败具有不同的定位入口。

手动组装只需明确三条引用:

1
2
3
4
var repository = new OrderRepository();
var policy = new PricePolicy();
var service = new OrderService(repository, policy);
BigDecimal amount = service.place(2);

这一次返回 25.00,仓储保存一条记录。服务持有的仓储与创建时传入的仓储是同一个对象;没有隐藏的服务定位器。对象图中每条边都是实际字段引用,类之间存在声明上的依赖,并不自动意味着实例之间已经建立引用。

手动与容器装配的对象图

业务对象保持显式构造参数,会暴露一项约束:服务无法在缺少仓储时完成构造。容器保留这项约束,只把“在启动代码中挑选对象并调用构造器”转移到配置与依赖解析流程。依赖注入契约

把组装责任交给容器

同一份业务类可注册进 AnnotationConfigApplicationContext:

1
2
3
4
5
6
7
OrderRepository managedRepository;
try (var context = new AnnotationConfigApplicationContext()) {
context.register(OrderRepository.class, PricePolicy.class, OrderService.class);
context.refresh();
managedRepository = context.getBean(OrderRepository.class);
BigDecimal amount = context.getBean(OrderService.class).place(2);
}

register() 提交类的定义;refresh() 组织启动工作;getBean() 获取可使用的实例。把三个方法写在一起很容易忽略中间状态:定义已经注册时,普通业务实例可能尚未创建。下一篇用计数器把这两个状态分开。

本例 OrderService 只有一个构造器,注解上下文中的相关处理器能够选择它,无须额外标记 @Autowired。但这个结论带有环境条件:换成只注册定义的最小 DefaultListableBeanFactory,并不自动获得注解上下文的所有处理器。注册方式与处理器集合都属于实验条件。单构造器规则

AnnotationConfigApplicationContext 的无参构造器建立 reader 和 scanner;显式注册通过 reader 生成定义。随后 AbstractApplicationContext.refresh() 调用工厂后置处理器、注册实例后置处理器,并在 finishBeanFactoryInitialization() 阶段创建剩余非延迟单例。固定版本注解上下文、启动实现

这里可观察到一次状态变化:定义进入工厂后,构造器解析与实例创建把它变成服务对象。决策分支是必要参数是否存在合格候选;外部后果是应用能够执行订单操作,或者在 refresh() 返回前抛出异常。注解只是输入,处理器与创建过程才使输入产生行为。

正常结果与缺依赖反例

Chapter00 在同一次运行中比较手动服务和容器服务。它同时检查金额和保存次数,防止“金额相同但没有真正保存”漏过验证。原始输出节选如下:

1
2
3
4
5
CHECK PASS 00 manual/container both return 25.00
CHECK PASS 00 both repositories saved once
CHECK PASS 00 reject zero quantity -> IllegalArgumentException
CHECK PASS 00 reject negative quantity -> IllegalArgumentException
CHECK PASS 00 invalid amount does not save

数量校验位于业务模型中。因此手动创建并不会失去这项约束,容器创建也不会自动增加事务性。内存仓储使用 ArrayList,没有实现并发控制;此次单线程结果不能证明多请求安全,更不能用作数据库提交的证据。

故障组省去 OrderRepository,仍注册服务与价格策略:

1
2
3
4
5
try (var missing = new AnnotationConfigApplicationContext()) {
missing.register(OrderService.class, PricePolicy.class);
fails(UnsatisfiedDependencyException.class, missing::refresh,
"00 missing repository fails refresh");
}

fails 是工程中的检查辅助方法:执行操作、验证异常类型,不符合预期就抛 AssertionError。实际外层异常是 UnsatisfiedDependencyException,信息指向 orderService 的构造器参数 0,缺少 OrderRepository 类型的合格候选。它在普通非延迟单例初始化时暴露,未进入 place()。

因此诊断从“服务构造所需的引用”开始,查看定义是否注册、类型是否匹配、候选是否被限定。盲目补一个 @Autowired 无法产生缺失的仓储定义。多个候选与延迟解析会改变失败条件,第 05、06 篇分别检验这些分支。

关闭后检查资源终态

仓储实现 DisposableBean,destroy() 将 closed 标记置为 true。此标记用于验证销毁回调,示例没有真实连接或文件句柄。退出 try-with-resources 后,实验继续检查先前取得的对象:

1
2
3
4
CHECK PASS 00 context close destroys managed repository
CHECK PASS 00 manual repository requires explicit destroy
CHECK PASS 00 missing repository fails refresh -> UnsatisfiedDependencyException
CHAPTER 00 PASS

手动仓储仍由手动组装代码持有,必须显式调用其 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代替。

篇 阅读入口
00 从手动组装到可验证的容器实验
01 IoC对象图与容器身份
02 BeanDefinition如何描述对象创建
03 refresh如何组织容器启动
04 配置类解析与Bean方法调用
05 依赖解析如何筛选候选对象
06 构造器工厂与延迟创建
07 生命周期回调与最终代理引用
08 扩展顺序与过早创建
09 循环依赖与早期代理一致性
10 作用域对象身份与销毁责任
11 属性解析与条件装配的时间边界
12 事件发布监听顺序与完成信号
13 启停顺序层级查找与资源终态
14 JDK与CGLIB代理的拦截边界
15 Advisor怎样进入Bean的创建过程
16 切点的静态筛选与运行时匹配
17 proceed与拦截器链的返回和异常
18 自调用目标引用与方法元数据
19 编程式事务连接绑定与提交结果
20 声明式事务的方法属性与代理入口
21 事务传播保存点与连接池等待
22 回滚规则异常捕获与rollback-only
23 提交回调与事务事件的保证边界
24 JDBC与ORM的事务资源边界
25 DispatcherServlet与请求的多次分派
26 路径方法与媒体类型怎样选择处理器
27 查询参数表单与JSON的绑定边界
28 校验与异常发生在哪个处理阶段
29 返回值消息转换与响应提交边界
30 MVC异步超时取消与资源回收
31 Async的提交执行与失败观察
32 Scheduled的时间基准重叠与关闭
33 缓存键并发加载与事务回滚
34 线程上下文的捕获恢复与资源边界
35 WebFlux的订阅需求与阻塞边界
36 响应式事务取消窗口与重新订阅
37 HTTP客户端的超时重试与连接释放
38 Boot自动配置的输入退让与属性绑定
39 导入选择注册与扩展边界
40 观测事件资源等待与业务终态
41 测试边界与提交回调的盲区
42 AOT生成代码与运行时配置的边界
43 订单应用的六个综合验收场景
E01 AspectJ与代理之外的连接点
E02 Security过滤器与方法安全
E03 Repository代理查询与事务
E04 XA恢复与outbox的一致性承诺
E05 虚拟线程MVC与WebFlux的资源上限
E06 Kotlin挂起调用事务与上下文边界
E07 从6到7的兼容矩阵与行为迁移
E08 最小容器的对象图代理与失败契约

本地Chapter00在JDK21、Spring6.2.11上通过11条断言,原始输出位于工程evidence/00/local-20261002/。本篇只覆盖内存装配、异常与关闭,不使用数据库、网络或并发。其他章节分别运行真实HTTP、数据库、响应式事务、迁移与AOT JVM;各自结论以对应程序、版本和日志为界。全系列共有862条行为断言,上游Gradle/JUnit与GraalVM原生编译未运行。