深入 Spring 04:配置类解析与 Bean 方法调用
同一个 repository 方法,为何得到不同对象
@Bean OrderService service() { return new OrderService(repository(), new PricePolicy()); } 看起来只是普通 Java 工厂方法。若它位于默认的 @Configuration 中,repository() 可以返回容器管理的仓储;把配置改成 proxyBeanMethods=false,同一个调用在本例中却会执行 new OrderRepository(),得到没有交给容器管理的另一个仓储。
差异不在 OrderRepository 是否加注解,而在配置类实例的来源和方法调用路径。本篇检验扫描、@Import 与 @Bean 如何形成定义,以及 full/lite 两种处理模式怎样改变跨 Bean 方法调用。基线为 Spring Framework 6.2.11、JDK 21,源码固定到 4c134254642d88e058aa004bdaf44168e1be7bb2。
图据 ConfigurationClassParser、ConfigurationClassBeanDefinitionReader 和 ConfigurationClassEnhancer 绘制。上半部分增加定义,下半部分执行工厂方法;配置发现不等于已经执行图中的业务构造器。
从配置入口到定义注册
AnnotationConfigApplicationContext 的基础设施会注册 ConfigurationClassPostProcessor。它同时实现注册表后处理和工厂后处理:前一个窗口解析配置并增加定义,后一个窗口为需要完整配置语义的类准备增强。这两步发生在第 03 篇讨论的普通业务单例预实例化之前。配置类后处理器
配置类不只是带若干工厂方法的类,还可以声明扫描范围和导入关系。解析器读取 @ComponentScan 后执行扫描,获得候选定义;若候选又包含配置相关元数据,继续解析。@Import 则给出显式导入入口,不需要被导入类型处于某个扫描包内。两者可以并存,但诊断“这个 Bean 从哪里进入容器”时,需要分别检查扫描规则和导入链。
固定实现的 processImports 区分普通导入类、ImportSelector 与 ImportBeanDefinitionRegistrar。普通类进入配置处理流程;selector 返回待导入类名,其中 deferred selector 有延迟处理路径;registrar 被记录后参与定义注册。它们并不是三种名字不同的包扫描。实验只使用普通类导入,另外两种是源码边界说明,不宣称执行了其复杂装配场景。扫描和 Import 解析
随后,ConfigurationClassBeanDefinitionReader 把 @Bean 方法转换成 BeanDefinition。实例方法记录所属配置 Bean 名与工厂方法名,静态方法记录工厂类及方法;名称来自 @Bean 的显式名称或默认方法名,多余名称注册为 alias。scope、初始化和销毁信息也在这时进入定义。解析器负责理解声明之间的关系,reader 负责把这些关系转成容器可以执行的注册结果。
| 声明入口 | 选择目标的方式 | 本例的可观察结果 |
|---|---|---|
@ComponentScan |
包范围与过滤器 | Scanned 可以按类型获取 |
@Import(Imported.class) |
显式类型 | Imported 可以按类型获取 |
@Bean repository() |
配置方法 | repository 定义具有工厂方法信息 |
实验特意关闭扫描的默认过滤器,只保留 Scanned 类型,避免扫描实验包时意外把 Full、Lite 等嵌套配置一起发现。若用不受控的广泛扫描,重复名称或多候选可能先让启动失败,就无法隔离研究方法拦截这一变量。
模式提炼:发现、注册、调用分别取证
参数化表达为 发现{候选} → 注册{创建描述} → 调用{实际入口}。
| 场景 | 发现与注册证据 | 调用证据 |
|---|---|---|
| Spring 配置 | 扫描/Import来源与定义 | 工厂调用、实例身份 |
| 插件系统 | 插件清单与注册项 | 实际扩展回调 |
| HTTP 路由 | 路由表 | 请求命中的处理器 |
“扫描到了”只能证明发现或注册结果,不能证明某次普通 Java 调用经过了容器。查询定义与记录调用路径应使用不同观测点。
full 配置拦截的是什么
默认 @Configuration 的 proxyBeanMethods 为 true。ConfigurationClassUtils 用元数据标记 full 候选,工厂后处理阶段再由 ConfigurationClassEnhancer 生成子类,为可覆盖的 @Bean 方法安装拦截逻辑。配置对象来自这个增强类时,跨方法调用才能接入容器。full/lite 判定
拦截器必须区分两种进入方式。如果容器正在调用某个工厂方法来创建它对应的 Bean,拦截器执行父类实现,让方法体真正运行;若这是从其他 Bean 方法或外部代码来的引用请求,拦截器转向 BeanFactory.getBean,按目标 Bean 的生命周期和作用域取对象。缺少这个区别,所有调用都跳回 getBean 就会妨碍真正执行工厂方法,所有调用都执行方法体又无法保持容器对象的共享关系。
本例创建 service 时执行原始 service() 方法体,内部调用 repository()。后者不是当前正在创建 service 的工厂方法,于是被解释成获取仓储 Bean 的请求。默认 singleton 让返回引用与 context.getBean(OrderRepository.class) 相同;并不是 CGLIB 在配置对象上简单维护了一个按方法名缓存的 Map。BeanMethodInterceptor
这类配置增强与后文的 AOP 自调用问题不能套同一结论。配置类的方法体在增强实例上执行,this.repository() 可以通过子类覆盖方法进入配置拦截;常见 Spring AOP 的目标对象自调用则涉及另一条代理与目标路径。只记“用了 CGLIB”不足以判断调用是否拦截,还必须看实际接收对象和方法分派。
lite 仍创建受管 Bean,直接调用恢复普通 Java 语义
@Configuration(proxyBeanMethods=false) 或普通组件里的 @Bean 方法走 lite 处理。这些工厂方法仍然被解析为定义,经定义创建的对象仍受容器管理。关闭配置增强后,跨 Bean 方法调用不再被该机制拦截。
因此,容器执行 lite 的 service() 后,返回的 service 可以是正常的受管 singleton。但方法体中的 repository() 是普通 Java 调用。在本实验里,该方法体每次都写着 new OrderRepository(),所以产生另一个仓储,嵌入 service 后不会自动登记成仓储 Bean。方法上存在 @Bean 并不会让 JVM 中所有直接调用都自动转成容器请求。
“lite 每次必然产生新对象”仍然过强。如果方法体返回一个自行缓存的对象,普通调用就可能得到同一个引用;即使如此,也不能从身份相同反推它经过了容器。可靠结论是:lite 的直接调用不享有 full 的方法拦截,本例的新增对象由方法体中的 new 造成。官方 full/lite 说明
本例用生命周期反例加强身份判断。受管仓储实现 DisposableBean,Context 关闭时被销毁;普通调用产生的另一个仓储没有被登记,关闭 Context 后 closed() 仍为 false。实验随后显式销毁它,保证控制组自身不遗留资源。真实系统若在这类方法内创建连接池或执行器,身份分叉还会带来不同的资源所有权问题;本实验只验证内存仓储的销毁标记,没有用它替代真实线程或连接测量。
用工厂方法参数表达依赖
lite 配置中可以把方法改成 @Bean OrderService injected(OrderRepository repository)。容器准备调用这个工厂方法时,按参数解析依赖,再把得到的仓储传入。方法体不需要回头调用另一个 @Bean 方法,因此不依赖配置增强也能维持“service 使用容器仓储”的关系。
这是一条容器调用约定,不是 Java 参数的全局能力。手工执行 config.injected(null) 不会让 Spring 自动填入仓储;在容器里存在多个同类型候选时,参数解析也可能出现歧义,需要第 05 篇的限定与选择规则。参数写成接口还需要容器知道候选的可预测类型,不能仅靠方法体最终返回什么来消除所有歧义。
可复制实验
1 | |
入口 src/main/java/blog/spring/Chapter04.java 使用累计业务模型,完整代码如下;Checks、业务类和资源清理实现均在同一工程。
1 | |
本地命令在 Amazon Corretto 21.0.11 / Spring 6.2.11 下退出 0,末行是 CHAPTER 04 PASS。原始输出在 evidence/04/local-20261002/run.txt,六条运行断言全部成立。
| 场景 | 实际对象关系或终态 |
|---|---|
| 扫描与普通 Import | 两个类型均可从容器获取 |
| full 跨方法调用 | service 内部仓储与容器仓储为同一引用 |
| lite 跨方法调用 | service 内部仓储与容器仓储不是同一引用 |
| lite 工厂方法参数 | injected 内部仓储与容器仓储为同一引用 |
| 关闭 lite Context | 直接调用产生的仓储尚未销毁,随后由实验显式释放 |
这里同时验证“谁创建了哪个引用”和“关闭时谁负责销毁”。身份关系解释装配差异,销毁标记解释所有权差异;两者共同限制结论,避免从一个类名或一次正常业务返回推断容器管理已经生效。
上游 ConfigurationClassPostProcessorTests 的 full 测试检查 bar.foo 与容器的 foo 是同一个引用,还检查依赖关系登记;proxyBeanMethods=false 对照检查引用不同。它们提供与本地实验独立的实现意图证据。本篇未运行上游 Gradle 套件,本地 PASS 只覆盖上述命令执行的场景。对应上游测试
模式提炼:用显式参数传递受管依赖
参数化表达为 容器解析{依赖} → factory(依赖) → 产品。
| 场景 | 参数承载什么 | 避免的隐藏前提 |
|---|---|---|
| lite 配置工厂 | 受管仓储 | 跨方法调用必须被增强 |
| 普通测试工厂 | 可控测试替身 | 全局变量或隐式查找 |
| 组合式业务装配 | 已构造的协作者 | 工厂内部自行创建第二套依赖 |
参数方式把对象图放在调用协议里,但不自动解决候选歧义、scope 或生命周期问题。它减少的是对方法拦截的依赖,而非取消容器的依赖解析规则。
full 的边界也需要检查
手工 new Full() 得到的是普通配置实例,不因为类上标注 @Configuration 就自动成为增强对象。静态 @Bean 方法不能被子类覆盖,直接调用不享有同样的拦截。full 配置中,需要增强的方法必须可覆盖,不能随意改为 final/private。
即使调用进入容器,也不能一律断言返回同一个对象:若目标定义是 prototype,容器按它的 scope 创建;full 的意义是遵守容器语义,默认 singleton 只是本例条件。选择 lite 前应检查现有配置是否通过直接方法调用表达依赖,把需要的关系改成参数注入,再用身份和销毁断言验证。单纯搜索是否存在 @Bean 不能完成这项判断。
练习与解答
练习一。 Lite 配置的 service 连续两次通过 getBean("service") 得到同一个对象,是否证明其内部仓储也是容器中的那个仓储?
解答:不能。service 自身可以被 singleton 缓存,内部却保存首次执行工厂方法时直接创建的未受管仓储。分别比较 service 的身份和 service.repository() == context.getBean(OrderRepository.class),这两条断言检验不同关系。
练习二。 把仓储工厂改成 @Scope("prototype"),full 配置是否还应保证所有 service 都共享一个仓储?
解答:不应。full 将调用交给容器,prototype 仍按获取请求创建实例;已创建的 singleton service 保留当时注入的引用,不会因为仓储声明 prototype 就在每次业务调用时自动换对象。重复获取与动态查找要另行设计,后续 scope 篇再验证。
模式速查
| 观察 | 对应模式 | 检查方向 |
|---|---|---|
| 类已扫描,某次调用仍绕过容器 | 发现、注册、调用分别取证 | 实际配置引用与调用方式 |
| lite service 内部出现第二个仓储 | 普通调用遵循方法体 | 是否直接调用含 new 的工厂方法 |
| 需要关闭配置增强 | 显式参数传递依赖 | 消除跨 Bean 方法直接调用后验身份 |
| Context 关闭却有对象未销毁 | 创建与所有权关联 | 对象是否真正登记为受管 Bean |
参考资料
实验工程与环境准备见00:容器实验入口。继续阅读05:依赖解析如何筛选候选对象。
- Configuration 注解契约:
proxyBeanMethods与可覆盖性约束。 - Bean 注解契约:工厂方法、参数与生命周期。
- ConfigurationClassBeanDefinitionReader:方法到定义的转换。

