Skip to content

Spring Boot 自动配置:一组带条件的候选配置 ​

自动配置不是「自动扫描所有东西」。框架准备了一批候选配置类,启动时逐个判断条件:classpath 上有没有某个类、用户有没有自己声明某个 Bean、某个属性是否打开。条件都满足的才生效,用户自己的配置通常优先。

写一个公司内部的 starter:引入依赖就有一个默认的支付客户端,用户可以自己声明一个覆盖它。本地测试一切正常,某天有人加了一个兜底的「空实现」自动配置,结果容器里同时出现了两个客户端,注入时报 NoUniqueBeanDefinitionException。兜底配置的条件写的是「没有这个 Bean 时才创建」,它判断的时候,真正的客户端还没有被注册。

本文用 Spring Boot 4.1.1 验证自动配置的几个关键行为:四种常用条件、条件报告、自动配置之间的顺序,以及自动配置为什么不依赖组件扫描。

一、先说结论 ​

  • 自动配置是候选集合加条件筛选:候选来自 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,不依赖应用的组件扫描路径。
  • 用户的 Bean 优先:@ConditionalOnMissingBean 让默认实现在用户声明了同类 Bean 时退让,实测容器里只有用户的那一个。
  • 条件报告能直接回答「为什么没生效」:属性不对、类不在 classpath 上,报告里都写着具体原因。
  • 自动配置之间要声明顺序:兜底配置没有声明 after,实测容器里出现 2 个同类 Bean;声明之后只剩 1 个。
  • 组件扫描和自动配置是两套机制:自动配置类放在扫描路径之外也会被加载;不在 imports 文件里、也不在扫描路径里的配置类不会生效。

二、启动时发生了什么 ​

不必背调用栈,按层次理解即可:

  1. 准备 Environment:加载配置文件、环境变量、命令行参数和 profile;
  2. 创建应用上下文,注册主类;
  3. 解析配置类:先处理组件扫描找到的用户配置,再从 imports 文件读出自动配置候选;
  4. 对每个候选评估条件,满足条件的注册为 Bean 定义;
  5. 实例化非懒加载的单例,生命周期见 Spring Bean 生命周期;
  6. 启动内嵌服务器(Web 应用)、执行 runner,发布就绪事件。

第 3 步的顺序很关键:用户配置先于自动配置处理,所以自动配置里的 @ConditionalOnMissingBean 能看到用户声明的 Bean。

组件扫描主类所在的包imports 文件AutoConfiguration.imports用户配置先处理自动配置候选后处理评估条件OnClass · OnPropertyOnMissingBean · 应用类型用户的 Bean 已注册条件报告写明每个候选为什么生效或没有生效
图 1 · 用户配置先由组件扫描处理,自动配置候选随后从 imports 文件读出,逐个评估条件;用户已经声明的 Bean 能让 @ConditionalOnMissingBean 的默认实现退让

三、四种常用条件 ​

实验里的支付自动配置:

java
@AutoConfiguration
@ConditionalOnClass(PaymentSdk.class)
@ConditionalOnProperty(prefix = "payment", name = "enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(PaymentProperties.class)
public class PaymentAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    PaymentClient paymentClient(PaymentProperties properties) {
        return () -> "默认 HTTP 客户端 → " + properties.endpoint() + "(" + PaymentSdk.version() + ")";
    }
}

用 ApplicationContextRunner 分别检查:

情况结果条件报告
条件都满足默认客户端,读到了 payment.endpoint—
用户声明了 PaymentClient容器里 1 个,是用户的—
payment.enabled=false没有 PaymentClient@ConditionalOnProperty (payment.enabled=true) found different value in property 'enabled'
classpath 上没有 PaymentSdk没有 PaymentClient@ConditionalOnClass did not find required class 'labs.payment.sdk.PaymentSdk'

几点说明:

  • @ConditionalOnClass 放在类上,判断发生在加载配置类的方法之前,所以配置类里可以放心引用这个可能不存在的类;
  • @ConditionalOnMissingBean 默认按方法返回类型判断,只适合用在自动配置里,在普通配置类里它的结果取决于配置类的处理顺序;
  • matchIfMissing = true 表示「没配置就当打开」,关闭需要显式写 false。

ApplicationContextRunner 是测试自动配置的标准方式:它不启动完整应用,可以方便地加用户配置、改属性、过滤 classpath,适合写进 starter 自己的测试。

四、为什么某个自动配置没生效 ​

排查顺序:

  1. 依赖是否真的在运行时 classpath 上(provided、optional 的依赖不会传递,见 Maven Scope);
  2. 属性名和取值是否正确,是否被某个 profile 覆盖;
  3. 是否已经有同类 Bean 让它退让,可能来自另一个 starter;
  4. 应用类型是否匹配(Servlet、Reactive、非 Web);
  5. 读条件报告:启动参数加 --debug 会打印 CONDITIONS EVALUATION REPORT,引入 Actuator 可以访问 conditions 端点。报告分 Positive matches(生效了,为什么)和 Negative matches(没生效,卡在哪个条件)。

条件报告比异常栈更直接。上面表格里的两条原因,就是从 ConditionEvaluationReport 里读出来的原文。

五、自动配置之间的顺序 ​

有一个自动配置提供真正的指标输出 MeterSink,另一个自动配置想做兜底:没有指标输出时,提供一个空实现。

java
@AutoConfiguration                          // 没有声明顺序
public class FallbackSinkAutoConfiguration {
    @Bean
    @ConditionalOnMissingBean(MeterSink.class)
    MeterSink noopSink() { return () -> "noop"; }
}
text
兜底配置没有声明 after:MeterSink 2 个 [noop, prometheus]
兜底配置声明 after = MetricsAutoConfiguration:MeterSink 1 个 [prometheus]

@ConditionalOnMissingBean 在处理这个配置类的那一刻判断。兜底配置排在前面处理时,真正的 MeterSink 还没有注册,条件成立;之后真正的配置再注册一个,容器里就有了两个。修复方法是声明顺序:

java
@AutoConfiguration(after = MetricsAutoConfiguration.class)

实验里为了稳定复现,把兜底配置的类名起成字母序更靠前的名字。真实项目中,没有声明顺序时谁先处理取决于排序规则,可能恰好正确,换个类名、加一个依赖就出问题。凡是自动配置里用 @ConditionalOnMissingBean 判断另一个自动配置的产物,都要显式声明 after 或 before。

没有声明 afterafter = MetricsAutoConfiguration兜底配置此时没有 MeterSinkMetrics 配置注册 prometheusMeterSink 2 个noop、prometheusMetrics 配置注册 prometheus兜底配置已有,退让MeterSink 1 个prometheus
图 2 · @ConditionalOnMissingBean 在处理这个配置类的那一刻判断:兜底配置先处理时还没有 MeterSink,于是也注册了一个;声明 after 之后,它能看到真正的 MeterSink 并退让

六、自动配置与组件扫描是两套机制 ​

@SpringBootApplication 组合了三件事:@SpringBootConfiguration、@ComponentScan(从主类所在的包开始扫描)和 @EnableAutoConfiguration(从 imports 文件读取候选)。

实验里主类在 labs.app,支付自动配置在 labs.payment.autoconfigure,不在扫描范围里:

text
PaymentClient 存在=true;不在 imports 文件里的 labs.payment.internal.InternalConfig 生效=false

自动配置经 imports 文件加载,与扫描路径无关;一个既不在扫描路径、也不在 imports 文件里的配置类不会生效。反过来也成立:不要让自动配置类落在应用的扫描路径里,否则它会被当作普通配置类处理,失去自动配置的排序和条件语义。写 starter 时,自动配置放在独立的包里,并且只通过 imports 文件注册。

旧版本用 META-INF/spring.factories 里的 EnableAutoConfiguration 键注册自动配置;Spring Boot 2.7 引入 imports 文件,3.0 起不再从 spring.factories 读取自动配置。升级时这个位置没改,自动配置就会静默消失。

七、写一个 starter 的检查清单 ​

  • 自动配置类用 @AutoConfiguration,注册在 imports 文件里,不在任何应用的扫描路径下;
  • 可选依赖用 @ConditionalOnClass 保护,默认 Bean 用 @ConditionalOnMissingBean 让位;
  • 和其他自动配置有依赖时,用 after/before 声明顺序;
  • 属性用 @ConfigurationProperties 绑定,写明默认值与开关;
  • 用 ApplicationContextRunner 测试每一种条件组合,包括「依赖不在 classpath 上」。

站内的 Domain Driven Kit 的各个 starter 就是按这套方式组织的。

八、常见误区 ​

  • 「自动配置就是自动扫描」:它读取的是 imports 文件里的候选,和组件扫描是两套机制。
  • 「写了 @ConditionalOnMissingBean 就不会重复」:它只看判断那一刻已经注册的 Bean,自动配置之间要声明顺序。
  • 「没生效就去翻源码」:先看条件报告,原因通常写在里面。
  • 「主类放错包,所有自动配置都会失效」:影响的是组件扫描,自动配置照常加载。
  • 「在普通配置类里用 @ConditionalOnMissingBean 也可靠」:普通配置类之间没有这套排序,结果取决于处理顺序。

小结 ​

自动配置的本质是一组带条件的候选:候选从 imports 文件来,条件决定去留,用户配置先于自动配置处理,所以用户的 Bean 可以让默认实现退让。遇到「没生效」看条件报告,遇到「重复了」检查自动配置之间的顺序。循环依赖在 Spring Boot 里为什么默认会启动失败,见 Spring 循环依赖。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权