深入 Spring E08:最小容器的对象图、代理与失败契约
注册、注入和拦截为什么仍有这么多边界
把类型放进Map、反射调用构造器、用动态代理包一层,就能运行一个很小的依赖注入容器。真正需要解释的是对象何时进入缓存、依赖得到原始对象还是代理、构造失败以后下一次查询会发生什么,以及同一个对象从不同入口调用时哪些方法经过拦截器。
E08把容器缩减到三个功能:显式注册一个接口到一个实现、通过唯一public构造器注入依赖、为最终接口引用安装一个拦截器。完整代码在下载工程的minimal-lab/MinimalContainerLab.java,只有JDK21依赖。这里的目标是用可以运行的差异解释成熟容器的责任范围。
Spring对照基线仍为6.2.11,源码固定在提交4c134254642d88e058aa004bdaf44168e1be7bb2。本练习没有启动Spring,也没有重新运行上游测试;Spring侧的真实对象图与代理实验分别见03、05、07、14–18。本篇实际运行最小容器,得到21条断言和退出码0。
一个接口对应一个定义,定义先于实例
注册表保存Map<Class<?>, Class<?>>。键是调用方要求的接口,值是实现类;另一个Map保存已经成功发布的代理。两张表保存不同阶段的事实,不能合并成一个“类型到对象”的缓存。
注册Repository与Orders以后,MemoryRepository.constructions仍为0。第一次查找Orders才沿构造器参数找到Repository,创建依赖,再创建OrderService。重复查找Orders返回同一个代理。注册不意味着对象已经存在,singleton身份也不意味着业务实现拥有线程安全性。
本容器按照接口键判断唯一性,不按Java类的可赋值关系搜索候选。Orders需要Repository时,必须显式注册Repository;注册一个实现类并不会使它自动成为所有父接口的候选。泛型、Qualifier、Primary和集合注入均不支持。
这种限制缩小了候选选择问题,却也缩小了能够表达的对象图。Spring通过BeanDefinition保存更丰富的创建元数据,通过依赖描述和候选规则寻找对象;本容器把调用者已经决定的映射记录下来。05篇中的泛型与候选歧义实验不能直接搬来并期待相同结果。Spring依赖注入文档
重复绑定会在注册时抛IllegalArgumentException。首次lookup以后注册表冻结,后续注册会抛IllegalStateException。冻结不是Spring运行时注册能力的实现,而是这个练习主动删减的一项能力:运行中没有替换定义、失效旧singleton和重新连接已有引用的协议。
如果同一个实现类以两个接口分别注册,两个键会分别创建实例。每绑定一个singleton是当前契约;“同一个实现类在全容器只有一个对象”不是契约。实例唯一性必须有明确的计数边界。
递归构造需要一条尚未完成的路径
get(Orders.class)先查询缓存。缓存未命中时读取实现类,取唯一public构造器,逐个解析参数。Repository完成构造并包装后,传入OrderService构造器的是Repository代理。
解析过程中,constructing记录当前递归路径,使用LinkedHashSet保持诊断顺序。查找A时加入A,A需要B时加入B,B再次需要A时发现A已经在路径中,直接拒绝。这里没有字段注入、setter注入,也没有提前暴露半成品。
循环检测集合与singleton缓存不可互换。路径集合只说明“当前构造尚未完成”;缓存只说明“该绑定已经完成并发布”。把尚未初始化的对象提前放进缓存,会同时改变循环依赖、代理身份和失败清理三项契约。
缺失依赖也在递归解析时失败。OrderService需要的Repository没有注册,构造器不会被调用;容器没有因为寻找失败而创建一个默认实现。一个可定位的启动失败通常比默默选出错误依赖更容易验证。
实验连续两次查找循环图,都得到循环诊断。finally会从路径集合移除当前接口,使失败不会留下永久“正在构造”的状态。这只证明当前递归路径得到清理,不证明整个对象图具有事务回滚能力。
失败不发布当前对象,成功依赖仍可能保留
对象构造完成后才创建代理,代理完成后才写入singleton缓存。目标构造器抛出的RuntimeException或Error会被解包并向上传播;构造失败时当前绑定没有缓存条目。
RetryConstructor第一次故意抛IllegalArgumentException,随后关闭故障开关,再次lookup可以成功。这个检查同时验证异常解包、路径finally清理和失败对象未发布。只检查第一次有异常,无法证明容器还能恢复到可诊断状态。
恢复的边界仍需说明。若Repository已成功创建,而OrderService构造失败,Repository会继续保留在缓存中。容器没有为这次lookup建立对象图级撤销列表,也没有销毁依赖的协议。“当前失败对象没有发布”与“本次创建的所有对象都已回滚”是不同承诺。
本实现拒绝AutoCloseable实现,因为它没有close协议。普通对象仍可能在构造器中自行创建线程或资源;类型检查不能证明对象无副作用。代码因此只用于无资源业务对象的单线程教学,不能拿来装配数据库连接池、HTTP服务器或后台执行器。
07和13篇的生命周期实验说明,成熟容器必须处理回调、失败路径、停机阶段以及资源终态。少写这些代码得到的是少一些能力;不能把代码短推导成具备相同生命周期保证。
最终引用是代理,构造注入也得到代理
代理使用JDK的Proxy.newProxyInstance,只实现当前注册接口。对象缓存保存代理,依赖注入返回代理,后续查找也返回代理。接口调用进入InvocationHandler,再进入唯一拦截器,最后反射调用目标方法。JDK21 Proxy API
拦截器不是异步调度器,也不是事务管理器。实验记录before、正常return和finally三个时点,next.run()同步调用目标。目标失败时没有return记录,但finally仍执行;当前实现没有拦截器链,也没有可以重复执行proceed的遍历状态。
Orders.place调用注入的Repository.save时,两个调用都经过各自代理。真实记录顺序为:
1 | |
依赖方向与代理边界在这里共同决定记录顺序。OrderService持有Repository代理,所以依赖调用被拦截;OrderService执行自己的place方法时,当前this仍是目标对象,不会自动成为Orders代理。
Object.equals、hashCode与toString单独处理。equals使用代理引用身份,hashCode使用identityHashCode,toString只表示接口类型;它们不会进入业务拦截器。这个决定避免把代理转交给目标equals时破坏反身性,也避免日志打印触发业务记录。它是本练习的身份契约,并不是所有Spring代理都必须采用的相等性策略。
自调用与异常不因容器变小而消失
OrderService.externalThenInternal调用自己的place。外部入口经过Orders代理,内部place通过目标this执行;日志有before:externalThenInternal,没有before:place。随后Repository.save仍经过注入的Repository代理。
这条检查把两种“内部调用”分开:调用自身方法与调用另一个注入对象。它们在业务代码中都位于同一个方法体,却经过不同引用,因而产生不同拦截结果。14–18篇中的代理调用边界在这个小实现中同样存在。
反射还引入了异常包装问题。Method.invoke把目标异常包装为InvocationTargetException;InvocationHandler需要取出cause。Orders.fail声明IOException,目标实际抛出的IOException在代理边界保持为IOException,finally仍执行。
若直接把InvocationTargetException抛出代理,它不是接口声明的IOException。JDK代理会对未声明的受检异常建立另一层包装,调用者看到的异常类型发生变化。一个只记录“发生了异常”的测试会漏掉这个契约差异;本实验检查实际异常类型。
JDK接口代理也不能供调用者当作具体OrderService使用。调用者依赖接口才与代理公开类型一致;本实现没有CGLIB类代理、默认方法专用调用协议或跨模块访问补偿。访问控制和代理公开类型都属于容器可用性的组成部分。
明确拒绝并发,不能从singleton推导安全
Container记录创建线程,注册、lookup和代理方法调用都检查当前线程。另一个真实线程调用Orders.place时得到IllegalStateException,线程随后终止。单线程限制覆盖返回出去的代理,而不只是容器内部查询。
这个实现没有用ConcurrentHashMap替换HashMap来声称支持并发。并发创建涉及同一绑定只创建一次、构造路径所属线程、缓存发布时点、失败唤醒及依赖环中的等待关系;换一张并发Map不能完成这些协议。
调用拦截器也会写入普通ArrayList,订单仓库本身没有并发约束。即使缓存具有安全发布保证,也不能自动保证对象业务状态与拦截记录安全。容器并发、代理并发和业务对象并发需要各自证明。
本次只验证跨线程调用明确拒绝,没有做吞吐或锁竞争比较。当前实现的能力上限已经由契约和断言给出;不能从21条教学断言推导生产容器可靠性。
运行与改动练习
在00篇下载完整实验工程,从实验根目录运行:
1 | |
脚本调用JDK21 javac与java,保存原始输出、退出码、环境、命令和源码SHA256。本次SUMMARY chapter=E08 checks=21、退出码0。实验没有网络、数据库或Maven依赖;其余章节的Spring执行证据仍在各自目录,不能合并成这份最小容器日志。
第一项练习是移除get方法中缓存命中直接返回的分支。重复lookup会重新创建目标和代理,published proxy singleton identity断言失败;依赖实例计数也会改变。观察对象计数和引用比较,可以区分缓存缺失与拦截器日志问题。
第二项练习是移除Method.invoke附近对InvocationTargetException的解包。Orders.fail的异常类型检查会失败,而finally记录仍可能存在。这说明资源清理时点正确并不足以保证调用者看到的异常契约正确。
第三项练习是在OrderService.externalThenInternal中把自身调用改成调用一个另行注册并注入的Orders接口。需要先拆分实现,避免构造器互相依赖。预期新增接口入口的拦截记录;直接让两个构造器互相注入只会触发本容器拒绝循环的检查。
练习改动未执行,预期来自代码路径与已运行控制组。修改后应重新运行脚本并保存独立日志,保留原控制组;不能把预期输出复制进验收文件。
与Spring对照时需要保留的差异
| 约束 | 最小容器 | Spring6.2.11对照位置 |
|---|---|---|
| 定义与候选 | 单接口显式绑定,重复拒绝 | BeanDefinition、DependencyDescriptor,02与05 |
| 生命周期 | 无回调与关闭协议 | 初始化、销毁、SmartLifecycle,07与13 |
| 循环图 | 构造路径直接拒绝,无早期引用 | 单例创建与早期代理,09 |
| 拦截 | 单接口、单拦截器、自调用绕过 | JDK/CGLIB与Advisor链,14–18 |
| 资源与优化 | 无事务、scope与AOT入口 | 各资源专门协议,19–24与42 |
把最小实现扩展为真实框架并不只是不断增加注解。每加入scope、资源回调或早期引用,都需要重新定义对象身份、失败恢复和关闭责任。当前代码的价值在于让这些缺失能够被指出、被运行控制组隔离,进而解释哪些保证由哪段协议提供。

