Spring Boot 条件装配的八个坑:从 @AutoConfigureBefore 失效说起

作者:忆笙智云官方 | 发布时间:2026-10-29 02:36 | 更新时间:2026-10-29 02:36

从一个很奇怪的现象说起

写 Spring Boot 的人大概都干过这件事:自动配置给了个默认的 ObjectMapper,你嫌它没开 WRITE_DATES_AS_TIMESTAMPS,于是自己写一个 @Bean,名字都不改,就盼着覆盖掉它。结果启动就报错,或者更诡异——没报错,但你的 Bean 压根没生效,日志里该输出时间戳还是时间戳。

再换一种:你写了个 starter,想在自己的自动配置里判断"用户有没有自己定义 DataSource",用了 @ConditionalOnMissingBean(DataSource.class),本地跑得好好的,一到集成环境就变成两个 DataSource,一个是你写的,一个是别人的。

这些问题的根源都不在 Spring 本身写错了,而在于条件装配的求值时机和大多数人脑子里的模型不一致。大家默认的模型是"容器启动完成后,看看有没有某个 Bean,没有就创建",而实际模型是"解析配置类的过程中,边解析边判断,判断依据是此刻已经注册的 BeanDefinition"。这两者的差别,足以让人排查一整天。

下面这八个问题,都是我在真实项目里反复见到、也都能用几行代码复现的。它们不是"冷知识合集",每一条都对应一个具体的错误配置方式和一个明确的修法。

先把加载链路讲清楚

不理解链路,后面所有结论都只能死记。

@SpringBootApplication 里含有 @EnableAutoConfiguration,后者通过 @Import(AutoConfigurationImportSelector.class) 把所有自动配置类拉进来。而"所有"这个集合从哪来?从 classpath 上每个 jar 包里的这个文件:

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

一行一个全限定类名,支持 # 注释。这是 Spring Boot 2.7 引入、3.0 正式取代 spring.factories 中 EnableAutoConfiguration 键的机制。如果你手上有 2022 年以前写的 starter,里面还有 META-INF/spring.factories,在 Boot 3 上它不会被当成自动配置——不是失效警告,是彻底不加载。很多人升级后"自动配置没了",第一反应是怀疑条件写错了,其实文件位置就错了。

这里有第一个容易被忽略的规定,官方文档写得很直白:自动配置类只能通过这个 imports 文件加载,绝不能成为组件扫描的目标,自动配置类内部也不应该开启 @ComponentScan,要引入别的配置就用 @Import。

为什么这条规定这么硬?因为自动配置包名通常长得像 com.foo.autoconfigure,如果你的启动类在 com.foo,而自动配置类恰好在 com.foo.autoconfigure 下面,那它就会被 @ComponentScan 扫到。一旦被扫到,它就变成了"用户定义的配置类",加载顺序、条件求值时机全变了,@ConditionalOnMissingBean 的语义直接崩掉。这种 bug 特别隐蔽,因为本地包结构往往和发布后的包结构不一样。

链路继续:所有候选配置类交给 ConfigurationClassPostProcessor(一个 BeanDefinitionRegistryPostProcessor)解析。这个解析发生在 refresh() 的 invokeBeanFactoryPostProcessors 阶段,此时容器里还没有任何业务 Bean 实例,只有一个个 BeanDefinition。条件注解就是在这一步被求值的。所以任何"我需要先有个 Bean 才能判断"的想法,在这个阶段都是行不通的。

这就是全部矛盾的源头。

坑一:@AutoConfigureBefore 对普通配置类无效

这是最高频的误解,几乎每个写 starter 的人都踩过。

@Configuration
@AutoConfigureBefore(DataSourceAutoConfiguration.class)
public class MyConfig {
    @Bean
    public DataSource routingDataSource() { ... }
}

看起来"我的配置排在数据源自动配置前面",实际上 @AutoConfigureBefore 只作用于自动配置类之间的排序。它的消费者是 AutoConfigurationSorter,处理的输入只有从 imports 文件读出来的那批类。你把它贴在一个普通的 @Configuration 上,等于写了个注释,没有任何代码会读它。

普通配置类和自动配置类的加载先后是另一套规则:ConfigurationClassPostProcessor 先解析应用自己的配置(@ComponentScan 扫到的、@Import 进来的),再处理 AutoConfigurationImportSelector 引入的那批。也就是说,你的配置天生就在自动配置之前。这本来是好事——@ConditionalOnMissingBean 正是靠这个前提才可靠。

所以正确写法是:如果你想影响自动配置的顺序,你必须自己也是自动配置,也就是加上 @AutoConfiguration 并把自己登记进 imports 文件:

@AutoConfiguration(before = DataSourceAutoConfiguration.class)
public class MyRoutingDataSourceAutoConfiguration {
    // ...
}

@AutoConfiguration 这个注解本身被 @Configuration 元注解标注,所以它就是个配置类,只是额外获得了参与自动配置排序的资格。

顺带说一个更隐蔽的:@AutoConfigureAfter 写成"我自己排在别人后面",很多人指望借此让别人的 Bean 先创建好,再注入给自己。这是不可能的,理由见下一条。

坑二:排序排的是"定义顺序",不是"创建顺序"

官方文档那句话值得抄下来贴在显示器上:

自动配置类的应用顺序,只影响它们的 Bean 被定义的顺序;至于这些 Bean 后续被创建的顺序,不受影响,而由各自的依赖关系和 @DependsOn 决定。

翻译成人话:@AutoConfigureBefore 只保证"我的 @Bean 方法先被解析成 BeanDefinition",绝不保证"我的 Bean 先被实例化"。

这解释了为什么很多人加了排序注解后,@PostConstruct 里的执行顺序还是乱的。Bean 的实例化顺序由依赖图决定:A 依赖 B,B 就先创建;没有依赖关系的两个 Bean,创建顺序取决于 BeanDefinition 的注册顺序,但只要有别的因素介入(比如某个 BeanPostProcessor 提前触发了 getBean),顺序就会变。

更要命的是 @PostConstruct 的执行时机——它在 Bean 实例化并完成属性注入之后、InitializingBean.afterPropertiesSet() 之前。你就算让 A 的定义排在 B 前面,只要 B 被谁提前 getBean 了一下,B 的 @PostConstruct 就先跑。

结论:排序注解解决的是"条件判断的可见性"问题,不是"初始化顺序"问题。 想控制初始化顺序,老老实实用构造器注入声明依赖,或者 @DependsOn。用排序注解去控制初始化顺序,是在用错误的工具解决错误的问题,还特别难查。

坑三:@ConditionalOnMissingBean 看的是"到目前为止"

官方文档有一句特别提醒:Bean 条件是基于"已经处理过的 Bean 定义"来求值的,所以要非常小心 Bean 定义的添加顺序。紧接着给出建议:这两个条件注解只应该用在自动配置类上,因为自动配置类保证在所有用户自定义 Bean 定义之后加载。

这句话反过来读就是:在普通 @Configuration 里用 @ConditionalOnMissingBean,行为是没有保证的。

为什么?考虑这个场景:

@Configuration
public class AConfig {
    @Bean
    @ConditionalOnMissingBean
    public CacheManager cacheManager() { return new SimpleCacheManager(); }
}

@Configuration
public class BConfig {
    @Bean
    public CacheManager redisCacheManager() { return new RedisCacheManager(); }
}

两个配置类谁先被解析,取决于 @ComponentScan 的扫描顺序——这个顺序在不同文件系统、不同打包方式下都可能不同。如果 AConfig 先被解析,它看到的 BeanDefinition 里没有 CacheManager,于是注册了一个;等 BConfig 解析时,容器里已经有两个 CacheManager 了,要么冲突报错,要么按名字共存,注入处 @Autowired CacheManager 直接抛 NoUniqueBeanDefinitionException。

这个 bug 的恶劣之处在于它和机器有关。开发机上扫描顺序恰好是先 B 后 A,一切正常;上了 CI 或者打包成 fat jar 之后顺序变了,测试全绿而线上炸。你本地怎么都复现不出来。

所以规则很清楚:条件装配留给自动配置。普通业务配置里判断"有没有",应该用 @ConditionalOnProperty 这类基于配置的判断,或者干脆别判断——业务代码里出现"可能有两个同类型 Bean"的设计,本身就是个信号。

坑四:返回类型写得太抽象,条件判断就失准

这一条官方文档也点了名:@Bean 方法的返回类型要尽可能提供精确的类型信息。如果具体类实现了接口,返回类型就写具体类,别写接口。

原因在条件求值的实现上。@ConditionalOnMissingBean 放在 @Bean 方法上、不指定 value 时,匹配的目标类型默认取方法签名上的返回类型。注意,取的是签名,不是运行时实例的真实类型——因为求值时压根还没有实例。

于是:

@Bean
@ConditionalOnMissingBean
public CacheManager cacheManager() {
    return new ConcurrentMapCacheManager();
}

这里判断"容器里有没有 CacheManager",任何实现了 CacheManager 的 Bean 都会让条件不成立——看起来没问题。

但换一个:

@Bean
@ConditionalOnMissingBean
public Object cacheManager() { ... }

这就废了,因为它判断的是"有没有 Object 类型的 Bean",而容器里几乎必然有,条件永远不成立,你这个 Bean 永远不创建。更常见的翻车版本是返回类型写成了父接口,而你实际想判断的是子接口,那就会出现"我以为你有了,其实你没有"或者"我以为你没有,其实你有"。

排查这类问题有个小技巧:把 @ConditionalOnMissingBean 显式写上类型和名字,别依赖推断。

@Bean
@ConditionalOnMissingBean(value = CacheManager.class, name = "cacheManager")
public ConcurrentMapCacheManager cacheManager() { ... }

写全了虽然啰嗦,但意图明确,也省得以后有人改了返回类型把条件搞坏——这种"改个返回类型导致 Bean 消失"的事故,我见过不止一次,而且 diff 里看起来人畜无害。

坑五:类级条件和方法级条件不是一回事

这个差别知道的人不少,但真正用对的不多。

@Configuration
@ConditionalOnMissingBean(FooService.class)
public class FooConfig {
    @Bean public FooService foo() { ... }
    @Bean public FooHelper helper() { ... }
}

类级条件的作用是:不满足时,这个配置类本身不注册为 Bean。类里所有的 @Bean 方法都不会被处理,一个都不会创建。这跟"逐个方法判断"的结果在大多数情况下一致,但有一处关键差异——类里的 @Bean 方法之间如果有互相调用,行为会变。

更值得说的是反向场景:如果你只想让其中一个 Bean 受条件控制:

@Configuration
public class FooConfig {
    @Bean
    @ConditionalOnMissingBean
    public FooService foo() { ... }

    @Bean
    public FooHelper helper(FooService fooService) { ... }
}

helper 无条件创建,但它依赖 fooService。如果用户自己定义了 FooService,用的是别的实现类,helper 会拿到用户那个——这通常是想要的效果。如果用户一个都没定义,foo() 生效,helper 拿到默认实现。逻辑是通的。

但要小心一种情况:@ConditionalOnMissingBean 挂在方法上时,条件不成立只是跳过这个 @Bean 方法,配置类仍然会被实例化。如果配置类里有 @PostConstruct、有构造函数里的副作用,它们照跑。类级条件则是整个类都不进来。这两种语义在"配置类有初始化逻辑"时会带来完全不同的行为。

想清楚你要的是"这个类不要加载"还是"这个方法不要执行",再决定注解贴在哪。

坑六:@ConditionalOnProperty 的默认语义是个陷阱

这个注解的三个默认行为,值得单独列出来:

第一,不写 havingValue 时,判断标准是"属性存在且值不等于 false"。 注意,不是"值等于 true"。所以 my.feature=enabled、my.feature=1、my.feature=(空串)都算匹配。很多人以为只有 true 才算,于是写下 my.feature=yes 觉得没生效,其实它生效了,只是没人告诉他。

第二,name 写多个时,是"全部满足"而不是"任一满足"。 官方原文:多个属性名都给出来时,所有属性都必须通过检查。想要"或"的语义,得用 @ConditionalOnExpression 或者写两个条件组合。

第三,matchIfMissing 决定"属性完全不存在"时的行为。 默认 false,也就是不存在就不匹配。这个默认值本身合理,但它和第一点组合起来会产生一个反直觉结果:属性存在但值为 false 时不匹配,属性不存在时也不匹配——两种情况结果一样,但原因完全不同。排查时看不出区别,很容易误判。

Spring Boot 3.5 起多了一个更精确的选择:@ConditionalOnBooleanProperty,专为布尔属性设计。它的语义比 @ConditionalOnProperty 更严格——默认要求属性存在且等于 true,不会因为 my.feature=abc 这种脏数据产生"匹配上了"的意外。

配置项是开关性质的,建议直接用 @ConditionalOnBooleanProperty,别再用 @ConditionalOnProperty 加 havingValue = "true" 的老写法了。

坑七:@ConditionalOnExpression 里引用 Bean,会让 Bean 提前出生

这个坑杀伤力最大,因为它不只是"条件失效",而是会让某个 Bean 以残缺状态进入容器。

@Configuration
@ConditionalOnExpression("@featureFlags.isEnabled('newCache')")
public class NewCacheConfig { ... }

写起来很优雅。但官方文档明确警告:在表达式里引用 Bean 会导致该 Bean 在上下文 refresh 的很早阶段就被初始化,结果它不会参与后续的后置处理(比如配置属性绑定),状态可能是不完整的。

原因还是时机。@ConditionalOnExpression 的求值发生在 ConfigurationClassPostProcessor 解析配置类时,也就是 BeanFactoryPostProcessor 阶段。这时候 getBean 一个 featureFlags,等于强行把它的实例化提前到"所有 BeanPostProcessor 还没就绪"的时刻。@ConfigurationProperties 的绑定、@Autowired 的注入、AOP 代理,全都赶不上。

结果就是:featureFlags 里的字段全是默认值 null 或 false,条件恒为假,你还在那儿查为什么开关不生效。

正确做法是别在条件表达式里碰 Bean,改成基于配置属性的判断:

@Configuration
@ConditionalOnProperty(prefix = "app.cache", name = "mode", havingValue = "new")
public class NewCacheConfig { ... }

Environment 在条件求值阶段是完整可用的,配置属性判断没有任何副作用。这条规则可以推广成一句话:条件注解里只应该读 Environment 和类元数据,永远不要读 Bean。

坑八:@ConditionalOnClass 贴在 @Bean 方法上,条件来不及生效

直觉上 @ConditionalOnClass(SomeClass.class) 写在 @Bean 方法上应该没问题,实际会炸。

官方解释得很技术:@ConditionalOnClass 用在类上时,注解元数据是通过 ASM 读取的,不会触发类加载,所以即使 SomeClass 不在运行时 classpath,写成 value = SomeClass.class 也不会报错。但用在 @Bean 方法上就不一样了——方法的返回类型通常就是条件的目标,而 JVM 在方法被检查之前就已经尝试解析方法签名涉及的类型了,类不在 classpath 会直接抛 NoClassDefFoundError。

也就是说,这个注解本来是用来"防着类不存在"的,结果它自己先把类加载了,防了个寂寞。

官方给的解法是隔离:把带条件的 @Bean 放进一个静态内部配置类,条件贴在这个类上。

@AutoConfiguration
public class MyAutoConfiguration {

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(SomeService.class)
    static class SomeServiceConfiguration {
        @Bean
        @ConditionalOnMissingBean
        SomeService someService() {
            return new SomeService();
        }
    }
}

还有一个更隐蔽的限制:如果你把 @ConditionalOnClass 组合进自己的自定义注解里(做元注解),必须用 name 属性传类名,不能用 value。因为元注解场景下框架没法可靠地拿到类字面量。这条写在官方文档里,但几乎没人读到。

顺带说清楚 proxyBeanMethods = false

自动配置类几乎都写成 @Configuration(proxyBeanMethods = false),这个细节经常被当成"性能优化"一句带过,其实它影响行为。

默认 proxyBeanMethods = true 时,Spring 会为配置类生成 CGLIB 子类,拦截 @Bean 方法之间的调用,保证 a() 里调用 b() 拿到的是容器里的单例,而不是又 new 一个。代价是启动时多一次字节码生成。

设成 false 之后不再生成代理,配置类里的方法调用就是普通 Java 方法调用。于是:

@Configuration(proxyBeanMethods = false)
public class FooConfig {
    @Bean public A a() { return new A(b()); }   // 会 new 出一个新的 B
    @Bean public B b() { return new B(); }      // 容器里还有一个 B
}

容器里 b 只有一个(@Bean 方法只被调用一次),但 a 里那个 b() 调用产生了一个额外的实例,游离在容器之外。如果 B 是有状态的、或者带 @PostConstruct,问题就来了。

所以写 proxyBeanMethods = false 的前提是:你的 @Bean 方法之间不互相调用,全部通过方法参数注入依赖。自动配置类能做到这一点(Spring 官方也是这么要求的),业务配置类如果做不到,就别关代理。

这条也给了一个诊断思路:如果某个 Bean 的状态莫名其妙不对、@PostConstruct 好像执行了两次,去看看是不是有人把 proxyBeanMethods 关了又保留了方法内调用。

3.4 和 3.5 各改了一条规则,都很安静

先说 3.4 的。release notes 里有一条不显眼但很容易踩的变更:

@ConditionalOnBean 和 @ConditionalOnMissingBean 用在 @Bean 方法上时,原本会把方法返回类型作为匹配类型的默认值。以前,只有你显式设了 name、type 或 value,这个默认值才不生效。从 3.4 开始,如果你设了 annotation 属性,默认值也不生效了。

annotation 属性是 3.0 加的,用来按"Bean 上有没有某个注解"来匹配,比如 @ConditionalOnBean(annotation = Primary.class)。3.4 之前,你写:

@Bean
@ConditionalOnBean(annotation = Primary.class)
public FooService fooService() { ... }

框架会同时用"返回类型 FooService"和"带 @Primary 注解"两个条件,有点"我全都要"的意思。3.4 之后只用注解条件,类型条件被丢掉。

这个变更在升级时不会有任何编译错误,行为却变了。想恢复旧行为,得把返回类型显式写进 value:

@Bean
@ConditionalOnBean(value = FooService.class, annotation = Primary.class)
public FooService fooService() { ... }

如果你的项目跨 3.3 和 3.4 两个版本,这类变更建议直接查官方 release notes 的 "Bean-based Conditions" 小节,别靠猜。

再说 3.5 的。3.5 让 Bean 条件开始考虑 @Bean 返回类型上的泛型,之前只比对原始类型。这个改动看着像优化,实际是破坏性的——release notes 把它列在 breaking changes 里,原话是"一些原本能匹配上的条件现在可能不再匹配"。

具体影响在这种写法上:

@Bean
@ConditionalOnMissingBean
public List<Validator> validators() { ... }

3.4 及以前,条件判断的是"容器里有没有 List 类型的 Bean"——几乎任何 List 都能让它不成立,或者反过来,你明明注册了 List<Validator>,框架却因为比对不上原始类型而重复注册。3.5 之后会带上泛型信息,判断变准了,结果是行为变了。

这类升级问题最烦的地方在于:编译通过、启动正常、测试也绿,只有某个条件分支悄悄走到了另一条路上。升级 major/minor 版本时,把 --debug 的 CONDITIONS EVALUATION REPORT 前后各存一份做 diff,比我读十遍代码都管用。

排查这类问题,别靠猜

条件装配的问题如果靠读代码猜,效率极低。有三个官方给的手段,用起来很省时间。

第一个,启动加 --debug。 控制台会输出一份 CONDITIONS EVALUATION REPORT,分三块:Positive matches(哪些条件成立了)、Negative matches(哪些没成立、为什么没成立)、Exclusions(被显式排除的)。看 Negative matches 里那句 did not match due to ...,通常一眼就能看出是"类不存在"还是"已经存在同类型 Bean"。

第二个,Actuator 的 /actuator/conditions。 内容同上,但是 JSON,适合在测试环境抓下来和另一份对比。升级前后各抓一份 diff,行为变化一目了然。前提是别忘了 management.endpoints.web.exposure.include=conditions。

第三个,直接打断点看 BeanDefinition 数量。 在 ConfigurationClassPostProcessor 之后、finishBeanFactoryInitialization 之前插一个 BeanFactoryPostProcessor,打印某个类型的 BeanDefinition 名字列表。这一步能看到"条件求值时容器里到底有什么",比看最终结果有用得多——因为最终结果里,那个本该被跳过的 Bean 可能已经被别处创建了。

写出稳定的自动配置,靠测试而不是靠肉眼

前面这些坑有个共同特征:出错时不一定报错,而是"行为不符合预期"。这种问题靠 code review 抓不住,只能靠测试。

Spring Boot 提供了一套专门测自动配置的工具,叫 ApplicationContextRunner,属于 spring-boot-test 模块。它的价值在于:不启动完整应用,只加载你指定的自动配置类,然后断言容器里有什么、没有什么。启动一次几十毫秒,可以在单元测试里跑几十个条件分支。

class MyCacheAutoConfigurationTest {

    private final ApplicationContextRunner runner = new ApplicationContextRunner()
            .withConfiguration(AutoConfigurations.of(MyCacheAutoConfiguration.class));

    @Test
    void 用户没定义时_提供默认_CacheManager() {
        runner.run(ctx -> assertThat(ctx).hasSingleBean(CacheManager.class));
    }

    @Test
    void 用户已定义时_自动配置让步() {
        runner.withBean("cacheManager", CacheManager.class, ConcurrentMapCacheManager::new)
              .run(ctx -> {
                  assertThat(ctx).hasBean("cacheManager");
                  assertThat(ctx.getBean(CacheManager.class))
                          .isInstanceOf(ConcurrentMapCacheManager.class);
              });
    }

    @Test
    void 缺少依赖类时_整个配置不加载() {
        runner.withClassLoader(new FilteredClassLoader(Caffeine.class))
              .run(ctx -> assertThat(ctx).doesNotHaveBean(CacheManager.class));
    }
}

三个用例分别覆盖了三种最容易出问题的情况:没有用户覆盖、有用户覆盖、依赖类缺失。第三个用例里的 FilteredClassLoader 是关键——它能在类路径上"假装"某个类不存在,从而真正验证 @ConditionalOnClass 是否按预期工作。没有它,@ConditionalOnClass 的代码路径你实际上从没测过。

再补一个 withPropertyValues,就能覆盖 @ConditionalOnProperty 的各个分支。

这套写法的另一个好处是:它把"条件装配的预期"变成了可执行文档。半年后有人想改 @ConditionalOnMissingBean 的类型,测试会立刻挂。

顺带说一个企业项目里常被忽略的问题:多模块下的可见性。如果 A 模块是公共 starter,B 模块依赖 A,B 又通过父 ApplicationContext 引入了另一个同类型 Bean(比如 Spring Cloud 的场景),@ConditionalOnMissingBean 默认的搜索策略是 SearchStrategy.ALL,会往父容器里找。这意味着父容器有 Bean,你的自动配置就退让了——大多数时候这是对的,但在"父容器放的是基础设施 Bean、子容器想要独立实例"的场景下就是错的。要限制只查当前容器,得显式写:

@ConditionalOnMissingBean(value = CacheManager.class, search = SearchStrategy.CURRENT)

这个属性平时不用管,一旦项目里出现父子容器,它就成了必查项。

什么时候不该用条件装配

最后说点反向的话。条件装配很强大,但它是框架作者的工具,不是业务开发的工具。

判断标准很简单:如果这段代码在别人的项目里运行、并且不知道对方的上下文,那条件装配是对的;如果这段代码只在你自己的单体应用里跑、上下文完全已知,那条件装配大概率是在给未来的自己挖坑。

典型的滥用是"用配置开关控制 Bean 是否存在"。比如 @ConditionalOnProperty(name = "app.legacy.enabled") 决定某个 LegacyService 要不要注册。听起来灵活,实际后果是:编译期能看到这个类,运行时它不存在,注入处必须全部改成 ObjectProvider,测试要准备两套上下文,IDE 的"查找引用"也帮不上忙。这种场景用策略模式加运行时判断,比条件装配干净得多。

真正适合条件装配的只有三类:类路径探测(有没有某个依赖)、用户覆盖检测(有没有自己定义 Bean)、环境适配(Servlet 还是 Reactive)。这三类的共同点是——答案是框架该关心的,不是业务该关心的。

把这条边界守住,前面七个坑你大概只会踩到两三个。守不住,那就是无穷无尽的"为什么这个 Bean 没进来"。

一段参考配置,把上面几条正确做法串起来:

@AutoConfiguration(after = CacheAutoConfiguration.class)
@ConditionalOnClass(Caffeine.class)
@EnableConfigurationProperties(MyCacheProperties.class)
public class MyCacheAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(value = CacheManager.class, name = "cacheManager")
    public CaffeineCacheManager cacheManager(MyCacheProperties props) {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCacheSpecification(props.getSpec());
        return manager;
    }

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(name = "com.github.benmanes.caffeine.jcache.JCacheCacheManager")
    static class JCacheConfiguration {
        @Bean
        @ConditionalOnMissingBean
        JCacheCacheManager jCacheCacheManager() { return new JCacheCacheManager(); }
    }
}

几个细节都在里面了:@AutoConfiguration 而不是裸 @Configuration;类路径条件用 @ConditionalOnClass 贴在类上;@ConditionalOnMissingBean 显式写类型和名字;内部配置类带 proxyBeanMethods = false 且方法之间不互调;类名不确定的地方用 name 字符串而不是类字面量。

写 starter 的时候多花十分钟把这些捋顺,能省掉后面查"为什么自动配置没生效"的三天。