条件装配全解:@Conditional 十连问

bee2026-10-0861 分钟0 次阅读
自动配置背后真正的大脑是条件注解。从 @Conditional 元注解讲到 OnClass/OnMissingBean/OnProperty 全家族,配成套的求值顺序与调试技巧。
1 / 137
小节
〇、30 秒看懂
2 / 137

先说人话。条件装配就是「这个对象到底要不要造出来,让容器在启动时替你判断」。你不用写 if (环境是生产) { ... } 这种散在各处的分支,只要往配置类或 @Bean 方法上贴一个注解,容器就会在造它之前先问几句:这个类在不在依赖里?那个 Bean 有没有?开关属性开了没?用户自己配过没?四问全过,才把它注册进容器;任何一问不过,就当它不存在。关键在于:条件不满足时不会有任何报错——对象就是安静地没了。这也是本篇最难的地方:不是语法,而是「怎么知道它为什么没生效」。

3 / 137

要用这套机制,你必须先分清三个词。条件注解(@ConditionalOnXxx)是贴在类或方法上的那张「准入贴纸」;条件类(实现 Condition 接口的那个类)是真正做判断的人,它只有一个方法 matches,返回 true 就放行;求值阶段是这个判断发生在哪一刻——解析配置类时,还是注册某个 Bean 时。三者关系一句话:贴纸负责声明「用哪条规矩」,条件类负责执行这条规矩,阶段决定它什么时候有资格开口。

4 / 137
类比

精装房的水电家具都是提前配好的,但你一旦把自己的沙发摆进客厅,装修方就不会再往那儿放它的沙发。条件装配就是这套「先看你自己有没有安排,没有我才补」的规矩:@ConditionalOnMissingBean——客人没带伞,店里才借一把;你自己带了伞,店员看一眼就把伞架收回去。反过来,@ConditionalOnClass 像餐厅的「这道菜要用的食材今天到货了才上」,@ConditionalOnProperty 像「这盏灯要靠墙那个开关打开才亮」。记住这四句话,下面所有注解都只是它们的变体。

5 / 137
架构图
图 · 条件求值的四问:本篇地图
图 · 条件求值的四问:本篇地图
6 / 137

上面这张图给的是全景,但「顺序」这件事光看不够——四问里第 ③ 和第 ④ 长得很像,记反的人一大半。把它摊成能点的路径,一格一格走:

7 / 137
交互图解
流程条件装配的四问:点着看每一问由谁回答1 / 5
从 ① 点到 ⑤,重点在第 ③ 与第 ④ 问——一个问「开关开没开」,一个问「用户配没配」,这两个最容易被记反
→
→
→
→
① 类在吗
`OnClassCondition` 只问 ClassLoader:这个类在不在 classpath 上。它发生在配置类被解析之前(PARSE 阶段),一旦答「不在」,整个类连解析都省了——所以这一问最便宜,也排得最靠前。
全部看懂了顺序不能换:越靠前的判断越便宜,也越不依赖别人的进度。
8 / 137

学完这一篇,你要能回答三个问题:

9 / 137
  1. 同样是「看容器里有没有某个 Bean」,为什么写在自动配置类上稳如泰山、写在你自己的 @Configuration 上就可能失效?
  2. @ConditionalOnProperty("my.feature") 只写名字不写值时,属性「不写」和写 false,结果一样吗?
  3. 一个贴着条件注解的 Bean 莫名其妙消失了,我该敲哪个参数让 Boot 亲口告诉我原因?
10 / 137
小节
一、为什么需要条件装配:三个真实场景
11 / 137

在讲语法之前,先回答一个更根本的问题:为什么不能直接写死配置,非要多出一套「条件」机制? 因为同一份代码,往往要在不同的环境、不同的依赖组合下表现出不同的行为。下面三个场景,几乎每个后端都遇到过:

12 / 137
  • 场景一:开发用 H2,生产用 MySQL。 团队想在本地跑测试时用内存数据库 H2,部署到生产时换成 MySQL。如果配置文件写死一个数据源,就要维护两套代码;理想状态是「同一份代码,检测到 classpath 里有 H2 就用 H2」
  • 场景二:可选依赖要能优雅降级。 某个模块依赖 Redis 做缓存,但有些边缘服务不部署 Redis。这时应该「有 Redis 就用 Redis,没有就退回本地 ConcurrentHashMap」,而不是启动直接报错
  • 场景三:写 starter 时给默认实现,同时允许用户替换。 你的 starter 要提供一个默认的 HttpClient,但如果使用方自己定义了同类型的 Bean,就应该让用户的实现生效
13 / 137

这三个场景的共同点是:「要不要装配,取决于运行时的环境与依赖,而不是写在代码里的常量」。条件装配就是 Spring 给出的标准答案——它把「如果……就……」这套判断,从 if 语句搬到了注解上,让容器在启动时替你做决策。

14 / 137

而「运行时的依赖」这件事,第一处真相不在注解里,在 pom 里。自己勾一遍就明白了:把 H2 那一行取消勾选,DataSourceAutoConfiguration 里靠 @ConditionalOnClass 守着的那批 Bean 会整批消失,而启动日志一个字都不会变。

15 / 137
生成器
生成器「要不要装配」其实在 pom 就定了pom.xml3 / 7
先按默认勾选跑一遍,再把 H2 或 Redis 的勾去掉重新生成:pom 少一行,classpath 少一个类,@ConditionalOnClass 就少一个「是」——第七节的 --debug 报告里会多出整整一段 Negative matches,第十一节的 cond 实验可以把这个过程按给你看
产物
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>demo-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
JDBC只要模板类和 HikariCP,不想被 ORM 绑住时的最小选择。
H2 内存库runtime scope,让本地启动和测试不用真库;生产 profile 记得排除。
16 / 137
小节
二、@Conditional 元注解机制:一切条件的根
17 / 137

所有条件注解,最后都归结到一个元注解:@Conditional。它本身不带任何判断逻辑,只负责绑定一个条件类:

18 / 137
java
@Target({ElementType.TYPE, ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface Conditional {    Class<? extends Condition>[] value();}
19 / 137

真正做判断的是 Condition 接口——注意它只有一个方法,返回一个布尔值:

20 / 137
代码对照
代码java
@FunctionalInterfacepublic interface Condition {    boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata);}
解读
  • context:ConditionContext,是条件类在运行时能摸到的「整个世界」。它能拿到 BeanFactory(查已有 Bean)、Environment(查配置项和 profile)、ResourceLoader(查资源)、ClassLoader(查类是否存在),以及 BeanDefinitionRegistry(查 BeanDefinition)
  • metadata:AnnotatedTypeMetadata,让你读到「被标注的类/方法上那个注解的属性值」。比如 @ConditionalOnProperty(name = "x", havingValue = "y") 里的 name 和 havingValue 就是从这里取的
21 / 137

普通的 Condition 只管「返回 true 还是 false」,但 Spring 内部还有一层更精细的设计——ConfigurationCondition,它把求值拆成了两个阶段:

22 / 137
java
public interface ConfigurationCondition extends Condition {    ConfigurationPhase getConfigurationPhase();    enum ConfigurationPhase {        PARSE_CONFIGURATION,   // 解析配置类时求值:决定这个类要不要被解析        REGISTER_BEAN          // 注册 Bean 时求值:决定这个 @Bean 要不要注册    }}
23 / 137
对照表
阶段求值时机决定什么典型注解
PARSE_CONFIGURATION解析配置类时整个 @Configuration 类要不要被解析@ConditionalOnClass
REGISTER_BEAN注册 Bean 时单个 @Bean 方法要不要注册@ConditionalOnMissingBean
24 / 137
要点

为什么条件要分两个阶段?因为这两类判断依赖的信息不同。@ConditionalOnClass 只看 classpath,解析类之前就能判定,放在 PARSE 阶段可以省掉整个类的解析开销;而 @ConditionalOnMissingBean 需要知道「别人注册了没有」,必须等到 Bean 注册阶段、别的定义都已经进场,它才有资格开口。阶段不同,是因为它们能看到的「信息」出现的时机不同。

25 / 137

上一条讲的是「什么时候能问」,那「能问到什么」呢?ConditionContext 一共给了五样东西,哪一种条件能用哪一样,是有硬对应的。背不住,来玩一局:先点左边的条件类,再点它唯一能问到的东西。

26 / 137
配对闯关
闯关谁做这个判断,它摸得到什么已配对 0/6 · 配错 0
左列是条件类,右列是它在 matches() 里唯一问得到的那一样东西——配对靠位置是猜不到的,两列都打乱了
先点左边一个
27 / 137
小节
三、条件注解全家族速查表
28 / 137

Spring Boot 在 @Conditional 之上封装了一整套开箱即用的条件注解。它们按「看什么」分成两大家族:

29 / 137
对照表
注解判断依据典型用途
@ConditionalOnClassclasspath 存在指定类有依赖才装配(HikariCP、Redis)
@ConditionalOnMissingClassclasspath 不存在指定类排除某个实现
@ConditionalOnBean容器内已有指定类型 Bean依赖另一个 Bean 才能装配
@ConditionalOnMissingBean容器内没有指定类型 Bean用户没配时的默认兜底
@ConditionalOnSingleCandidate该类型只有一个候选 Bean唯一数据源、唯一事务管理器
@ConditionalOnProperty配置项等于 / 不等于某值功能开关
@ConditionalOnResource指定资源存在有 classpath:xxx.yml 才装配
@ConditionalOnWebApplication当前是 Web 应用Web 专属自动配置
@ConditionalOnNotWebApplication当前不是 Web 应用非 Web 专属自动配置
@ConditionalOnExpressionSpEL 表达式为真需要组合的复杂条件
@ConditionalOnJavaJDK 版本在某范围内版本相关的兼容逻辑
30 / 137
架构图
图 1 · 条件注解家族
图 1 · 条件注解家族
31 / 137
提示

@ConditionalOnProperty 有个高频易错点——默认情况下它要求属性存在且不等于 false。也就是说 @ConditionalOnProperty("my.feature") 只写了名字时,my.feature=false 会让条件不成立,但属性完全不写时也不成立。想表达「不写就默认开启」,要写 matchIfMissing = true。

32 / 137
小节
四、求值顺序为什么重要
33 / 137

条件注解不是各判各的、互不相干。它们之间有时序依赖,最典型的就是 @ConditionalOnBean:它之所以能判断「容器里有没有某个 Bean」,前提是那个 Bean 的 BeanDefinition 已经被注册。顺序错了,它就会看到「还没有」,从而做出错误判断。

34 / 137

这带来一个反直觉的结论:同一个条件注解,放在不同的位置、求值的时机不同,结果可能不同。 Spring Boot 用三套机制来保证顺序可控:

35 / 137
java
// 1. 用户优先:AutoConfigurationImportSelector 是 DeferredImportSelector,//    自动配置类永远排在用户自己的 @Configuration 之后处理//    → 所以 OnMissingBean 总能看见你定义的 Bean// 2. 自动配置之间:用 before / after 排依赖@AutoConfiguration(after = DataSourceAutoConfiguration.class)@ConditionalOnBean(DataSource.class)public class JpaAutoConfiguration { }// 3. 绝对顺序兜底:@AutoConfigureOrder(数字越小越靠前)@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 100)public class MyFirstAutoConfiguration { }
36 / 137
原理动画
动图 · 条件求值的先后手
动图 · 条件求值的先后手
37 / 137
  • 用户优先是整套设计的地基:正因为 DeferredImportSelector 把自动配置推迟到用户配置之后,@ConditionalOnMissingBean 才有资格「让路」
  • before / after 解决的是自动配置之间的顺序,成对声明、不允许成环
  • @AutoConfigureOrder 用绝对数字兜底,官方建议优先用相对的前两个
38 / 137

「用户优先」这四个字落到 @ConditionalOnMissingBean 上,就是一次有先后顺序的让路。这段动画把它拆成六步,注意第 ⑤ 帧——默认值被收回去的时候,全程没有任何报错:

39 / 137
原理动画
动图 · 让路全程:默认值是怎么被收回去的
动图 · 让路全程:默认值是怎么被收回去的
40 / 137
坑

在同一个 @Configuration 类里,不要依赖两个 @Bean 方法的声明顺序去让其中一个 @Bean 带上 @ConditionalOnBean 判断另一个。同类内 @Bean 方法之间的处理顺序并不保证(受方法签名、代理等影响),这种写法非常脆弱。要表达依赖,请把这些 Bean 拆到不同的配置类,并用 before / after 明确排序。

41 / 137

把这条坑画成一张对照图,小白只要记住「这个注解该贴在哪扇门后」:

42 / 137
架构图
图 · @ConditionalOnBean 放哪儿:同一个注解,两种命运
图 · @ConditionalOnBean 放哪儿:同一个注解,两种命运
43 / 137
类比

挂号窗口只在大厅开放后才有效。@ConditionalOnBean 像医院挂号台问你「你要看的这位医生今天到岗了吗」——这个问题只有在排班表贴出来之后才答得准。自动配置就是那张排班表:容器先把你自己的科室登记完(用户优先),再按 before / after 把自动配置的科室逐个排好,此时问「某位医生在不在」才有确定答案。而把它贴在业务配置类上,等于在早上六点、排班表还没贴时问同样的话:有人已经来上班了就说「在」,没人来就说「不在」,答案取决于谁先到,而不是事实本身。

44 / 137

一句话判据:@ConditionalOnBean / @ConditionalOnMissingBean 只往自动配置类和 starter 里写;你自己的业务配置想「看情况装配」,用 @Profile 或 @ConditionalOnProperty。

45 / 137

判据好说,「一个 Bean 到底是在哪一拍消失的」就没有那么好记了。把它摊成一次单步执行:左边六行是要走的代码,右边同步刷新此刻的变量和调用栈。连点「下一步」,重点看第 ⑤ 拍——beanDefinitionCount 少 1 的那一拍,屏幕上什么异常都没有:

46 / 137
单步调试台
单步台逐行走一遍:一个 @Bean 是怎么静默消失的1 / 6
六拍走完,盯右侧的 beanDefinitionCount 与「抛出的异常」这两格;第 ④ 拍就是第八节那个坑的现场
被调试的代码
1@SpringBootApplication // 其中的 @EnableAutoConfiguration 登记一个 DeferredImportSelector
2SpringApplication.run(...) // 先解析用户自己的 @Configuration
3// 用户配置全部解析完,自动配置类才进场(这就是「延迟导入」)
4@ConditionalOnBean(DataSource.class) // 我写在业务配置类上的这一问
5matches() -> registry.containsBeanDefinition("dataSource") // 此刻查一次
6// 条件为 false:不注册、不报错,只在报告里留一行 Did not match
此刻的变量
刚读到@EnableAutoConfiguration
登记的导入器DeferredImportSelector(延迟)
已注册定义数0
调用栈
1SpringApplication.run
2@Import(AutoConfigurationImportSelector)
1延迟导入是整套秩序的地基。它把自动配置类的处理推到所有用户配置之后,于是「用户优先」不是一句客气话,而是被时序保证的事实。
47 / 137
小节
五、手写一个自定义条件注解
48 / 137

理解了 Condition 接口,就可以自己造一个条件注解。下面这个「只在星期几生效」的例子虽然趣味,但用到的每个细节都是生产级的:

49 / 137
java
@Target({ElementType.TYPE, ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@Documented@Conditional(OnDayOfWeekCondition.class)   // 绑定条件类public @interface ConditionalOnDayOfWeek {    DayOfWeek[] value();}
50 / 137
java
public class OnDayOfWeekCondition implements ConfigurationCondition {    // 放在 REGISTER_BEAN 阶段,和其他 Bean 条件保持一致    @Override    public ConfigurationPhase getConfigurationPhase() {        return ConfigurationPhase.REGISTER_BEAN;    }    @Override    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {        // 1. 从注解上取属性值        Map<String, Object> attrs = metadata                .getAnnotationAttributes(ConditionalOnDayOfWeek.class.getName());        DayOfWeek[] days = (DayOfWeek[]) attrs.get("value");        // 2. 从 Environment 取「今天」,方便测试时覆盖        Environment env = context.getEnvironment();        String override = env.getProperty("demo.day-of-week");        DayOfWeek today = override != null                ? DayOfWeek.valueOf(override.toUpperCase())                : LocalDate.now().getDayOfWeek();        // 3. 也可以顺带看一眼已有 Bean(演示 ConditionContext 的能力)        boolean hasReportService = context.getBeanFactory() != null                && context.getBeanFactory().getBeanNamesForType(ReportService.class).length > 0;        return Arrays.asList(days).contains(today) && !hasReportService;    }}
51 / 137
代码对照
代码java
@Configuration@ConditionalOnDayOfWeek(DayOfWeek.MONDAY)   // 只在周一装配public class MondayReportConfig {    @Bean    public ReportJob reportJob() {        return new ReportJob();    }}
解读
  • metadata.getAnnotationAttributes(...):读注解属性,value() 就是 @ConditionalOnDayOfWeek(DayOfWeek.MONDAY) 里的内容
  • context.getEnvironment():拿到配置环境,读取 demo.day-of-week 可以在测试里把「今天」捏成任意一天,让条件变得可测
  • context.getBeanFactory():查容器里已有的 Bean,这正是 @ConditionalOnBean 内部做的事

提示:ConditionContext 一共能拿到五样东西——BeanFactory、Environment、ResourceLoader、ClassLoader、BeanDefinitionRegistry。写自定义条件时,这五样基本覆盖了「判断所需的一切信息」。面试问「自定义条件注解怎么写」,答出「实现 Condition、覆写 matches、用 @Conditional 绑定」即可,再补充 ConfigurationCondition 两阶段就是加分项。

52 / 137
小节
六、@Profile 其实也是条件装配
53 / 137

很多人以为 @Profile 是另一套独立机制,其实它只是条件装配的一个「官方预设」。看它的定义就一目了然:

54 / 137
java
@Target({ElementType.TYPE, ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@Documented@Conditional(ProfileCondition.class)   // 本质还是 @Conditionalpublic @interface Profile {    String[] value();}
55 / 137

ProfileCondition 做的事很朴素:读取 spring.profiles.active / spring.profiles.default,看当前激活的 profile 是不是落在注解声明的列表里。所以「按 profile 切换 Bean」和「按条件切换 Bean」在机制上是同一件事——@Profile 是 @Conditional 的一个特例。理解了这一点,你就能把它们放在同一套心智模型里,而不是记两套互不相干的规则。

56 / 137
小节
七、调试技巧:把它「为什么没生效」看明白
57 / 137

条件装配最大的难点是「静默」——条件不满足时,Bean 悄无声息地消失,没有任何报错。所以调试能力比语法本身更重要。第一手段还是开启条件评估报告:

58 / 137
bash
java -jar app.jar --debug
59 / 137

报告里每一行都告诉你是哪个条件类(括号里的 OnClassCondition 等)做出的判断:

60 / 137
代码对照
代码text
Positive matches:-----------------   RedisAutoConfiguration#redisTemplate matched:      - @ConditionalOnClass found required class 'org.springframework.data.redis.core.RedisOperations' (OnClassCondition)      - @ConditionalOnMissingBean (names: redisTemplate) did not find any beans (OnBeanCondition)      - @ConditionalOnProperty (spring.data.redis.host) matched (OnPropertyCondition)Negative matches:-----------------   MongoAutoConfiguration:      Did not match:         - @ConditionalOnClass did not find required class 'com.mongodb.client.MongoClient' (OnClassCondition)
解读
  • 看 Positive matches 确认「我以为会生效的,到底生效了没有」
  • 看 Negative matches 里的 Did not match: 列表,逐条比对是哪个条件把你拦下了——是类没在 classpath(依赖没加全),还是属性没配对(配置写错),一眼便知
  • 甚至可以在代码里直接拿到这份报告对象:ConditionEvaluationReport.get(beanFactory),用于把装配结果输出到自己的运维面板
61 / 137

这套「读报告 → 找那一行 → 对症下药」的动作值得单独走一遍,因为新手最常见的卡点不是不会写条件,而是看到 Bean 消失之后不知道从哪查起:

62 / 137
原理动画
动图 · 追一个「消失的 Bean」
动图 · 追一个「消失的 Bean」
63 / 137

光有路线还不够,得在真堆栈上练一次。下面这段就是线上最常出现的那种「代码一行没改、换台机器就炸」,先别看解析——点出你认为的凶手行:

64 / 137
报错急救
报错急救NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.report.ReportJob' available
一个贴着条件注解的 Bean 凭空消失

本地跑得好好的报表任务,交到同事机器上启动就失败。你确认代码一行没动,只是他的 pom 里少了一个依赖。

APPLICATION FAILED TO START
Parameter 0 of constructor in com.example.report.ReportService required a bean of type 'com.example.report.ReportJob' that could not be found:
at org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:1801)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doGetBean(DefaultListableBeanFactory.java:1357)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:1309)
at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:542)
at com.example.report.ReportService.<init>(ReportService.java:22)
The following candidate was skipped by a condition:
- ReportJobConfig#reportJob: @ConditionalOnClass did not find required class 'com.opencsv.CSVWriter' (OnClassCondition)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
65 / 137
小节
八、坑:@ConditionalOnBean 的两个陷阱
66 / 137

这个注解最容易用错,有两个坑必须提前知道:

67 / 137
  • 坑一:它只能看见「已注册」的 Bean。 @ConditionalOnBean 判断的是容器里已经注册的 BeanDefinition,而不是「将来会出现」的 Bean。所以它和「谁先注册」强绑定——这也是第四节反复强调顺序的原因。在自动配置类之间用它,务必配合 before / after
  • 坑二:条件里不要做耗时操作。 matches 会被反复求值(同一个条件在一次启动里可能被调用很多次),而且它是在启动的关键路径上同步执行的。在里面查数据库、发起网络请求、解析大文件,会直接拖慢甚至卡死启动。条件应该是快、纯、无副作用的
68 / 137
警告

不要写「读远程配置中心来判断条件」这类逻辑。条件求值的时机非常早,此时很多基础设施(连接池、注册中心客户端)本身还在装配中,你很可能拿到一个还没初始化好的组件,导致启动进入不确定状态,排查起来极其痛苦。

69 / 137
小节
九、动手体验:亲手触发条件求值
70 / 137

下面这个演示把条件求值做成了可切换的开关。试着切换「用户自定义 DataSource」与「classpath 有 spring-jdbc」这两个条件,观察装配结果如何随之改变——这正是 @ConditionalOnMissingBean 与 @ConditionalOnClass 在真实工作中的配合方式:

71 / 137
内核实验
72 / 137
小节
十、决策:写 starter 时该用 OnMissingBean 还是 OnProperty
73 / 137
决策
决策你在写一个公司内部 starter,要为客户方提供一个默认的 `HttpClient`,同时允许使用方用自己定义的 `HttpClient` 替换掉它。该用哪个条件注解?
74 / 137
小节
十一、上手实验:把「四问」逐条按给你看
75 / 137

前面写的都是文字结论。下面五个内核实验请按顺序跑完,每个大约 30 秒,它们分别对应本篇的五条主线:先看单个条件怎么判,再看整条装配链,再往下看「条件到底在判什么东西」,然后把这套规矩用回 starter,最后回头确认「一个对象究竟是怎么进容器的」。

76 / 137

第一个实验单独盘问四种条件。先点 onclass(依赖在不在),再点 onprop(开关开没开),然后点 onbean 与 missing 对照着看——前者问「有没有」,后者问「你有没有自己配」,这一对最容易记反:

77 / 137
内核实验
TeaVM四种条件各自盘问什么:onclass / onbean / onprop / missing未启动
重点对比 onbean 与 missing:一个是「有我才装」,一个是「没有我才兜底」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
78 / 137

第二个实验把这些条件放回自动配置的流水线上。按 imports → filter → sort → apply → report 点一遍,你会看到候选数量在哪一步掉得最狠,以及为什么同一个注解在不同阶段答案不同:

79 / 137
内核实验
TeaVM自动配置装配链:条件过滤发生在哪一站未启动
盯住 filter 那一站的候选数变化,再看 report 里正选/落选的分组
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
80 / 137

第三个实验回答一个更底层的问题:条件判的到底是什么?答案是 BeanDefinition——容器在真正造对象之前先写好的一份「配方」(描述这个 Bean 该怎么造的元数据对象,不是 Bean 本身)。看清这一层,你就明白条件装配拦的是「要不要写这份配方」,而不是「要不要调构造器」:

81 / 137
内核实验
TeaVM从 @Bean 到 BeanDefinition:条件拦的是哪一步未启动
先看 scan 与 registry 两站,再用 attrs 观察 scope / lazy 如何改写创建时机
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
82 / 137

第四个实验把整套规矩用回你自己的 starter:清单登记 → 属性绑定 → 生成 Bean → 被你关掉。走完前三站一定要点 off,看清楚 exclude 和开关属性这两条「关掉它」的路有什么区别:

83 / 137
内核实验
TeaVM亲手装一条自动配置:清单 → 属性 → Bean → 关掉它未启动
点 off 时对比 spring.autoconfigure.exclude 与 sms.enabled=false 两种关闭方式
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
84 / 137

第五个实验回答一个更前置的问题:条件判的是「要不要写这份配方」,那配方到底有几条来源?依次点 scan、bean、import、auto,四条路(组件扫描、@Bean 方法、@Import 直送、自动配置登记)会各自打出它在哪个阶段把 BeanDefinition 塞进注册表;最后点 miss——包没被扫到的现场,报错措辞和被条件拦下几乎一模一样,这正是第七节那段堆栈里最需要分辨的一件事:

85 / 137
内核实验
TeaVM一个对象进容器的四条路:scan / bean / import / auto未启动
四个按钮各走一遍,重点看每条路写下 BeanDefinition 的时机,以及它和条件求值阶段的先后关系
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
86 / 137
内核实验
TeaVM扫不到的现场:为什么它长得和「被条件拦下」一样未启动
对照第七节那段堆栈:同样是「容器里没有」,这一档是没扫到,上一档是被拦下;措辞差别就在有没有 skipped by a condition 那一句
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
87 / 137
要点

五个实验的共同结论是——条件求值发生在「还没造对象」的时候。所以它快、便宜,也正因为它发生得这么早,很多你熟悉的东西(连接池、远程配置)在此刻根本还不存在,这就是第八节那条警告的由来。

88 / 137

实验按完了,换成命令行自己敲。下面这台控制台连着浏览器里的同一个内核,回显全部由内核算出来——先 boot 建容器,再逐条翻开关:

89 / 137
内核控制台
90 / 137
说明

cond jdbcOnClasspath false 之后必须再敲一次 beans 才看得出差别——cond 会顺手重建容器,但它只回显新开关值,不替你列清单。这一串动作就是第七节「改条件 → 看报告」的手工版。

91 / 137
小节
十二、沙盘:三个开关,决定 Bean 到底进不进容器
92 / 137

现在轮到你自己当一回容器。左边拨三个开关,右边立刻给出启动结果和 --debug 报告里的那一行判决。建议先把三格都调到默认值读一遍,再每次只改一格——这是理解条件装配最快的路径:

93 / 137
沙盘
沙盘这个 Bean 到底会不会被注册
运行结果
Positive matches: CacheAutoConfiguration#cacheService matched
- @ConditionalOnClass found required class 'redis.clients.jedis.Jedis' (OnClassCondition)
- @ConditionalOnProperty (cache.redis.enabled) matched (OnPropertyCondition)
- @ConditionalOnMissingBean did not find any beans (OnBeanCondition)
容器里有 1 个 CacheService:自动配置的那个
matchIfMissing = true 的效果:属性不写也算开。这一格是全绿的默认态。
94 / 137
说明

最后一格是最容易被忽略的组合。@ConditionalOnMissingBean 保证的是「用户先注册的那一个能顶掉默认值」,它不负责帮你清理重复定义。真的出现两个同类型 Bean,报错的是注入方而不是容器装配阶段——记住那句 required a single bean, but 2 were found,它就是本节要找的目标。

95 / 137
小节
十三、随堂自测
96 / 137

先来一道热身题,考的就是第三节那条提示里的默认值陷阱:

97 / 137
随堂自测
随堂自测配置类上写了 `@ConditionalOnProperty("my.feature")`(只写名字,没写 havingValue,也没写 matchIfMissing)。yml 里完全没有 `my.feature` 这一行。这个 Bean 会被注册吗?
先自己选一个,选中立刻告诉你对不对
98 / 137

再来一道综合题,把第四、八节和那张 vs 图串起来:

99 / 137
随堂自测
随堂自测你在自己的业务 `@Configuration` 类里给一个 `@Bean` 方法加了 `@ConditionalOnBean(DataSource.class)`,本地能启动,换台机器就报这个 Bean 找不到。最合理的解释和修法是?
先自己选一个,选中立刻告诉你对不对
100 / 137
小节
十四、常见报错速查
101 / 137

下面每一行的「报错原文」都可以整段复制去搜索,别意译、别缩写。条件装配的坑有个共同特征:大多不以红字异常出现,而是「东西凭空少了」。

102 / 137
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
@ConditionalOnBean 明明写了,Bean 却根本没生成(也没有任何报错)求值时机不对:被判断的那个 Bean 的定义还没注册进来,条件就看到「没有」。写在业务配置类里尤其容易踩把它挪进自动配置类并配 before / after;或者干脆换成 @ConditionalOnProperty 做显式开关本篇第四、八节
NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.ReportJob' available贴着条件注解的类压根没被注册——条件没过,静默消失加 --debug 重启,在 Negative matches 里找这个类的 Did not match: 后面那行,它会写明是哪个条件拦的本篇第七节 · 第十二节沙盘
java.lang.NoClassDefFoundError: org/springframework/data/redis/core/RedisOperations编译期这个类在(provided 或 IDE 里能看到),运行期对应的 jar 不在 classpath 上确认依赖是否 <optional> / <scope>provided</scope> 被排掉了;mvn dependency:tree 核对;这正是 @ConditionalOnClass 要防的事故#2 Maven 依赖 · #18 自动配置
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'noticeService': Unsatisfied dependency expressed through constructor parameter 0你要注入的那个 Bean 没被装配出来——通常是某个上游条件(属性开关、类缺失)把它拦掉了顺着报错里的类型名去 --debug 报告搜它属于哪个自动配置;先确认开关属性和依赖 jar,再怀疑代码本篇第十二节沙盘 · #6 IoC 与 DI
Parameter 0 of constructor in xxx required a single bean, but 2 were found同类型出现多个候选:@ConditionalOnMissingBean 让位失败、或用户与自动配置各注册了一个注入点加 @Qualifier("名字"),或给首选者标 @Primary;根治是把默认实现的条件写准本篇第十二节 · #7 BeanDefinition
@ConditionalOnProperty (my.feature) did not match (OnPropertyCondition)只写了名字没写 matchIfMissing = true,而属性完全不存在——不存在同样算不匹配要么在 yml 里补上 my.feature: true,要么在注解上加 matchIfMissing = true本篇第三节提示条
CONDITIONS EVALUATION REPORT 里找不到我的自动配置类(连 Negative matches 都没有)该类没被登记进候选清单:imports 文件路径写错,或它是被 @ComponentScan 扫进来的普通配置类核对 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports;确认里面写的是全限定类名#20 自定义 Starter
103 / 137
提示

搜报错时只搜冒号后第一段原文(例如 required a single bean, but 2 were found),命中率远高于搜整句——不同 Spring 版本会在后半句加话。

104 / 137
小节
十五、动手练习
105 / 137
小节
第一档 · 照做
106 / 137

目标:造一个「只有开关打开才装配」的小应用,并用 --debug 报告亲眼确认两条路径的差异。全程只需要四个文件。

107 / 137

第一步,依赖只要 Boot 本体(不需要数据库、不需要 Redis):

108 / 137
xml
<dependencies>    <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter</artifactId>    </dependency></dependencies>
109 / 137

第二步,主类 DemoApplication.java(放在 src/main/java/com/example/cond/DemoApplication.java):

110 / 137
java
package com.example.cond;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.ConfigurableApplicationContext;@SpringBootApplicationpublic class DemoApplication {    public static void main(String[] args) {        ConfigurableApplicationContext ctx = SpringApplication.run(DemoApplication.class, args);        System.out.println("featureRunner 在容器里吗 -> "                + ctx.containsBean("featureRunner"));        System.out.println("beanDefinitionCount = " + ctx.getBeanDefinitionCount());    }}
111 / 137

第三步,被条件管束的配置类 FeatureConfig.java(与主类同包):

112 / 137
java
package com.example.cond;import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configuration(proxyBeanMethods = false)public class FeatureConfig {    @Bean    @ConditionalOnProperty(prefix = "feature", name = "enabled", havingValue = "true")    public Runnable featureRunner() {        return () -> System.out.println("[feature] 我只在开关打开时存在");    }}
113 / 137

第四步,配置文件 src/main/resources/application.yml:

114 / 137
yaml
feature:  enabled: false      # ← 先写 false,跑一次;再改成 true 跑第二次logging:  level:    root: info
115 / 137

两次运行的预期输出(关键行照抄即可对照):

116 / 137
text
# 第一次:feature.enabled=falsefeatureRunner 在容器里吗 -> falsebeanDefinitionCount = 78# 第二次:feature.enabled=truefeatureRunner 在容器里吗 -> truebeanDefinitionCount = 79
117 / 137

第五步,加上 --debug 再跑一次,把控制台开头那段条件报告翻到自己这一项:

118 / 137
bash
java -jar target/demo-0.0.1-SNAPSHOT.jar --debug
119 / 137

--debug 报告片段(第一次运行时,你的类是被扫描进来的普通配置类,所以它不出现在自动配置的正选/落选里,但你可以直接搜属性名):

120 / 137
text
============================CONDITIONS EVALUATION REPORT============================Positive matches:-----------------   PropertyPlaceholderAutoConfiguration matched:      - @ConditionalOnMissingBean (types: org.springframework.context.support.PropertySourcesPlaceholderConfigurer) did not find any beans (OnBeanCondition)Negative matches:-----------------   RedisAutoConfiguration:      Did not match:         - @ConditionalOnClass did not find required classes 'org.springframework.data.redis.core.RedisOperations', ... (OnClassCondition)
121 / 137

验收清单:① 两种取值下 featureRunner 在容器里吗 分别是 false / true;② 你能说出 beanDefinitionCount 为什么只差 1;③ 你能在 --debug 输出里找到 Negative matches: 这一段,并解释任意一行为什么落选。

122 / 137
小节
第二档 · 变体
123 / 137

目标:只用一个改动,观察条件行为的变化。每条各做一次,把两行日志记进笔记。

124 / 137
  1. 把注解改成 @ConditionalOnProperty(prefix = "feature", name = "enabled", havingValue = "true", matchIfMissing = true),然后把 yml 里那行 enabled 整行删掉。你会观察到:Bean 照样被注册(containsBean 返回 true)——这就是「不写也算开」。再对比第一档里不带 matchIfMissing 时删掉同一行的结果(false),你就彻底记住了这个默认值。
  2. 把 @ConditionalOnProperty 换成 @ConditionalOnBean(String.class),yml 保持原样。你会观察到:结果开始随「容器里此刻有没有一个 String Bean」漂移,甚至可能因加载顺序在你机器上和同事机器上不一致——这正是第四节和那张 vs 图讲的现象。
  3. 保留第一档的写法,但在 DemoApplication 旁边再加一个 @Configuration 类,里面用 @Bean 自己定义一个 Runnable featureRunner()。你会观察到:由于同类内 @Bean 顺序不保证,你可能看到 BeanDefinitionOverrideException(Boot 2.1+ 默认禁止覆盖),也可能两个都在。修法是把默认值搬进自动配置类并使用 @ConditionalOnMissingBean。
125 / 137

提示:做完第 3 条,回头重跑第十一节的 cond 实验的 missing 参数,抽象规则会立刻落地。

126 / 137
小节
第三档 · 造一个
127 / 137

给自己做一个「功能开关套件」,要求把本篇所有机制都用上一遍。

128 / 137
  • 一个自定义条件注解 @ConditionalOnDayOfWeek(参考第五节),支持 DayOfWeek[] value()
  • 两个配置类:周一到周五装配 WorkdayReporter,周末装配 WeekendReporter,两者实现同一个接口 Reporter
  • 一个总开关:report.enabled=false 时两者都不装配(@ConditionalOnProperty,且带 matchIfMissing = true)
  • 一个测试钩子:允许用配置项 report.day-of-week 捏造「今天是星期几」,让单元测试不用等到周一才能跑
  • 一段 main:打印当前激活的是哪个 Reporter,以及 --debug 报告里对应的那一行判决
129 / 137

验收清单:① 用同一个 jar 通过命令行参数切换出三种结果(工作日 / 周末 / 全关),不需要重新打包;② report.day-of-week=MONDAY 能在任意一天触发工作日分支;③ 你能写出「为什么两个 Reporter 不会同时出现」的一句话解释,并在 --debug 报告里指出证据行;④ 有人第一次接手时,不看代码只看 application.yml 的注释就能知道怎么开关。

130 / 137
小节
十六、要点自查
131 / 137
自检

不看上文,说出条件求值的「四问」顺序,以及每一问由哪个条件类负责(OnClassCondition / OnBeanCondition / OnPropertyCondition)。

132 / 137
自检

@ConditionalOnBean 和 @ConditionalOnMissingBean 各自问的是什么?为什么说它们是同一枚硬币的两面?

133 / 137
自检

为什么 @ConditionalOnClass 可以放在 PARSE_CONFIGURATION 阶段,而 @ConditionalOnMissingBean 必须等到 REGISTER_BEAN?用「信息什么时候出现」这句话回答。

134 / 137
自检

@ConditionalOnProperty("my.feature") 只写名字时,「属性不存在」和「属性等于 false」结果一样吗?想让「不写就默认开启」要加什么?

135 / 137
自检

一个贴着条件注解的 Bean 消失了,你的第一步动作是什么?在报告里该搜哪个关键词?

136 / 137
口诀

类在不在、Bean 有没有、开关开没开、用户配没配——四问全过才注册,不过就静默消失;想知道为什么,--debug 看 Negative matches。

137 / 137
总结

条件装配的全部秘密,是「把 if 搬进注解」。所有条件最终都落到 @Conditional(Condition.class):一个返回 boolean 的 matches 方法,配上一个能拿到 BeanFactory / Environment / ClassLoader 等五大能力的 ConditionContext。条件分 PARSE_CONFIGURATION 与 REGISTER_BEAN 两个阶段求值,是因为它们需要的信息出现时机不同。全家族按两大家族记忆:存在性判断(OnClass / OnBean / OnMissingBean / OnResource)与环境配置判断(OnProperty / OnWebApplication / OnExpression)。实战里最该刻进肌肉记忆的是三条:求值顺序决定成败(用户优先 + before/after + @AutoConfigureOrder)、@Profile 也是条件装配、条件必须快且无副作用——调试的第一手段永远是 --debug 条件报告。