ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot 自动装配背后的 Condition:条件注解原理与自定义 Starter 实战

Spring Boot 自动装配背后的 Condition:条件注解原理与自定义 Starter 实战 当年第一次用 Spring Boot我一度觉得这框架有点玄学。往 pom 里加一个spring-boot-starter-data-redis重启应用之后一个可用的RedisTemplate就自动出现在容器里了把依赖一删这些东西又悄无声息地消失。后来很多人管这种体验叫“自动装配魔法”实际上拆开看一点也不玄自动配置类从哪来是一套加载机制而决定它到底要不要生效的正是标题里写到的Condition——一个被不少开发者忽略、却掌握着整个自动装配生杀大权的判断器官。这篇文章不打算把自动装配从头到尾讲一遍只聚焦 Condition 这一个点但我会把它放回自动装配的完整链路里看。先梳理自动配置类是怎么被发现的再逐个过一遍常用的条件注解然后带大家写一个带开关的报警推送 starter最后把我在实际项目里排查“条件不生效”的几条路径整理出来。适合刚开始研究自动装配原理的读者也适合准备写业务型 starter 或公共组件的开发。1. 自动装配与 Condition先认清舞台再聊演员1.1 自动配置类是怎么被发现的一条固定流水线Spring Boot 的自动装配官方名字叫 Auto-configuration起点是启动类上的SpringBootApplication。这个注解实际上是个组合注解里面藏着SpringBootConfiguration、ComponentScan以及最关键的EnableAutoConfiguration。EnableAutoConfiguration通过Import引入了一个AutoConfigurationImportSelector。这个 Selector 干的事可以简单概括成四步读取 classpath 下所有固定位置的配置文件。Spring Boot 2.7 之前是META-INF/spring.factories2.7 及以后换成了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。把文件里声明的自动配置类全部加载出来。根据EnableAutoConfiguration上的 exclude、以及全局的spring.autoconfigure.exclude配置做一轮排除。排序之后批量注册进 Bean 容器。注意自动配置类不是普通组件它一般不会被ComponentScan扫到否则到处乱扫就全乱套了。它走的是独立的注册通道。这也是为什么很多人在自己项目的包结构里怎么都扫不到RedisAutoConfiguration因为它本来就不在你的代码包里而是从第三方 jar 包里被SpringFactoriesLoader按名单“拎”出来的。1.2 为什么非要有 Condition自动装配不能是无脑装配如果所有自动配置类都被无脑注册后果相当严重。classpath 里同时有 Tomcat 和 Netty 怎么办同时有多个DataSource候选怎么办用户自己定义了RestTemplate自动配置还要不要再来一个默认的答案是全靠条件决定。以RedisAutoConfiguration为例它的类声明上挂着ConditionalOnClass(RedisOperations.class)——类路径下真的存在 Redis 客户端相关类才往下走方法上又配合ConditionalOnMissingBean保证用户如果自己声明了RedisTemplate自动配置就自动“退位让贤”。这一整套判断逻辑就是 Spring Boot 的条件评估Condition Evaluation。我特别喜欢用一个比喻自动配置类像仓库里已经准备好的半成品零件Condition 是质检员环境满足什么条件质检员就放哪一批零件上流水线其余的在仓库里继续躺着。理解了这一点你就知道“依赖一变容器里的组件跟着变”这件事不是魔法而是一套运行时的决策系统。这里顺带回应一个搜索里经常出现的问题为什么引入spring-boot-starter-actuator之后HealthEndpoint、MetricsEndpoint这些监控端点会自动冒出来因为自动配置类在 Web 环境下会额外注册一组 Web 端点在非 Web 环境下只注册 JMX 端点这一步就是ConditionalOnWebApplication在起作用。Spring Boot Admin 这类监控运维工具能做到“开箱即用”底层也是同一套逻辑。1.3 Condition 的出生时间Spring 4 就有了Condition 并不是 Spring Boot 发明的。Spring Framework 4.0 就提供了底层的Conditional注解和Condition接口Spring Boot 只是在其上做了一批语义明确的扩展注解比如ConditionalOnClass、ConditionalOnProperty等等。所以你写自定义 starter 时最底层也只是实现Condition接口那些ConditionalOnXxx是帮你省事的语法糖。还有一个容易被忽略的细节条件判断发生在 Bean 定义注册之前而不是实例化之后。容器在决定“要不要这条 BeanDefinition”的时候条件已经给出了 yes/no根本不会走到实例化那一步。这也是为什么条件判断的运行成本很低几十上百个自动配置类一层层判断下来对启动时间的影响几乎可以忽略不计。2. 条件注解全家桶从速查到自研2.1 高频 Conditional 注解速查表常用注解其实不多我整理成一张表方便对照注解作用典型使用场景ConditionalOnClass/ConditionalOnMissingClass类路径存在/不存在指定类时才生效判断第三方依赖是否引入ConditionalOnBean/ConditionalOnMissingBean容器存在/不存在指定 Bean 时才生效用户自定义优先于自动配置ConditionalOnProperty配置属性满足条件才生效功能开关、多环境切换ConditionalOnExpressionSpEL 表达式为 true 才生效多个条件的复杂组合ConditionalOnWebApplication/ConditionalOnNotWebApplication是否 Web 应用环境端点、拦截器、Web 配置ConditionalOnJavaJava 版本满足才生效不同版本加载不同实现ConditionalOnResource类路径存在指定资源才生效配置文件、静态资源存在性判断单独说下ConditionalOnClass它有两种写法value XXX.class这种类字面量形式和name com.xxx.XXX这种全限定名形式。Spring Boot 对name形式会走 ASM 字节码扫描不会触发类加载所以哪怕类不存在也不会抛NoClassDefFoundErrorvalue形式则要求类能正常加载否则直接跳过。写自定义 starter 时建议尽量用name形式避免因为依赖缺失导致类加载问题。ConditionalOnProperty是日常开发里最常用的开关注解。比如ConditionalOnProperty(name alarm.sms.enabled, havingValue true, matchIfMissing false)意思是配置项alarm.sms.enabled的值必须等于true才生效而且配置项缺失时默认不生效。matchIfMissing这个属性特别容易踩坑后面实战部分我还细说。2.2 自己实现 Condition 接口把匹配逻辑攥在手里官方注解覆盖了大部分场景但总有覆盖不到的业务条件。比如“只有服务器操作系统是 Linux 时才启用某个 Bean”这时候就得自己实现Condition接口。public class OnLinuxCondition implements Condition { Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { Environment environment context.getEnvironment(); return environment.getProperty(os.name, ).toLowerCase().contains(linux); } }ConditionContext能拿到的资源十分关键Environment拿环境配置BeanDefinitionRegistry检查容器注册情况ResourceLoader加载资源ClassLoader做反射。AnnotatedTypeMetadata则能拿到当前类或方法上的注解信息这为自定义注解传参打下了基础。下面是自定义条件注解的标准姿势Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) Conditional(OnLinuxCondition.class) public interface ConditionalOnLinux { }之后在任意配置类或Bean方法上标记ConditionalOnLinux即可。有一点必须提醒Conditional注解可以写多个条件类比如Conditional(A.class, B.class)它们之间是AND关系也就是全部通过才算通过没有“或”的语义。想实现 OR 关系要么用 SpEL要么把多个条件合并到一个Condition实现里。2.3 条件注解真正的副作用它会影响 Bean 注册顺序的确定性看源码时我会特别注意条件注解背后的顺序关系。以ConditionalOnMissingBean为例它看起来只是在判断容器里存不存在某个 Bean但这个判断容不容易误判取决于用户自定义 Bean 是否已经先注册完成。Spring Boot 之所以能保证“用户自定义 Bean 优先于自动配置”是因为自动配置类的导入时机被刻意安排在了用户 Bean 注册之后。这是一个靠“固定顺序”换来的确定性而不是条件本身会随机应变。写 public 组件时千万不要依赖“自动配置一定会先执行”这种假设后面我会在常见问题里展开讲。3. 实战从零写一个报警推送 starter把“开关”交给配置3.1 需求与设计思路假设公司内部要做一套报警推送同时支持短信、邮件、钉钉机器人三种渠道。不同客户部署环境还不一样有的只开短信有的只开钉钉有的全开。如果这些渠道直接写死在业务代码里每部署一个客户就得改一次代码如果全部无条件注入不用的渠道也会初始化连接、白白浪费资源。用自动装配加 Condition 可以很优雅地解决做一个独立的报警 starter引入 jar 包后什么都不用动只在配置文件里写几个开关容器里就自动出现对应的AlarmSender实现。我的设计决策是alarm.enabled是总开关默认关闭短信、邮件、钉钉各自再有一个独立开关也默认关闭。这样防止新接入的项目一引入依赖就莫名其妙开始发报警。3.2 配置属性类与消息发送接口先定义一个统一发送接口public interface AlarmSender { void send(String message); }短信、邮件、钉钉分别实现这个接口比如SmsAlarmSender、MailAlarmSender、DingTalkAlarmSender。每个实现里负责各自的 API 调用逻辑这里不展开。然后是配置属性类ConfigurationProperties(prefix alarm) public class AlarmProperties { private boolean enabled false; private Sender sms new Sender(); private Sender mail new Sender(); private Sender dingtalk new Sender(); public static class Sender { private boolean enabled false; // getter、setter 略 } // getter、setter 略 }ConfigurationProperties的 prefix 指定为alarm配置项就能直接对应到alarm.enabled、alarm.sms.enabled、alarm.mail.enabled等层级。3.3 自动配置类怎么写顶层开关加分渠道开关自动配置类承担的是“编排”工作各个渠道再各自写独立配置类方便裁剪。示例代码AutoConfiguration ConditionalOnProperty(name alarm.enabled, havingValue true, matchIfMissing false) EnableConfigurationProperties(AlarmProperties.class) public class AlarmAutoConfiguration { Configuration(proxyBeanMethods false) ConditionalOnProperty(name alarm.sms.enabled, havingValue true, matchIfMissing false) static class SmsAlarmConfiguration { Bean ConditionalOnMissingBean public AlarmSender smsAlarmSender(AlarmProperties properties) { return new SmsAlarmSender(properties); } } Configuration(proxyBeanMethods false) ConditionalOnProperty(name alarm.mail.enabled, havingValue true, matchIfMissing false) static class MailAlarmConfiguration { Bean ConditionalOnMissingBean public AlarmSender mailAlarmSender(AlarmProperties properties) { return new MailAlarmSender(properties); } } Configuration(proxyBeanMethods false) ConditionalOnProperty(name alarm.dingtalk.enabled, havingValue true, matchIfMissing false) static class DingTalkAlarmConfiguration { Bean ConditionalOnMissingBean public AlarmSender dingTalkAlarmSender(AlarmProperties properties) { return new DingTalkAlarmSender(properties); } } }用到的AutoConfiguration是 Spring Boot 2.7 以后提供的专用注解等价于Configuration(proxyBeanMethods false)并且补齐了自动配置类特有的顺序处理能力。2.7 之前写普通Configuration也能注册但既然新版本提供了专用注解新代码建议直接用它。注意每个渠道的Bean上都挂了ConditionalOnMissingBean这意味着使用者如果自己定义了一个AlarmSender自动配置里的就不再生效。这是组件开发的一个基本原则给用户留后门永远不要和用户抢 Bean。3.4 注册自动配置类spring.factories 与 imports 文件自动配置类写完后关键一步是注册。不同版本写法不同。Spring Boot 2.7 之前在src/main/resources/META-INF/spring.factories里声明org.springframework.boot.autoconfigure.EnableAutoConfiguration\ com.example.alarm.AlarmAutoConfigurationSpring Boot 2.7 及之后改用META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importscom.example.alarm.AlarmAutoConfiguration为什么要改spring.factories是 Spring Framework 的通用扩展机制很多组件都在用自动配置塞进去之后文件越来越臃肿而且不好追踪来源。换成了专用的 imports 文件后自动配置的加载路径就非常清晰了。到了 Spring Boot 3.xspring.factories里的自动配置键已经彻底不再被支持自定义 starter 必须要迁到 imports 文件。如果组件既要兼容 2.3.x、2.6.x又要兼容 3.x旧项目里两种文件可以同时存在旧版本读spring.factories新版本优先读 imports。3.5 实测效果开关一拨Bean 说变就变验证方法说起来特别简单把 starter 打包后引入到测试项目配置里加上alarm: enabled: true sms: enabled: true启动之后拉一个ListableBeanFactory出来数一数容器里只有一个SmsAlarmSender的 Bean。再把alarm.sms.enabled改成false重启后它立刻消失。整个过程不需要改任何 Java 代码这就是“把开关交给配置”的价值。调试阶段我一般建议用 IDEA 打开启动日志配合debugtrue看自动配置报告完全能看清每个条件匹配或不匹配的原因。社区版也完全够用不一定非得依赖付费版这不是 IDE 功能差异而是 Spring Boot 本身的日志能力。4. 实战排查实录条件不生效时我从哪几个方向找问题4.1 第一反应打开 ConditionEvaluationReport条件不生效的时候最忌讳的就是盯着代码瞎猜。Spring Boot 其实已经把答案写到了日志里只要在配置文件里加上debugtrue启动时就会输出两份报告Positive matches匹配成功的自动配置和Negative matches匹配失败及其原因。比如你发现自己的自动配置类没有生效去 Negative matches 里一搜通常会看到类似AlarmAutoConfiguration: Did not match: - ConditionalOnProperty (alarm.enabledtrue) found no property alarm.enabled原因一目了然要么配置没写要么开关没开。这是排查条件问题的第一站先看报告再动代码效率能高一倍。4.2 ConditionalOnMissingBean 失效的两种典型姿势第一个坑是 Bean 注册顺序的问题。大多数情况下自动配置类在用户 Bean 之后导入所以ConditionalOnMissingBean能正确看到用户自定义的 Bean。但如果你在自动配置类里通过Import把用户配置类塞进了自动配置链路顺序就全乱了——用户 Bean 还没注册完条件已经判断完了结果自动配置照样生成了一个默认 Bean。第二个坑是搜索策略。ConditionalOnMissingBean有一个searchStrategy属性默认是SearchStrategy.ALL会搜索包括父容器在内的所有 Bean如果某些场景下希望只查当前容器就得显式指定。跨容器继承的组件最容易在这上面翻车。注意如果你的条件涉及 Bean 是否存在尽量做到“只读不改”。不要在条件判断里去BeanDefinitionRegistry里注册新 Bean这会让顺序推断彻底变成谜。4.3 配置 key 拼错或层级写错条件永远不满足ConditionalOnProperty(name alarm.sms.enabled, havingValue true)这种写法让我在同事项目里看到过好几次翻车现场。配置文件里写的不是alarm.sms.enabled而是alarm.sms.Enabled或者 yml 缩进错了把sms.enabled挂到了alarm的上一层Spring 直接认为属性不存在条件静默不匹配。这类问题排查起来也不难debugtrue报告中会明确写出“found no property alarm.sms.enabled”。但更推荐的做法是在 IDEA 里给ConfigurationProperties配上spring-boot-configuration-processor依赖这样 IDE 会为你的属性生成元数据提示写配置时直接有补全和校验从源头把拼写错误掐掉。4.4 类名判断的隐患字符串临时拼错但没有报错ConditionalOnClass(name com.example.SomeClass)这种写法不会因为类不存在而抛异常最多导致自动配置不生效。但它的缺点也随之而来如果某个类全限定名拼错了系统不会给你任何编译时的警告只会安静地跳过配置。所以在自动配置类上我习惯优先用ConditionalOnClass(SomeClass.class)这种类字面量形式。它至少有编译期的类型检查只有在引入依赖类可能导致类加载问题的场景下才使用name形式并让测试覆盖到位。4.5 版本迁移的坑2.3.x、2.6.x 到 3.x从 2.3.x 升级到 2.6.x 时有个非常经典的坑Spring Boot 2.6 默认开启了循环引用检测spring.main.allow-circular-referencesfalse。很多组件之间的自动配置互相依赖时以前能启动升级后启动直接报循环引用错误。排查方法是在配置里临时设置allow-circular-referencestrue然后重点去找那些“你中有我、我中有你”的自动配置类。从 2.6 到 2.7 再到 3.x最大的变化就是自动配置注册方式。2.7 引入 imports 文件3.0 不再从spring.factories里加载自动配置。如果接手老项目第一件事就是去看spring.factories里有没有EnableAutoConfiguration键有的话老老实实迁移到 imports 文件。另外 3.x 全面切换到 Jakarta 命名空间javax.*改成jakarta.*所有自定义 starter 代码都得跟着改。5. 写条件配置我沉淀下来的几个习惯5.1 开关默认关闭宁可不开也不能误开我见过的绝大多数报警类、通知类、推送类组件都应该把默认值设为关闭。matchIfMissing false的含义是“配置文件里没写就当没开”。这么做的底气在于安全默认值不会在用户刚引入依赖时制造意外比如一启动就发了一堆测试短信出去。功能类组件可以默认开但凡是会产生外部效应的组件都建议“显式开启”。5.2 一个条件只做一件事组合要克制一旦条件逻辑变得复杂排查成本会直线上升。比如“既判断类路径、又判断配置、还要看某个 Bean 是否存在”这种多条件组合虽然 Spring 支持你叠加多个ConditionalOnXxx但阅读时人脑的负担会翻倍。推荐的做法是条件尽量单一组合条件浓缩到自定义注解里并取个好名字。比如ConditionalOnAlarmSmsEnabled一看就懂三个注解叠在一起还得一个个猜。5.3 别把重量级逻辑塞进 matches 方法Condition.matches()在启动阶段就会被调用而且是在早期阶段。如果在这个方法里做网络请求、读文件、甚至查数据库轻则拖慢启动速度重则因为依赖尚未初始化而直接抛异常。条件判断应该是纯计算看配置、看类路径、看容器注册情况仅此而已。真正复杂的初始化逻辑放到Bean方法里、放到PostConstruct里那才是它该待的地方。5.4 用 ApplicationContextRunner 做自动配置的单元测试自动配置类很难通过普通的SpringBootTest去测试因为整个应用环境一上来条件判断就被各种因素干扰了。Spring Boot 提供了一个专门用于验证自动配置的测试工具ApplicationContextRunner。new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(AlarmAutoConfiguration.class)) .withPropertyValues(alarm.enabledtrue, alarm.sms.enabledtrue) .run(context - { assertThat(context).hasSingleBean(AlarmSender.class); });这段测试会独立起一个微缩容器只加载你自己指定的自动配置类条件、属性都可以人为控制。我写 starter 时几乎每个自动配置类都会配一组这样的测试开开关、关关把“Bean 存在与否”钉死在测试用例里。比起每次都在完整应用里重启验证这套方式既快又稳。最后再分享一个小技巧条件不生效时不要只盯着自己的配置类代码先开debugtrue把 Positive matches 和 Negative matches 从头到尾翻一遍。我刚接触自动装配的前几个月有一半的疑问都是在这份报告里找到答案的。用起来你就知道了比对着源码猜半天舒服得多。
返回列表