同一个 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。

配置发现与Bean方法调用两阶段,以及full和lite的分支

图据 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
2
cd examples/spring-framework-lab
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter04

入口 src/main/java/blog/spring/Chapter04.java 使用累计业务模型,完整代码如下;Checks、业务类和资源清理实现均在同一工程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package blog.spring;

import org.springframework.context.annotation.*;
import org.springframework.stereotype.Component;
import static blog.spring.Checks.*;

public class Chapter04 {
@Component public static class Scanned { }
public static class Imported { }
@Configuration
@Import(Imported.class)
@ComponentScan(basePackageClasses=Chapter04.class, useDefaultFilters=false,
includeFilters=@ComponentScan.Filter(type=FilterType.ASSIGNABLE_TYPE, classes=Scanned.class))
public static class Discovery { }
@Configuration public static class Full {
@Bean OrderRepository repository() { return new OrderRepository(); }
@Bean OrderService service() { return new OrderService(repository(), new PricePolicy()); }
}
@Configuration(proxyBeanMethods=false) public static class Lite {
@Bean OrderRepository repository() { return new OrderRepository(); }
@Bean OrderService service() { return new OrderService(repository(), new PricePolicy()); }
@Bean OrderService injected(OrderRepository repository) { return new OrderService(repository, new PricePolicy()); }
}
public static void main(String[] args) {
try (var context = new AnnotationConfigApplicationContext(Discovery.class)) {
check(context.getBean(Scanned.class) != null, "04 component scan registers component");
check(context.getBean(Imported.class) != null, "04 Import registers explicit type");
}
try (var context = new AnnotationConfigApplicationContext(Full.class)) {
check(context.getBean(OrderService.class).repository() == context.getBean(OrderRepository.class), "04 full configuration intercepts direct Bean call");
}
OrderRepository unmanaged;
try (var context = new AnnotationConfigApplicationContext(Lite.class)) {
unmanaged = context.getBean("service", OrderService.class).repository();
check(unmanaged != context.getBean(OrderRepository.class), "04 lite direct Bean call creates unmanaged repository");
check(context.getBean("injected", OrderService.class).repository() == context.getBean(OrderRepository.class), "04 lite method parameter restores managed injection");
}
check(!unmanaged.closed(), "04 context cannot destroy repository made by ordinary direct call");
unmanaged.destroy();
System.out.println("CHAPTER 04 PASS");
}
}

本地命令在 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:依赖解析如何筛选候选对象。