1. 写在前面:这个注解到底在解决什么问题
先抛几个场景,你感受一下是不是遇到过。
第一类场景是做多环境部署。开发环境、测试环境、生产环境,某些组件只在特定环境启用,比如开发环境用内嵌缓存、生产环境用独立缓存服务。常规做法可能是在代码里写一堆if (env.equals("prod")),或者在启动时手动选择Profile,但一旦组件多起来,这种散落式的判断代码维护成本慢慢就上来了。
第二类场景是功能开关。产品经理说某个新功能先只对内部账号开放,后端需要根据某个配置项的值决定是否把对应组件加载进容器。这个需求看起来简单,但如果组件之间还有依赖关系,只靠@Autowired(required = false)这种弱依赖方案会很脆弱——你要的那个Bean确实不装配了,但其他依赖它的代码还在照常执行,不出问题才怪。
第三类场景是第三方SDK接入。同一个接口有多个实现,比如短信服务商A和B,配置里写了用哪家就装配哪家的客户端。手动用if-else去初始化,代码里全是分支,后来接新服务商的成本只会越来越高。
这三个场景的共同诉求是:需要一种“配置驱动”的Bean装配方式,让Bean是否进入Spring容器由外部配置文件决定,而不是由代码写死。@ConditionalOnProperty就是Spring Boot针对这类需求提供的开箱即用的解决方案。
简单说,这个注解会根据application.yml或application.properties中的某个配置项是否存在、值是否匹配,来决定要不要实例化和注册对应的Bean。
它的优势在于:
- 把“是否启用某个能力”从代码层上移到配置层,改配置不用改代码
- 与
@Configuration、@Bean配合时,能做到真正意义上的条件装配 - 是Spring Boot自动配置机制的重要基石,比如
spring-boot-autoconfigure里大量模块的开关都依赖它
这篇文章我不打算只讲API怎么用,那在官方文档里就能查到。我重点想说的是:这个注解的底层判断逻辑是什么、四个属性之间怎么配合、哪些组合会产生预料之外的行为、在真实项目里怎么设计才不至于把自己坑了。这些都是文档不会告诉你的东西。
2. @ConditionalOnProperty 的底层逻辑
2.1 它的本质是一个派生注解
先搞清楚定位。@ConditionalOnProperty的全限定名在org.springframework.boot.autoconfigure.condition包下,它本身标注了@Conditional(OnPropertyCondition.class)。
也就是说,@ConditionalOnProperty是@Conditional的派生注解,真正干活的是OnPropertyCondition这个条件类。Spring在解析配置类时,会读取@Conditional上指定的Condition实现,逐个调用matches()方法,只有返回true才会继续往下解析。这一套机制不局限于@ConditionalOnProperty,Spring Boot条件家族的其他成员如@ConditionalOnClass、@ConditionalOnBean、@ConditionalOnWebApplication走的是同一条链路。
理解了这层关系,你就能明白一个关键点:@ConditionalOnProperty不是Spring Framework原生提供的,而是Spring Boot在自动配置体系上做的封装。所以如果你的项目是纯Spring Framework(没用Spring Boot),这个注解是用不了的,得自己实现Condition接口做类似逻辑。
2.2 四个核心属性的判断规则
注解定义里共有四个可配置属性:prefix、name、havingValue、matchIfMissing。很多人对着源码能读出属性名,但真到组合使用时容易犯迷糊。我直接结合OnPropertyCondition的源码逻辑把判断流程梳理出来。
判断过程大致如下:
- 先从
Environment(也就是配置环境抽象)中根据prefix + name拼接出的完整属性名取值。比如prefix = "app.cache"、name = "enabled",那查的就是app.cache.enabled这个属性。 - 如果属性值不存在,走
matchIfMissing分支——matchIfMissing = true就直接匹配成功,否则匹配失败。 - 如果属性值存在,再看
havingValue是否给了目标值——如果没给(默认空串),要求属性值不等于false就算匹配成功,这是一种兼容设计;如果给了,就要求属性值(字符串形式)与havingValue完全相等才算匹配成功。
源码里的一个细节很容易被忽略:属性值可能会做trim()之后再去比较。也就是说,havingValue = "true"时,配置里写true和true(带空格)都能匹配上。这个细节小,但排查问题的时候能省不少时间。
2.3 默认行为为什么容易坑新手
matchIfMissing的默认值是false。这个默认值的含义是:如果你只写了@ConditionalOnProperty(name = "某个属性"),而这个属性在配置文件里没出现,那整个条件直接判定不通过,Bean不会被装配。
这个默认行为和很多人的直觉是反的——不少新手拿到这个注解,以为没配置就“默认启用”,结果Bean一直不生效,排查半天发现条件压根没匹配过。
再叠加一个Spring Boot版本差异:在Spring Boot 1.x时代,OnPropertyCondition的“空白属性值匹配”逻辑和2.x不一样,2.x里如果属性值存在但值是空字符串,会被视为“未匹配”,除非havingValue也为空且有特殊预设。这个历史包袱导致网上很多老教程里的写法照搬到新版本后行为不一致。我的建议很简单:不要依赖这种边界行为,显式配置永远比隐式约定可靠。
2.4 和其他条件注解的优先级关系
Spring Boot里的条件注解不止这一个,还有@ConditionalOnClass、@ConditionalOnBean、@ConditionalOnMissingBean、@ConditionalOnExpression等。它们可以组合使用,但组合时的判定顺序和“短路”逻辑可能让新手上头。
大体原则是:
- 同一个配置类上的多个条件注解之间是AND关系,全部满足才会加载
- 不同配置类之间的加载顺序受
@AutoConfigureBefore、@AutoConfigureAfter以及类名排序影响,条件注解本身的优先级不决定加载顺序 @ConditionalOnMissingBean这类注解的判定时机很敏感,它只能看到“当前已经注册”的Bean,后续配置类再注册的Bean它管不着,所以多个配置类之间的顺序如果控制不好,很容易出现“明明定义了Bean,条件还是说缺失”的诡异问题
换句话说,条件注解之间真正的优先级不是由注解本身决定的,而是由配置类的解析顺序决定的。这就像排队进场,条件注解只是检查你有没有票,但排在前面的人先进场,你后进场时前面的人已经参与判定了。
3. 四个属性逐个拆解:怎么配、为什么这么配
3.1 prefix:把属性归拢到同一个命名空间
prefix是属性的公共前缀,作用和包名类似,把一组相关的开关属性归拢到一个命名空间下,避免属性名冲突。
举个例子,一个系统里可能有“消息推送”和“短信通知”两个功能,二者都有enabled这个开关,如果不加前缀,配置里就会出现两个enabled,根本无法区分。加了前缀后,notify.push.enabled和notify.sms.enabled互不干扰,语义也清晰得多。
选前缀时的经验是:直接沿用项目里已有的配置命名空间,一般和业务模块名对应,不要另起一套和模块无关的抽象前缀。比如模块叫order,开关属性就叫order.something.enabled,而不是定义一个feature.toggle.order之类的绕弯前缀。配置文件的阅读成本也是开发成本,越直白越好。
另外注意,prefix和name拼接时中间自动加.,不需要你也跟着写一个。有些人不小心在prefix末尾或name开头加了点,结果拼接出来的属性名是app..cache.enabled这种多一个点的形式,配置里怎么都匹配不上。这种错误很隐蔽,报错又不会直说,只能靠日志里打印的实际属性名去核对。
3.2 name:可以写多个属性名
name是字符串数组类型,不是单个字符串。这意味你可以一次声明多个属性名,比如:
@ConditionalOnProperty(prefix = "app.feature", name = {"enabled", "available"})多个属性名之间是AND关系——所有列出的属性都必须满足条件,整体才算匹配。配置里只满足其中一个,条件还是不通过。
这种多属性写法一般不常用,但我遇到过一种合理的场景:某个功能要求“总开关打开 + 细分开关也打开”两个条件同时满足,而且两个开关都由配置中心动态管理。这时候用多属性声明比写两个@ConditionalOnProperty要内聚一些。不过要提醒一句:用多个条件注解时,如果后期需要调整某个属性的havingValue,多属性声明的方式会牵一发动全身,可读性也会下降。根据我的经验,大部分场景用单个属性作为开关就够用了,多属性声明不要为了炫技而用。
3.3 havingValue:比较的是什么类型的值
havingValue是目标匹配值,含义是“属性值等于这个值时条件成立”。它是个字符串,比较时也按字符串来比,不存在类型转换。
这句话背后的坑很实际。很多人以为havingValue = "1"能匹配配置里的数字1,实际上只要配置值能转为字符串“1”,那确实没问题,因为Spring会从Environment里拿到String类型的属性值做比对。但如果你在配置里写的是enabled: true(布尔型),而havingValue = "TRUE",大小写不一致,照样匹配不上。
havingValue = "true"和havingValue = "TRUE"在OnPropertyCondition里是走equalsIgnoreCase还是普通equals呢?直接说结论:源码里两处比较路径不一样,havingValue匹配逻辑用的是equals做精确匹配,不具备忽略大小写的能力。所以配置里写True而注解里写true,结果是匹配失败。
那为什么网上有人说不区分大小写呢?因为在某些特定场景下,Environment对布尔值的规范化处理会让true、TRUE、True都转成同一形态,但这属于环境层面的“偶然行为”,不是注解本身的保证。稳妥做法是:配置值和havingValue保持完全一致,统一小写true/false,别在大小写上偷懒。
还有一个细节:havingValue默认值是空字符串""。当你不指定havingValue时,条件匹配的语义是“属性存在且值不等于false”。这是个历史兼容设计,目的是让@ConditionalOnProperty(name = "xxx")既能按布尔开关理解,又能在老项目里兼容某些非布尔值。但空字符串这个默认值也意味着:如果你显式把havingValue设为空字符串,那和没设置效果一样,属于歧义写法,建议避免。
3.4 matchIfMissing:用默认值还是强制显式
matchIfMissing表示属性不存在时怎么办。默认false表示属性必须存在;设为true表示属性缺失时就算匹配通过,这是“默认开启”的开关语义。
用matchIfMissing = true时有一个隐藏风险:配置属性缺失是匹配通过了,但Bean装配成功后,进一步初始化依赖的配置值如果也缺失,就会在运行时报错,而且报错信息往往指向逻辑更深的地方。
举个例子,某个Bean用@ConditionalOnProperty(prefix = "app.push", name = "enabled", havingValue = "true", matchIfMissing = true)装配,但代码里@Value("${app.push.apiKey}")引用的属性没配。此时Bean装配成功,但实例化阶段就会因属性缺失直接启动失败。这比装配失败的问题更隐蔽——启动失败信息里先报“属性找不到”,不了解的人会去查属性配置,而不会想到是matchIfMissing把Bean放进来才触发后续解析的。
所以关于matchIfMissing,我个人的实践原则是:尽量不用。配置驱动的组件就应该要求配置显式存在,所有开关状态都白纸黑字写在配置文件里。真要用,也优先选择matchIfMissing = true表示“功能默认开”的业务场景,而不是为了省几行配置冒险。
4. 三种典型使用姿势与代码示例
4.1 用配置开关控制单个Bean是否注册
这是最基础的用法。假设项目里有一个定时任务组件,只在配置里显式打开时才装配:
@Configuration public class TaskSchedulerConfig { @Bean @ConditionalOnProperty(prefix = "app.task", name = "enabled", havingValue = "true") public ScheduledTaskRunner scheduledTaskRunner() { return new ScheduledTaskRunner(); } }配置文件:
app: task: enabled: true当enabled不是true或者缺失时,ScheduledTaskRunner不会进入容器。别人要用这个定时任务,只需要在配置里把开关打开,不需要懂任何装配细节。
这种用法适合“功能内聚、单独成Bean”的场景,但要注意:如果这个Bean是某个接口的唯一实现,而其他代码在它缺失时仍然@Autowired注入该接口,启动会直接报NoSuchBeanDefinitionException。所以开关控制的不只是Bean本身,还有依赖它的上下游。
4.2 根据配置选择不同实现类
这是“多实现切换”的典型场景。比如消息推送服务有短信和邮件两种实现,配置里通过app.notify.channel属性决定加载哪一套:
public interface NotifyService { void send(String message); } @Component @ConditionalOnProperty(prefix = "app.notify", name = "channel", havingValue = "sms") public class SmsNotifyService implements NotifyService { @Override public void send(String message) { // 短信实现 } } @Component @ConditionalOnProperty(prefix = "app.notify", name = "channel", havingValue = "mail") public class MailNotifyService implements NotifyService { @Override public void send(String message) { // 邮件实现 } }这里的核心点是:两个条件互斥,配置里channel只能取一个值,所以只会有一个实现类被装配。用@Autowired注入NotifyService接口时,容器里恰好只有一个候选Bean,不会报歧义异常。
但如果同事在配置里手误写成了channel: sms,带了一个空格,那就要看Spring的trim逻辑了——前面提到过,比较时带上了trim处理,这个场景反而能兜住。不过,真要出了奇奇怪怪的匹配失败,先把配置值肉眼核对一遍是省时间的排查路径。
还可以用missing这种“非havingValue”语义吗?答案是@ConditionalOnProperty本身不支持不等于。想表达“属性值不是某个值”时,得用@ConditionalOnExpression。比如:
@ConditionalOnExpression("${app.notify.channel:sms} != 'mail'")这种写法把“动态计算”交给SpEL表达式,灵活度更高,但可读性和静态扫描能力不如@ConditionalOnProperty。我的经验是,能用@ConditionalOnProperty精确表达的就不要用表达式,表达不了再说。
4.3 在自动配置类中与其他条件注解配合
Spring Boot的自动配置类里,@ConditionalOnProperty经常和@ConditionalOnClass、@ConditionalOnMissingBean搭配,形成一套“先看依赖、再看开关、最后看是否已有自定义Bean”的组合判定。
以一个自动配置模块为例:
@AutoConfiguration @ConditionalOnClass(RedisTemplate.class) @ConditionalOnProperty(prefix = "app.cache", name = "enabled", havingValue = "true") public class AppCacheAutoConfiguration { @Bean @ConditionalOnMissingBean public CacheManager cacheManager() { return new RedisCacheManager(); } }这个配置同时满足三个条件才生效:项目里存在RedisTemplate类(说明引入了Redis依赖)、配置开关打开、容器里还没有自定义的CacheManager。第三点尤其重要,它保证了使用方如果自己定义了CacheManager,自动配置的会退避,不覆盖用户代码。
@ConditionalOnMissingBean在这里的语义是“让位给用户自定义实现”,是自动配置的最佳实践之一。三个条件组合的先后顺序看似随意,其实有讲究:先@ConditionalOnClass是快速失败,不满足时连后面的配置都不解析;再@ConditionalOnProperty是业务开关控制;最后@ConditionalOnMissingBean是给扩展留口子。按这个顺序写,整个配置的意图非常清晰。
5. 用配置类组织开关:避免注解散落一地
实际项目中,如果把@ConditionalOnProperty直接标在各个业务组件上,时间一长你会发现一个尴尬的问题:开关属性分布在不同的类上,没有一个地方能全景看到整个系统有哪些开关、各自作用在哪个组件上。
更好的做法是把“开关判断”集中到配置类层面,业务类保持干净。比如做一个消息推送模块的自动装配:
@Configuration @ConditionalOnProperty(prefix = "app.notify", name = "enabled", havingValue = "true") public class NotifyAutoConfiguration { @Bean @ConditionalOnMissingBean public NotifyService notifyService() { return new NotifyService(); } @Bean public NotifySender notifySender(NotifyService notifyService) { return new NotifySender(notifyService); } }业务类NotifyService和NotifySender本身不需要标注任何条件注解,开关统一归口到NotifyAutoConfiguration上。这样排查问题时,只需要看配置类这一层就能理解整个模块的装配逻辑。
更进一步的实践是:把开关相关的属性整理成一组常量,在配置类和文档里共用。
public final class NotifyProperties { public static final String PREFIX = "app.notify"; public static final String ENABLED = PREFIX + ".enabled"; private NotifyProperties() { } }而后在@ConditionalOnProperty中直接引用:
@ConditionalOnProperty(prefix = NotifyProperties.PREFIX, name = "enabled", havingValue = "true")这样做的好处是:属性的命名空间和属性名是强约束的,Refactor时能跟着变,不会出现改了配置文件但注解里忘了同步的问题。十几个开关起步的项目里,这个习惯能省下不少无谓的排查时间。
6. 实操复盘:用配置驱动一次数据源切换
前面讲的概念都是“点”,这里完整走一遍“线”。我们做一个多环境数据源自动切换的例子。需求是这样的:
- 开发环境使用内存数据库,不依赖外置中间件
- 生产环境使用独立数据库服务
- 通过同一个配置项控制,切换环境的成本只是改一行配置
先定义一个数据源接口:
public interface AppDataSource { String getUrl(); }内存实现:
@Component @ConditionalOnProperty(prefix = "app.datasource", name = "type", havingValue = "embedded") public class EmbeddedDataSource implements AppDataSource { @Override public String getUrl() { return "jdbc:embedded:mem"; } }外置实现:
@Component @ConditionalOnProperty(prefix = "app.datasource", name = "type", havingValue = "external") public class ExternalDataSource implements AppDataSource { @Value("${app.datasource.external.url}") private String url; @PostConstruct public void validate() { if (url == null || url.isBlank()) { throw new IllegalStateException("External data source url must be configured"); } } @Override public String getUrl() { return url; } }配置文件里只需要一行:
app: datasource: type: embedded整套设计遵循了几个原则:
- 开关属性名用
type而不是enabled,更准确地表达“二选一”的语义 - 外置数据源把必填属性放在
@PostConstruct里校验,做到快速失败 - 两个实现互斥,注入接口时不会歧义
实际操作中,这个方案最常踩的坑是:开发环境切到生产后忘了把type改成external,导致外置数据源没装配,启动成功后应用却连上了内存库。这是典型“配对了条件、配错了业务”的例子,事后排查的速度完全取决于日志里有没有把当前生效的数据源类型打出来。
我的建议是:在这种多实现切换的装配逻辑里,加一个启动日志。
@Component public class DataSourceTypeReporter { @EventListener(ApplicationReadyEvent.class) public void report(ApplicationContext context) { AppDataSource dataSource = context.getBean(AppDataSource.class); System.out.println("Active data source type: " + dataSource.getClass().getSimpleName()); } }这一行日志能救命的次数比你想象的多得多。
7. 注解失效的常见场景:哪些情况条件会不生效
7.1 方法上标注时的行为边界
@ConditionalOnProperty标注在@Bean方法上时,条件只作用于该Bean;标注在@Configuration类上时,作用于整个配置类里所有Bean。两种方式可以混用,但要注意嵌套关系带来的预期偏差。
比如配置类本身条件不通过时,类里的@Bean方法即使各自标注了条件,也不会执行到——因为类级别的条件已经短路了。反过来,类级别条件通过,某个@Bean方法上的条件不通过,那这个方法不会装配,但不影响其他方法。
一个更隐蔽的边界:@ConditionalOnProperty标注在@Bean方法上时,如果方法有参数,Spring会尝试解析这些参数对应的Bean。条件不匹配时不会走到方法体,所以参数处理也不会触发。但这里有个“静态条件 vs 运行期条件”的差异——条件判定发生在Bean定义注册阶段,而参数解析在实例化阶段,两者不在同一个生命周期节点,概念上要分清。
7.2 同一个类里的内部 Bean 影响
在一个配置类中,如果你用@ConditionalOnProperty控制了某个Bean的装配,而另一个Bean的方法参数里声明了依赖前一个Bean,当前一个Bean缺席时,后一个Bean的实例化会直接报错。
示例:
@Configuration public class AppConfig { @Bean @ConditionalOnProperty(prefix = "app.cache", name = "enabled", havingValue = "true") public CacheManager cacheManager() { return new CacheManager(); } @Bean public CacheService cacheService(CacheManager cacheManager) { return new CacheService(cacheManager); } }如果app.cache.enabled没打开,CacheManager不装配,CacheService在实例化时找不到依赖,启动报错。这个问题的本质是:条件装配只决定了某个Bean是否注册,但依赖关系仍然强约束。
解决方案有几种。一是cacheService也加同样的条件,两个Bean同生共死。二是cacheService的方法参数加@Autowired(required = false),改造成可空依赖。三是把cacheManager改成接口,提供无缓存实现做兜底。哪种方案取决于业务对“缺失缓存”的容忍度,但最怕的是什么都不做,直接让启动失败,那等于条件注解没解决问题,反而引入了新问题。
7.3 @ComponentScan 全量扫描的干扰
用@ConditionalOnProperty做条件装配时,如果类上同时标注了@Component,它会被@ComponentScan扫描到,条件判定依然生效——Spring扫描到组件后,会检查上面的@Conditional条件,不满足就不注册。这个机制本身没问题,但有一个心智负担:你会觉得“这个类被扫描到了应该就会注册”,实际行为却是由配置决定的。
我建议在业务模块里统一用@Configuration+@Bean的方式做条件装配,组件类保持纯粹的@Component,不要在组件类上直接堆条件注解。原因很简单:@Configuration类本身也是一个Bean,但它承载的是“装配逻辑”的职责,条件注解放在装配逻辑上,语义更合理。
7.4 属性占位符解析的意外情况
@ConditionalOnProperty比较的是配置源里“原始”的属性值。如果你在配置里写的是${other.property}这种占位符引用:
app: feature: enabled: ${global.toggle}条件注解拿到的值可能是尚未解析的占位符文本,除非Environment在取值时已经完成了占位符替换。这个行为很依赖Spring Boot的配置处理顺序,我遇到过有些场景能解析、有些场景不能,非常不确定。
最稳的写法是:条件注解的havingValue只和字面值比较,不参与占位符解析。所有用占位符引用的属性,都不要放进条件判断里。这种“引用了别处属性”的配置,让条件逻辑变得不透明,排查问题时多一层跳转,不值得。
8. 启动时如何验证条件是否命中
条件装配最烦的一点是:它不报错。条件不满足,Bean就是静默地不存在,你能看到的只有后续依赖方报错。所以,一个合格的使用者必须主动给条件装配加上可观测性。
Spring Boot本身在/actuator/conditions端点(如果有spring-boot-starter-actuator)里会暴露所有自动配置的评估结果——哪些条件通过了、哪些没通过、原因是什么。这在排查自动配置相关问题时很好用,但对业务里自己定义的配置类,信息不一定足够细。
我的常用手段是,在配置类里主动打印匹配结果:
@Configuration @ConditionalOnProperty(prefix = "app.feature", name = "enabled", havingValue = "true") public class FeatureConfig { private static final Logger log = LoggerFactory.getLogger(FeatureConfig.class); @PostConstruct public void logActive() { log.info("Feature module is ACTIVE according to property app.feature.enabled=true"); } }配置类本身的条件不满足时,FeatureConfig不会被实例化,@PostConstruct不会执行,日志自然不出现。所以用这套逻辑能快速区分“模块没装配”和“模块装配了但初始化报错”两种情况。
再配合debug=true(日志级别调到DEBUG)时Spring打印的ConditionEvaluationReport,基本上能把条件判定的整个链路看穿。
9. 常见问题排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 条件始终不匹配,Bean未装配 | 属性名拼接错误,如prefix/name多写了一个点 | 在OnPropertyCondition处打断点,或查看日志里实际读取的属性值 |
| 配置里值存在但匹配失败 | 大小写不一致或前后有空格 | 检查havingValue与配置值的精确字符串,优先统一小写 |
| 属性缺失时Bean也装配了 | 误用了matchIfMissing = true | 审视业务是否真的需要默认开启,能省则省 |
| 多个条件注解同时用,整体没匹配 | 类级别的@ConditionalOnProperty短路了内部方法 | 检查类上条件是否满足,再排查方法级条件 |
| 依赖一个可能不装配的Bean导致启动失败 | 条件注解控制了一个Bean,但另一个Bean强依赖它 | 给依赖方补同样的条件,或把依赖改为可选/兜底实现 |
| 切换环境后模块行为没变化 | 配置文件名或Profile指定不对,属性没有真正加载 | 用Environment.getProperty()实际打印一下属性值 |
这个速查表看着简单,但每一条我都在项目里真实踩过。尤其是第一条“属性名多一个点”的问题,没有一次是看代码看出来的,全是靠日志里打印的Environment属性列表对出来的。
10. 扩展:自定义 Condition 与注解
@ConditionalOnProperty能满足大部分配置驱动的场景,但总有表达不了的时候。比如“属性A等于x且属性B不等于y”“当前时间在某个范围内才启用”。这种复杂逻辑要么堆成多个@ConditionalOnExpression,要么就得自己写。
自定义一个条件类并不难:
public class OnTimeRangeCondition implements Condition { @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { String start = context.getEnvironment().getProperty("app.timerange.start", "00:00"); String end = context.getEnvironment().getProperty("app.timerange.end", "23:59"); LocalTime now = LocalTime.now(); return !now.isBefore(LocalTime.parse(start)) && !now.isAfter(LocalTime.parse(end)); } }用法:
@Configuration @Conditional(OnTimeRangeCondition.class) public class TimeRangeFeatureConfig { }自定义条件的好处是逻辑完全受控,不受@ConditionalOnProperty属性模型限制;坏处是自己维护条件类的生命周期和判定结果的可观测性。如果只是简单的不等于判断,优先考虑@ConditionalOnExpression,至少它是Spring官方支持的SpEL方案,文档和社区案例更多。
把自定义条件和@ConditionalOnProperty结合用也是一种思路,比如一个配置类先判断开关是否打开,再判断是否满足运行期自定义条件:
@Configuration @ConditionalOnProperty(prefix = "app.gray", name = "enabled", havingValue = "true") @Conditional(OnTrafficPercentCondition.class) public class GrayFeatureConfig { }这种组合把“静态开关”和“动态条件”分离开,职责清晰,我很推荐。
11. 一些想补充的实战体会
关于@ConditionalOnProperty,最后想多说几句个人的体会和习惯,想到哪写到哪,不一定成体系,但都是我实际用下来觉得值得说道的:
第一,配置属性的命名要花心思。enabled本身是对“是否启用”的一种常见约定,但它表意有限。比如一个服务同时有“离线任务”和“实时任务”,用enabled和disabled表达,不如直接从“类型”或“模式”维度命名——app.job.mode: offline/online。属性名本身也是代码的一部分,好的属性名能让人一看到配置就明白整个模块的启用策略。
第二,条件注解是对外API的一部分。如果你的模块是给别人用的(比如公司内部的基础库),@ConditionalOnProperty的开关属性实际上就是一个外部配置契约。它一旦被依赖方使用,再修改属性名或改变matchIfMissing默认值,就可能造成别人的服务静默出问题。所以发布前最好检查一遍:开关的默认行为是什么、改了会不会影响存量用户。
第三,条件装配的逻辑不要藏太深。如果有人看代码时需要翻好几个类才能搞明白“这个Bean到底怎么才会被创建”,那说明这套装备逻辑的可维护性出了问题。用@Configuration类把开关集中暴露、命名直白、必要时加启动日志,都是为了减少这种认知成本。
第四,测试要覆盖条件两端的路径。@ConditionalOnProperty这块很容易被测试遗漏,因为测试配置一般会把开关打开,导致“关闭路径”没人跑。我建议至少有一个测试专门验证开关关闭时Bean确实不存在。可以用ApplicationContextRunner做轻量级测试,少启几次完整Spring容器,速度飞快,约束也足够。下面这个例子就是典型的写法:
@Test void shouldNotLoadBeanWhenPropertyMissing() { ApplicationContextRunner runner = new ApplicationContextRunner() .withUserConfiguration(FeatureConfig.class); runner.run(context -> { assertThat(context).doesNotHaveBean("featureService"); }); }条件注解最容易出现的问题不是“该开的时候没开”,而是“该关的时候没关”。这一行测试,能拦住最贵的线上事故。
这篇文章从底层原理讲到场景实践,再把坑和排查方法铺开,核心想传递的就一件事:@ConditionalOnProperty是个好工具,但它的“好”是建立在配置纪律之上的——显式、直白、可观测。把这三个词做到位,用它来管配置驱动的装配逻辑,就是一把顺手的好工具。