08-SpringBean与自动配置

O泡李华 5

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 就加入了所有依赖吗?

答:没有,它主要管理版本,项目仍声明需要的依赖。

参考资料