
title: 升到 Spring Boot 3 那天7 个自研 starter 集体静默失效自动装配的 5 个加载边界tags: [Java, Spring Boot, 自动装配, 源码解析, 版本升级]category: Java 后端编译过了、启动过了然后 NPE我们去年做 Spring Boot 2.7.14 → 3.1.2 的升级。前期评估做得挺细JDK 从 11 升到 17、javax.*换成jakarta.*、几个第三方依赖找到了兼容版本。改了两天编译通过应用也正常起来了健康检查绿的。上灰度五分钟后第一个 NPE 来了java.lang.NullPointerException: Cannot invoke com.xxx.trace.TraceContextHolder.currentTraceId() because this.traceHolder is null at com.xxx.order.OrderController.create(OrderController.java:64)traceHolder是我们自研链路追踪 starter 里的 bean用Autowired(required false)注入的——所以容器里没有它也不报错启动时一切正常直到真正被调用。排查过程中越查越心凉不只是这一个。我们内部维护了 12 个 starter逐个验证下来有 7 个在 Spring Boot 3 里完全没有生效。启动日志里没有一行 WARN没有一行 ERROR就是安安静静地什么都没装配。根因spring.factories不再被读了我们的 starter 都是 Spring Boot 2 时代的标准写法resources/META-INF/spring.factoriesorg.springframework.boot.autoconfigure.EnableAutoConfiguration\ com.xxx.trace.TraceAutoConfiguration,\ com.xxx.trace.TraceWebMvcAutoConfigurationSpring Boot 2.7 起这种写法就被标记为废弃官方推荐改成resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件内容是纯文本一行一个全限定类名#开头是注释com.xxx.trace.TraceAutoConfiguration com.xxx.trace.TraceWebMvcAutoConfiguration2.7 是过渡版本两种方式都支持用spring.factories时会打一条 WARN。到Spring Boot 3.0spring.factories里的EnableAutoConfigurationkey 被彻底移除支持不再读取也不再警告——因为读都不读了自然没有警告的机会。这就是静默失效的由来。而我们那 7 个 starter都是三年前建的从来没人改过spring.factories剩下 5 个之所以没事是因为去年有同事升 2.7 时看到 WARN 顺手改了。从源码看这个 key 是怎么被丢弃的Spring Boot 2.7 里自动配置类的加载在AutoConfigurationImportSelector中// Spring Boot 2.7.x protected ListString getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) { // 新方式读 META-INF/spring/....AutoConfiguration.imports ListString configurations ImportCandidates .load(AutoConfiguration.class, getBeanClassLoader()) .getCandidates(); // 旧方式读 META-INF/spring.factories 里的 EnableAutoConfiguration configurations.addAll(SpringFactoriesLoader .loadFactoryNames(getSpringFactoriesLoaderFactoryClass(), getBeanClassLoader())); Assert.notEmpty(configurations, No auto configuration classes found in ...); return configurations; }两个来源都读合并返回——这就是过渡期的兼容处理。到 Spring Boot 3.0第二段被删掉了// Spring Boot 3.1.x protected ListString getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) { ListString configurations ImportCandidates .load(AutoConfiguration.class, getBeanClassLoader()) .getCandidates(); Assert.notEmpty(configurations, No auto configuration classes found in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. If you are using a custom packaging, make sure that file is correct.); return configurations; }只剩ImportCandidates.load。再往里看ImportCandidates// org.springframework.boot.context.annotation.ImportCandidates private static final String LOCATION META-INF/spring/%s.imports; public static ImportCandidates load(Class? annotation, ClassLoader classLoader) { Assert.notNull(annotation, annotation must not be null); ClassLoader classLoaderToUse decideClassloader(classLoader); // 拼出资源路径META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports String location String.format(LOCATION, annotation.getName()); EnumerationURL urls findUrlsInClasspath(classLoaderToUse, location); ListString importCandidates new ArrayList(); while (urls.hasMoreElements()) { URL url urls.nextElement(); importCandidates.addAll(readCandidateConfigurations(url)); // 逐行读忽略 # 注释和空行 } return new ImportCandidates(importCandidates); }逐行看几个要点第 2 行LOCATION文件名由注解的全限定类名拼出来。AutoConfiguration.class.getName()是org.springframework.boot.autoconfigure.AutoConfiguration所以最终路径是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。这个名字长得离谱手打必错我建议直接从官方 starter 的 jar 里复制。第 9 行findUrlsInClasspath用的是classLoader.getResources(location)会扫描所有jar。这意味着多个 starter 各自带一份同名文件不会冲突会全部被读到、合并。readCandidateConfigurations会跳过空行和#开头的行其余每行trim()后当作类名。不要在类名后面加逗号——spring.factories是逗号分隔的这个文件是换行分隔的加了逗号就变成类名的一部分会在后面抛ClassNotFoundException。这个我们真的踩了改的时候直接把逗号一起复制过去了。另外 4 个容易踩空的加载边界修完文件路径以为万事大吉结果又冒出来几个新问题。一并记下边界一AutoConfiguration取代了Configuration。Spring Boot 2.7 引入了AutoConfiguration注解3.x 里自动配置类应该用它// 旧写法Spring Boot 2.x Configuration(proxyBeanMethods false) ConditionalOnClass(TraceContextHolder.class) AutoConfigureAfter(WebMvcAutoConfiguration.class) public class TraceAutoConfiguration { } // 新写法Spring Boot 3.x 推荐 AutoConfiguration(after WebMvcAutoConfiguration.class) ConditionalOnClass(TraceContextHolder.class) public class TraceAutoConfiguration { }看AutoConfiguration的定义就明白它是个组合注解Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) Documented Configuration(proxyBeanMethods false) // 默认关掉方法代理性能更好 AutoConfigureBefore AutoConfigureAfter public interface AutoConfiguration { AliasFor(annotation Configuration.class) String value() default ; AliasFor(annotation AutoConfigureBefore.class, attribute value) Class?[] before() default {}; AliasFor(annotation AutoConfigureAfter.class, attribute value) Class?[] after() default {}; }注意proxyBeanMethods false是写死的不能改。这一点在迁移时坑过我们原来有个配置类里Bean方法互相调用依赖 CGLIB 代理保证单例。换成AutoConfiguration之后方法调用变成了普通 Java 调用同一个 bean 被创建了两次。正确的改法是把依赖改成方法参数注入让容器去解析AutoConfiguration public class TraceAutoConfiguration { Bean ConditionalOnMissingBean public TraceContextHolder traceContextHolder(TraceProperties props) { return new TraceContextHolder(props.getSamplingRate()); } Bean ConditionalOnMissingBean // 错误写法TraceInterceptor(traceContextHolder()) —— proxyBeanMethodsfalse 时会新建实例 // 正确写法声明成方法参数由容器注入上面那个单例 public TraceInterceptor traceInterceptor(TraceContextHolder holder) { return new TraceInterceptor(holder); } }边界二ConstructorBinding的位置变了。Spring Boot 3 里如果一个ConfigurationProperties类只有一个构造函数不需要再加ConstructorBinding而且这个注解从类级别移到了构造函数级别加在类上会直接报错ConstructorBinding is not supported on classes, use it on a constructor instead我们有 4 个 properties 类踩了这个好在这个是启动即报错比静默失效友好得多。边界三spring.factories的其他 key 还活着。这点很容易搞混。被移除的只有EnableAutoConfiguration这一个 key。其他的比如keySpring Boot 3 是否还支持说明EnableAutoConfiguration移除改用AutoConfiguration.importsApplicationContextInitializer支持仍在 spring.factoriesApplicationListener支持仍在 spring.factoriesEnvironmentPostProcessor支持仍在 spring.factoriesFailureAnalyzer支持仍在 spring.factories所以不要一看到spring.factories就全删了——我们有个同事就这么干了结果把一个EnvironmentPostProcessor也删掉了配置解密功能挂了又查了半小时。边界四AutoConfigureOrder与包扫描的关系没变但更容易撞车。自动配置类不应该被ComponentScan扫到。如果你的 starter 包路径恰好在主应用的扫描范围内比如都在com.xxx下配置类会被当成普通Configuration提前注册AutoConfigureAfter、ConditionalOnMissingBean的顺序保证全部失效。判断很简单自动配置类是兜底普通配置类是抢先。ConditionalOnMissingBean之所以能生效是因为自动配置在所有用户配置注册完之后才处理。一旦被 component scan 提前拉进来它就跟用户 bean 抢注册顺序行为完全不可预测。我们的规范是starter 的包名统一放在com.xxx.starter.*下和业务包com.xxx.biz.*隔开主应用的SpringBootApplication明确指定scanBasePackages com.xxx.biz。我们最初查错的方向这次排查最大的教训是过度信任启动日志。第一个小时我们一直在翻启动日志找线索。日志里干干净净——因为自动配置类压根没进候选列表连被 Condition 过滤掉这一步都没走到自然什么都不会打。我们甚至一度怀疑是Autowired(requiredfalse)本身在 Spring 6 有行为变化。真正的转折点是打开自动配置报告。加上--debug启动参数或者logging.level.org.springframework.boot.autoconfigureDEBUGSpring Boot 会打印CONDITIONS EVALUATION REPORT CONDITIONS EVALUATION REPORT Positive matches: ----------------- DispatcherServletAutoConfiguration matched: - ConditionalOnClass found required class org.springframework.web.servlet.DispatcherServlet Negative matches: ----------------- ActiveMQAutoConfiguration: Did not match: - ConditionalOnClass did not find required class jakarta.jms.ConnectionFactory我们在这份报告里搜TraceAutoConfigurationPositive 和 Negative 里都没有。这就是关键信号不是被条件过滤掉了是根本没进候选名单。条件没匹配 出现在 Negative matches根本没加载 两边都找不到。这两种情况的排查路径完全不同分清楚能省大量时间。这条经验我们已经写进 wiki。另一个好用的验证手段是直接在代码里 dump 一下候选列表Test void dumpAutoConfigurationCandidates() { ListString candidates ImportCandidates .load(AutoConfiguration.class, getClass().getClassLoader()) .getCandidates(); candidates.stream() .filter(c - c.startsWith(com.xxx)) .forEach(System.out::println); // 期望能看到自己的 7 个 starter看不到就是 imports 文件有问题 }这个单测我们后来加进了每个 starter 的构建流程任何 starter 发版前必须能在候选列表里看到自己从机制上杜绝再次静默失效。迁移方案对比方案兼容 2.x兼容 3.x维护成本评价A. 只保留spring.factories是否低不可行3.x 直接失效B. 只保留AutoConfiguration.imports2.7 才行2.6 及以下不识别是低若下游都已 ≥2.7推荐C. 两个文件都保留是全部 2.x是中要同步两份过渡期用D. starter 拆两个版本分支是是高多版本共存且差异大时才值得我们最后选了C 过渡三个月然后切 B。理由是内部还有 3 个业务线停在 Spring Boot 2.6短期升不动直接切 B 会把他们打挂但长期维护两份文件必然会漏同步改了一个忘了另一个比原来更难查。定了三个月的 deadline到期后统一切 B写进了中间件组的迭代计划。顺带说两个文件都保留时Spring Boot 2.7 会把同一个类读到两次。这不会出问题——AutoConfigurationImportSelector里有去重protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata metadata) { // ... ListString configurations getCandidateConfigurations(metadata, attributes); configurations removeDuplicates(configurations); // 这里去重 // ... }removeDuplicates就是往LinkedHashSet里过一遍保序去重。所以过渡期的双份配置是安全的。复盘数字指标数值受影响 starter 数7 / 12故障发现方式灰度环境 NPE非启动报错从灰度发现到根因定位2 小时 40 分钟其中浪费在翻启动日志上约 1 小时--debug报告定位耗时8 分钟实际修复耗时7 个 starter45 分钟主要是加文件 发版后续新增的防御手段每个 starter 一个候选列表单测最扎心的一组对比真正的修复只要 45 分钟定位却花了 2 小时 40 分钟。而如果第一时间就加--debug看条件评估报告定位能压到 10 分钟以内。升级类故障先看框架自己给的诊断输出再去猜。我的几点看法Spring Boot 大版本升级最危险的不是编译报错是静默降级。编译报错是显性的改完就完了spring.factories这种不报错但不生效的变更才要命。我现在做升级前会专门列一张静默变更清单哪些配置项被忽略了、哪些扩展点不再被读、哪些默认值变了。Spring Boot 官方的 Migration Guide 里这类内容其实都写了只是容易被required changes 那一大段淹没。自研 starter 必须有集成测试而且要用ApplicationContextRunner。这个类是 Spring Boot 官方给 starter 作者准备的测试工具能在不启动完整应用的前提下验证条件装配Test void traceHolderShouldBeRegistered() { new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(TraceAutoConfiguration.class)) .withPropertyValues(xxx.trace.enabledtrue) .run(context - { assertThat(context).hasSingleBean(TraceContextHolder.class); assertThat(context).hasSingleBean(TraceInterceptor.class); }); }但要注意withConfiguration(AutoConfigurations.of(...))是手工指定配置类它绕过了imports文件的加载。所以这个测试能验证配置类逻辑对不对验证不了配置类能不能被发现。两种测试都要有我们这次栽的正是后者。ConditionalOnMissingBean不要滥用。它的语义是用户没提供我才提供前提是自动配置在用户配置之后处理。如果你在普通Configuration里用它行为取决于 bean 定义的注册顺序非常不可靠。这个注解只应该出现在自动配置类里。什么时候不该写 starter如果这段代码只有一个应用会用直接写成普通Configuration放在应用里就行。starter 的价值在于多个应用复用 条件装配 可被覆盖为了一个使用方去搭一套 starter多出来的版本管理成本远大于收益。我们 12 个 starter 里事后复盘至少有 3 个属于这种过度设计。思考题如果一个 jar 里同时有spring.factories含 EnableAutoConfiguration和AutoConfiguration.imports且两个文件里列的类不完全相同在 Spring Boot 2.7 和 3.1 上分别会加载哪些AutoConfiguration强制proxyBeanMethods false。除了文中提到的Bean 方法互调会新建实例这个设定还带来了什么收益为什么官方要写死假设你的 starter 需要同时支持 Spring Boot 2.6、2.7、3.x 三个版本且 2.6 不识别 imports 文件、3.x 不识别 spring.factories除了文中的方案 C 和 D还有没有第三种做法你在 Spring Boot 3 升级里踩过哪些不报错但不生效的坑评论区聊聊。