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.factories:
org.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.0,spring.factories里的EnableAutoConfigurationkey 被彻底移除支持,不再读取,也不再警告——因为读都不读了,自然没有警告的机会。
这就是"静默失效"的由来。而我们那 7 个 starter,都是三年前建的,从来没人改过spring.factories;剩下 5 个之所以没事,是因为去年有同事升 2.7 时看到 WARN 顺手改了。
从源码看这个 key 是怎么被丢弃的
Spring Boot 2.7 里,自动配置类的加载在AutoConfigurationImportSelector中:
// Spring Boot 2.7.x protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) { // 新方式:读 META-INF/spring/....AutoConfiguration.imports List<String> 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 List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) { List<String> 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()); Enumeration<URL> urls = findUrlsInClasspath(classLoaderToUse, location); List<String> 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()) —— proxyBeanMethods=false 时会新建实例 // 正确写法:声明成方法参数,由容器注入上面那个单例 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。其他的比如:
| key | Spring Boot 3 是否还支持 | 说明 |
|---|---|---|
EnableAutoConfiguration | 移除 | 改用AutoConfiguration.imports |
ApplicationContextInitializer | 支持 | 仍在 spring.factories |
ApplicationListener | 支持 | 仍在 spring.factories |
EnvironmentPostProcessor | 支持 | 仍在 spring.factories |
FailureAnalyzer | 支持 | 仍在 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(required=false)本身在 Spring 6 有行为变化。
真正的转折点是打开自动配置报告。加上--debug启动参数(或者logging.level.org.springframework.boot.autoconfigure=DEBUG),Spring 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'我们在这份报告里搜TraceAutoConfiguration,Positive 和 Negative 里都没有。这就是关键信号:不是被条件过滤掉了,是根本没进候选名单。
条件没匹配 = 出现在 Negative matches;根本没加载 = 两边都找不到。这两种情况的排查路径完全不同,分清楚能省大量时间。这条经验我们已经写进 wiki。
另一个好用的验证手段是直接在代码里 dump 一下候选列表:
@Test void dumpAutoConfigurationCandidates() { List<String> 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.imports | 2.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) { // ... List<String> configurations = getCandidateConfigurations(metadata, attributes); configurations = removeDuplicates(configurations); // 这里去重 // ... }removeDuplicates就是往LinkedHashSet里过一遍,保序去重。所以过渡期的双份配置是安全的。
复盘数字
| 指标 | 数值 |
|---|---|
| 受影响 starter 数 | 7 / 12 |
| 故障发现方式 | 灰度环境 NPE(非启动报错) |
| 从灰度发现到根因定位 | 2 小时 40 分钟 |
| 其中浪费在翻启动日志上 | 约 1 小时 |
--debug报告定位耗时 | 8 分钟 |
| 实际修复耗时(7 个 starter) | 45 分钟(主要是加文件 + 发版) |
| 后续新增的防御手段 | 每个 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.enabled=true") .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 升级里踩过哪些"不报错但不生效"的坑?评论区聊聊。