三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

升到 Spring Boot 3 那天,7 个自研 starter 集体静默失效:自动装配的 5 个加载边界

升到 Spring Boot 3 那天,7 个自研 starter 集体静默失效:自动装配的 5 个加载边界

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.TraceWebMvcAutoConfiguration

Spring Boot 2.7 起,这种写法就被标记为废弃,官方推荐改成:

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

文件内容是纯文本,一行一个全限定类名,#开头是注释:

com.xxx.trace.TraceAutoConfiguration com.xxx.trace.TraceWebMvcAutoConfiguration

2.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。其他的比如:

keySpring 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'

我们在这份报告里搜TraceAutoConfigurationPositive 和 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.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) { // ... 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 个属于这种过度设计。

思考题

  1. 如果一个 jar 里同时有spring.factories(含 EnableAutoConfiguration)和AutoConfiguration.imports,且两个文件里列的类不完全相同,在 Spring Boot 2.7 和 3.1 上分别会加载哪些?
  2. @AutoConfiguration强制proxyBeanMethods = false。除了文中提到的"@Bean 方法互调会新建实例",这个设定还带来了什么收益?为什么官方要写死?
  3. 假设你的 starter 需要同时支持 Spring Boot 2.6、2.7、3.x 三个版本,且 2.6 不识别 imports 文件、3.x 不识别 spring.factories,除了文中的方案 C 和 D,还有没有第三种做法?

你在 Spring Boot 3 升级里踩过哪些"不报错但不生效"的坑?评论区聊聊。

← 返回列表