08-SpringBean与自动配置
Spring Boot 为什么能自动装配?Bean 生命周期与条件配置
加入 starter 后接口就能调用,自己定义一个 Bean 后默认实现又消失了。这不是依赖下载时发生了魔法,而是容器创建、配置发现和条件判断共同决定最终对象。理解这些机制,也能解释升级后为何选中了不同实现。
涉及 Spring 的示例沿用 Java 17、Spring Boot 3.5.3;不同示例项目独立阅读,文件名与依赖在相应代码段说明。
1. 从普通对象走到容器对象
创建、注入、初始化与代理形成一个有顺序的生命周期。
Bean 与普通对象有什么区别?
Bean 是交由 Spring 容器管理的对象。容器根据 BeanDefinition 记录的类型、工厂方法、作用域和依赖信息创建它,再执行相应扩展点与回调。
自己 new 一个带有 @PostConstruct 的类,Java 不会自动调用该方法。注解只是元数据,需要容器中的处理器识别并执行。
生命周期也不等于所有对象都在启动时创建。懒加载 Bean 可以在首次获取时创建;prototype Bean 可以按请求创建多个实例。本篇使用 Java 17 与 Spring Boot 3.5.3 管理的 Spring Framework 6.2.8。
一条常见的创建路径
flowchart TD
A[读取 Bean 定义] --> B[解析构造参数并实例化]
B --> C[设置属性与注入依赖]
C --> D[相关 Aware 回调]
D --> E[初始化前的 BeanPostProcessor 链]
E --> F[InitializingBean / 自定义初始化方法]
F --> G[初始化后的 BeanPostProcessor 链]
G --> H[可供调用的 Bean,可能是代理]
H --> I[容器关闭时执行适用的销毁回调]
@PostConstruct 通常由初始化前的处理器执行,因此它位于处理器链内部,不能把它想象成所有 before 回调结束后独立出现的一步。
这张图描述常见路径。某些处理器可以提前返回代理,循环依赖还涉及早期引用;不同 Aware 接口的回调也由不同设施执行。分析具体顺序时,应结合所使用的扩展点,而不是把简图当作所有 Bean 的唯一内部流程。容器扩展点
构造器注入与属性注入发生在哪里?
构造器注入需要先取得依赖,才能执行相应构造器。因此,“先创建对象,再注入依赖”主要适用于属性或 setter 注入,不能覆盖全部注入形式。
setter 注入对象在构造期间尚未收到对应依赖,不应在无参构造器里使用它。构造器注入更容易表达必要依赖,并支持把引用设置为 final。
无论使用哪种方式,依赖能够取得也不代表所有 Bean 的业务启动活动都已完成。例如数据库连接池对象已创建,不等于外部服务已经可用。依赖注入
初始化与销毁回调如何排列?
为不同方法同时配置三种初始化机制时,顺序是:@PostConstruct、InitializingBean.afterPropertiesSet()、自定义 initMethod。对应的销毁机制是 @PreDestroy、DisposableBean.destroy()、自定义 destroyMethod。
如果多个机制指定的是同一个方法,还需要考虑去重规则。日常业务通常选择一种合适的机制即可,没有必要为了完整性重复初始化同一份资源。
普通初始化回调作用于目标对象,不应依赖其方法已经具有完整的 AOP 代理语义。尤其不能在 @PostConstruct 中自调用一个事务方法,就假定进入了事务代理。Bean 回调说明
2. 自动配置到底自动了什么
starter 负责依赖组合,配置发现和条件判断决定是否装配。
starter 与自动配置不是同一件事
starter 通常是一组方便引入的依赖;自动配置是带有条件和 Bean 定义的配置类。依赖进入 classpath 后,可能满足某些条件,但仅有一个 jar 并不保证全部配置都会生效。
应用通常通过 @SpringBootApplication 启用自动配置。它同时组合了 Boot 配置、自动配置和组件扫描;如果只想展示自动配置,可以使用 @SpringBootConfiguration 与 @EnableAutoConfiguration,不必扫描应用包。
本文固定使用 Java 17、Spring Boot 3.5.3 和它管理的 Spring Framework 6.2.8。Boot 自动配置说明
候选配置从哪里来?
Boot 3 的自动配置候选可以登记在以下 classpath 资源中:
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件按行记录配置类名。Boot 加载候选、处理排除与排序,再根据条件决定哪些配置和 Bean 定义参与应用。
候选被发现不等于一定创建实例,也不等于条件已经满足。不能把 imports 文件理解为一张“全部无条件启用”的名单。自定义自动配置
常见条件分别回答什么问题?
| 条件 | 判断内容 |
|---|---|
| ConditionalOnClass | 所需类型是否在 classpath 中 |
| ConditionalOnBean | 已知 Bean 定义是否满足要求 |
| ConditionalOnMissingBean | 是否尚无指定类型或名称的 Bean |
| ConditionalOnProperty | 属性值是否满足要求 |
| ConditionalOnWebApplication | 是否处于对应 Web 应用环境 |
有某个类,不代表有该类型的 Bean。MissingBean 也不是运行期间不断监听容器再自动替换对象,而是在配置处理的相应阶段作条件判断。
Bean 条件受已经处理的定义影响。自动配置通常在应用用户配置之后处理,方便识别应用提供的实现;手工导入普通条件配置时,不能忽略处理顺序。
覆盖默认配置为什么通常不是同名覆盖?
应用定义一个接口类型的 Bean,默认工厂方法上的 MissingBean 条件不再满足,默认实现就不注册。这种“退让”并不要求应用与框架 Bean 使用相同名称。
同名 BeanDefinition 覆盖是另一种机制,还涉及容器是否允许覆盖。不能为了让默认组件退让,就习惯性开启 allow-bean-definition-overriding。
同样,自动配置的 before/after 表达配置处理顺序,不直接承诺所有 Bean 的实例创建顺序。创建时机仍由依赖关系、作用域与懒加载等决定。
3. 解释为什么某个 Bean 出现或消失
用条件报告与有效配置观察结果,而不是猜测。
如何查看条件报告与排除配置?
加上 --debug 可以查看条件评估报告。找到目标配置,检查类是否存在、属性是否满足、哪个 Bean 让 MissingBean 条件失败,再决定修改哪一层。
代码中的 default.matched 是针对默认工厂方法的简化观察,不是完整诊断报告。配置类条件未满足时,方法可能根本没有单独评估,所以 false 也可能表示没有方法条目。
验证时还遇到过一个具体的依赖边界:如果只引入 jackson-databind,ObjectMapper 类虽然可见,但本例所用的 Jackson 自动配置还缺少 Jackson2ObjectMapperBuilder,因而没有提供 ObjectMapper Bean,Greeting 工厂的 Bean 条件也不满足。正文配置使用 starter-json,补齐相应依赖。这个例子说明“类条件满足”和“Bean 条件满足”必须分开核对。
还可以显式排除示例配置:
mvn spring-boot:run '-Dspring-boot.run.arguments=--spring.autoconfigure.exclude=demo.autoconfigure.GreetingAutoConfiguration'
本地运行得到 absent 和空 Bean 列表。排除某个自动配置不等于卸载依赖,也不会自动删除其他配置提供的同类型 Bean。
自定义自动配置要注意哪些边界?
工厂方法应声明足够明确的返回类型,方便 Bean 条件识别。配置处理顺序也应表达真实依赖,本例在 Jackson 自动配置之后判断 ObjectMapper Bean。
自动配置还应避免被普通扫描、直接 Import 或主配置的嵌套配置处理路径提前发现。仅列入 imports 文件,不能让已经按用户配置处理的同一类重新获得预期排序。本例把默认配置独立为一个文件,并关闭组件扫描,保留自动配置候选的发现路径。
可选依赖的类型条件需要合理放在配置边界,避免缺少类时先解析了依赖这些类的方法签名。不要把 ConditionalOnClass 当作放在哪里都能防止类加载错误的万能保护。
实际 starter 通常需要验证默认装配、用户覆盖、功能禁用、缺少可选依赖以及非法配置等场景。ApplicationContextRunner 适合为这些条件建立小型上下文测试,而不是每次都启动完整业务应用。
本文实测了前缀、用户覆盖、禁用、组合开关、排除和非法配置;缺少 Jackson 的路径应在专门过滤 classpath 的测试中验证,不把未运行场景算作实验结果。
初始化方法是不是启动任务的最佳位置?
初始化方法适合检查当前 Bean 的配置、建立小型内部结构。耗时网络调用、访问复杂 Bean 链路或等待另一个组件完成,可能拉长启动时间并引入初始化依赖问题。
SmartInitializingSingleton 表达常规 singleton 初始化完成后的回调;SmartLifecycle 适合需要 start/stop 协调的组件;Boot 的 Runner 与应用事件提供其他启动阶段入口。选择时应明确需要等待哪个阶段,而不是看到一个“启动时执行”的注解就使用它。
即使进入应用就绪阶段,外部依赖之后仍然可能失败。资源连接与故障恢复需要自己的超时、重试和运行状态设计。
销毁回调是否一定执行?
显式关闭上下文或正常 JVM 退出中的关闭钩子,可以让适用的销毁回调执行。操作系统强制终止、进程崩溃等情况下不能保证。
后台组件关闭时应停止接收新任务、协调已提交工作,并释放自己拥有的资源。不要依靠 PreDestroy 完成唯一一次关键业务持久化,否则非正常退出会留下风险。
prototype 注入 singleton 也不意味着每次调用都得到新实例:如果只在 singleton 创建时注入一次,之后使用的是同一个已取得对象。需要每次新建时,可以明确使用 ObjectProvider 等按需获取机制。
4. 版本与类路径也参与最终行为
BOM、依赖调解和运行时类来源解释另一类装配异常。
Maven 怎样选择传递依赖?
一个项目依赖 A,A 又依赖某个日志库;同时项目依赖 B,B 使用另一个版本。Maven 通过依赖调解选择版本,通常采用距离项目更近的定义;深度相同时,声明顺序也会影响选择。依赖机制。
这不代表自动选择最新版本,也不代表选中的版本一定兼容两个上游。版本选择解决“放哪一个”,兼容性仍需要开发者判断。
同一个类还可能存在于不同 artifact 中,此时并不是同坐标的版本冲突。依赖树没有显示冲突,也可能出现重复类。
先查看树,再查看有效配置
mvn dependency:tree
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind
mvn help:effective-pom
先找到目标依赖的完整路径,再检查最终版本来自直接声明、parent、BOM 还是 dependencyManagement。不同插件版本的输出和参数可能不同,团队可以固定插件版本以保持可复现性。
有效 POM 可以展开继承后的配置,但输出可能包含内部仓库或环境配置,分享前检查内容。依赖树的某个版本被 omitted,不说明它完全没有影响上游兼容要求。
同时记录 Maven 与 JDK 版本、激活 profile 和构建参数。IDE 与 CI 启用不同 profile,可能生成不同产物。
dependencyManagement、BOM 与 exclusions
dependencyManagement 管理版本与相关默认信息,本身不会把所有依赖加入项目。BOM 通过 import 方式提供一组协调的版本,也不意味着它涵盖全部第三方组件。
下面是一个依赖管理片段,版本由项目确认后的属性提供:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>internal-client</artifactId>
<version>${internal-client.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
这是组织结构示例,com.example 不是可直接下载的公共库。实际项目用真实坐标与已验证版本。
排除依赖要有明确目的,例如移除不需要的日志实现。若上游代码仍然需要被排除的类,应用可能在运行时失败。不要把 exclusions 当成“看见冲突就删除一条路径”的通用办法。
编译成功为什么仍会运行失败?
NoSuchMethodError 表示运行时找到类,但所调用的方法签名不匹配。ClassNotFoundException、NoClassDefFoundError 则需要进一步区分类缺失与初始化失败。
检查实际 JAR 内容、启动类路径和容器中的库。Boot 可执行 JAR 的依赖通常位于 BOOT-INF/lib;普通 WAR 可能还有应用服务器提供的依赖。
scope 同样影响运行。provided 表示运行环境提供,不能保证独立运行时存在;test 依赖不应成为正式业务运行的必要条件。
遇到怀疑重复类的问题,可以检查类实际来源和加载日志,再回到依赖路径定位。仅在 IDE 重新导入 Maven,不一定改变已经发布的旧镜像。
修复时优先保持版本组合一致
优先采用框架兼容的 BOM 和 starter 组合。如果升级某个核心模块,需要确认相关模块是否也要协调升级,避免同一框架混用不同大版本。
修复后重新构建并检查产物,验证触发异常的实际路径。仅测试应用启动,不代表所有延迟加载的类都已使用。
团队可以使用 Maven Enforcer 等规则提前发现某些依赖问题,但严格收敛规则也可能需要例外。规则应该服务兼容性,不把“所有版本一样”当成业务正确的证明。
记录冲突来源、最终版本、选择原因和验证方式,便于下一次升级。本文没有构建项目,示例命令与修复步骤是检查方法。
5. 设计判断与适用边界
自定义配置尽量显式表达依赖与条件,让用户提供的 Bean 有清楚的退让路径。不要用同名覆盖代替契约设计,也不要在构造过程执行长时间网络请求,让容器启动与外部波动紧耦合。
升级时记录最终依赖组合和条件报告。编译成功只说明某条编译类路径成立;正式运行仍可能加载不同版本。选用框架 BOM 后再核对不受管理的组件,避免只强制提升一个版本破坏整体兼容。
6. 问答
问:构造方法执行完,依赖一定已经注入吗?
构造参数已经提供,但属性与 setter 注入可能尚未发生,需要区分注入方式。
问:PostConstruct 是否代表整个应用已经启动完成?
不是。它是当前 Bean 的初始化回调。
问:加了 starter 就一定创建所有相关 Bean 吗?
不会。依赖只可能满足部分条件,还要看应用类型、属性和已有 Bean。
问:MissingBean 判断的是 Bean 名称吗?
可以按类型或名称等条件判断。本例按 Greeting 类型判断,不要求与默认 Bean 同名。
问:Maven 总是选最高版本吗?
答:不是,依赖调解与管理配置决定结果。
问:导入 BOM 就加入了所有依赖吗?
答:没有,它主要管理版本,项目仍声明需要的依赖。