news 2026/10/16 4:58:09

手写Spring Boot Starter:从自动配置原理到条件注解实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写Spring Boot Starter:从自动配置原理到条件注解实战

写手写Starter这件事,最初我是被“魔法”逼的。每次往pom里塞一个依赖,重启应用,某个能力就跟变戏法一样出现了,Spring Boot帮你把一切细节藏得干干净净。这种体验用起来很爽,可真要自己动手时,心里就发虚:自动配置到底是怎么被触发的?为什么一个jar包放进去,类就被识别了?条件注解在启动阶段又是怎么被评估的?不懂这些,遇到“依赖放进来了但Bean没生效”的诡异问题,就只能瞎猜。后来我索性照着官方思路手写了一个小型Starter,从设计、写自动配置类、注册、调试到发布走通一遍之后,再回头看Spring Boot的启动流程,思路完全不一样了。这篇文章就把整个过程和踩过的几个坑一起放出来,适合已经能熟练写Spring项目、但还没拆过框架源码的开发者;如果你是团队里负责基础组件封装的人,这篇更值得读透。

1. Starter的本质:它不是“库”,而是一套约定好的装配流程

很多人分不清普通jar包和Spring Boot Starter的区别。普通jar包,比如一个工具包,它提供了类和静态方法,用的时候你手动new、手动调,一切都在你的掌控里;Starter则不一样,它除了提供类,还会替你“做决定”——当某些条件满足时,自动创建好对应的Bean,让你注入即用。

1.1 为什么这种自动装配让人觉得“神”

可以这样类比:普通jar包相当于你拿到一整套厨具,什么时候点火、什么时候放盐,都是你亲力亲为;Starter则等于一个自动烹饪机,你把食材放进去,按几个按钮,菜就自己做好了。它背后的魔法核心是Spring Boot的自动配置机制,它允许框架在启动阶段扫描一批预先注册好的“配置类”,然后根据当前项目的实际情况——比如类路径下有没有某个类、配置项里有没有某个属性——决定哪些配置类应该生效,哪些应该放弃。

最让我觉得值得深挖的一点是:Starter在项目里只是一个普通的依赖,但它承载的信息量远超一个普通jar包。它包含了配置属性定义、核心服务类、自动配置类,以及最重要的——一份“注册清单”,用来告诉Spring Boot启动时该去哪里找自动配置类。

1.2 什么时候你需要手写Starter

手写Starter的需求通常来自这几个场景:第一,公司内部有一个公共组件,比如统一日志、链路追踪、消息发送工具,希望各业务方引入一个依赖就能用,而不是复制几段初始化代码;第二,你自己封装了一个中间件,想让它与Spring生态自然整合,比如自定义一个分布式锁组件,希望自动从配置中心读取参数并创建客户端;第三,你纯粹想搞懂Spring Boot自动配置的底层机制,手写是最好的带着问题去读源码的方式。

刚开始我的想法很简单,直接复制代码到业务项目里不就行了?但复制代码的缺点在于:每个项目初始化逻辑不同,改一处要到处改;配置参数的集中管理也不一致。引入一个官方风格的Starter,可以让初始化逻辑收敛到一处,由框架按约定自动触发。

1.3 官方对Starter模块的划分逻辑

Spring Boot官方推荐的工程结构是这样的:一个Starter通常拆成两个模块,一个是“自动配置模块”,里面放所有逻辑代码,比如配置类、服务类、@ConfigurationProperties类;另一个是“Starter模块”,通常只依赖自动配置模块,里面不放代码,充当一个入口依赖。这么拆的目的是降低耦合:如果某个项目只需要自动配置,不想拉进额外依赖,它可以直接依赖自动配置模块;如果要把组件完整发布给别人,就让别人依赖Starter模块。

这个拆法对手写项目意义很大——我把所有逻辑放在trace-spring-boot-autoconfigure模块里,trace-spring-boot-starter模块只负责依赖前者。业务方不需要关心底层模块长什么样,只需要在pom.xml里加一行Starter依赖即可。

2. 启动期间发生了什么:自动配置是怎么被“选中”的

坦白说,第一次看到自动配置生效时,我最想知道的就是:Spring Boot在启动时靠什么找到这些配置类?它又凭什么决定让哪些配置类生效?这一节把这些机制拆开来看。

2.1 注册清单的读取:AutoConfigurationImportSelector做了什么

@SpringBootApplication里面有一个关键注解叫@EnableAutoConfiguration,这个注解通过引入AutoConfigurationImportSelector来完成自动配置类的装载。口说太虚,直接看流程:

启动时,SpringApplication完成环境准备后,会回调AutoConfigurationImportSelector;它会去classpath下扫描一个固定路径的文件——Spring Boot 3里是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports;在Spring Boot 2时代,这个路径是META-INF/spring.factories,里面的Key是org.springframework.boot.autoconfigure.EnableAutoConfiguration。

扫描到这份清单后,框架把里面的类都装载为AutoConfigurationEntry,再经过一系列过滤和排序,最终交给ConfigurationClassPostProcessor去解析。这些类会被当成普通的@Configuration配置类处理,但又有自己独立的执行顺序。整个过程可以用一句话概括:Starter只管注册,Spring Boot负责筛选和执行。

我看到身边有人误以为自动配置类是放在@ComponentScan扫描路径下才能生效,其实不是。自动配置类的定位不依靠组件扫描,而是依靠上面那份固定路径的清单文件;这也是为什么自动配置类的包路径即使与业务入口不同,也能被正确加载。

2.2 条件注解:决定“何时生效”的裁判

光把所有自动配置类加载出来还不够,一个Spring Boot项目里可能躺着上百个自动配置类,要是全都生效,内存和Bean冲突都是灾难。于是@Conditional系列注解就成了裁判。简单说,每个自动配置类上都会挂几个条件,只有条件全部满足时,这个配置类才被解析。

最常用的几个条件注解各有用途:

  • @ConditionalOnClass:当前classpath下必须存在指定类。比如你做了个Redis Starter,可以只在检测到某个Redis客户端类时才装配连接工厂。
  • @ConditionalOnMissingBean:当前容器中不存在指定Bean时才创建。这是给用户留后门,如果用户自己定义了同类型Bean,自动配置就让位。
  • @ConditionalOnProperty:读取配置项,符合指定值才生效。比如你配置了trace.enabled=true,才加载追踪组件。
  • @ConditionalOnWebApplication:当前应用是Web应用类型才生效。

这套条件机制最巧妙的地方在于@ConditionalOnClass的评估方式。它分两个阶段:第一阶段,Spring通过ASM读取类上的注解元数据,不需要真正加载目标类,因此你哪怕在条件里写了当前classpath压根不存在的类,也不会抛ClassNotFoundException;第二阶段,到了真正装配Bean的时候才会加载目标类,甚至还有name属性可以指定类名,进一步绕开类加载风险。

2.3 自动配置的顺序与用户自定义Bean的关系

自动配置类默认在用户配置之后被处理,这是Spring Boot有意为之:用户自己定义的Bean优先级高于自动配置。如果一个自动配置类因为条件判定通过,想要创建某个Bean,但用户在业务代码里已经声明了一个同类型的Bean,只要自动配置方法上带有@ConditionalOnMissingBean,它就会选择放弃,把机会让给用户的Bean。

这种设计思路类似“海归式协作”:框架先不出手,等用户自己搞定;用户没有,框架才提供默认实现。我建议在写自动配置类时,凡是对外暴露的核心服务Bean,都尽量加上@ConditionalOnMissingBean,这是给使用方留出口,否则别人想定制都没办法覆盖你。

3. 实战:手写一个“方法执行耗时追踪”Starter

理论说得再多,不如动手写一个。我以“方法执行耗时追踪”为场景,做一个名字叫trace-spring-boot-starter的组件,目标是:业务方引入依赖后,只要调用TraceService,就能自动打印方法耗时日志;配置项统一以trace.前缀管理。

3.1 工程结构:为什么用两个模块

动手前我先把工程拆成两个模块,集中到一个Maven父工程下:

trace-parent ├── trace-spring-boot-autoconfigure # 自动配置模块:核心逻辑 └── trace-spring-boot-starter # Starter模块:仅依赖自动配置模块

不少教程图省事,把自动配置类和Starter代码放在同一个模块里,这在小型测试项目里问题不大,但一旦公司里有多个组件,你会发现混在一个模块里很难维护。自动配置模块要尽量少引入业务依赖,因为它要承担的职责是“确定性装配”;而Starter模块相当于门面,依赖关系都在这里聚合。以后如果自动配置模块有代码变更,Starter模块几乎不用动。

3.2 第一步:定义配置属性类

配置属性类负责把application.yml里的trace.前缀配置映射成Java对象。我写了这样一个类:

package com.example.trace.autoconfigure; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "trace") public class TraceProperties { /** * 是否启用追踪 */ private boolean enabled = true; /** * 日志前缀 */ private String prefix = "[TRACE]"; /** * 超过多少毫秒才输出日志,默认0表示全部输出 */ private long thresholdMs = 0L; public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } public String getPrefix() { return prefix; } public void setPrefix(String prefix) { this.prefix = prefix; } public long getThresholdMs() { return thresholdMs; } public void setThresholdMs(long thresholdMs) { this.thresholdMs = thresholdMs; } }

这里有两个容易被忽略的细节。第一,@ConfigurationProperties需要配合@EnableConfigurationProperties或@Component才会被Spring管理,单纯加这个注解不保证生效;我放到自动配置类上统一开启。第二,以前流行@Component加在配置类上,这样配置类会被组件扫描发现,但自动配置场景下更标准的写法是在自动配置类上使用@EnableConfigurationProperties(TraceProperties.class),避免把配置类的装载方式绑定到扫描路径上。

3.3 第二步:核心服务TraceService

接下来写一个最简版的TraceService,代码不多,但足够说明问题:

package com.example.trace.autoconfigure; public class TraceService { private final TraceProperties properties; public TraceService(TraceProperties properties) { this.properties = properties; } public void trace(String methodName, Runnable action) { long start = System.nanoTime(); try { action.run(); } finally { long costMs = (System.nanoTime() - start) / 1_000_000; if (costMs >= properties.getThresholdMs()) { System.out.println(properties.getPrefix() + " method[" + methodName + "] cost " + costMs + " ms"); } } } }

这个类不需要加任何Spring注解。你可能会问,不加注解怎么被注入?答案是:由自动配置类里的@Bean方法去创建它。这也是自动配置场景中非常推荐的写法:核心服务类保持纯POJO,不依赖Spring的任何注解,测试起来方便,未来也可以直接手动构造使用。

3.4 第三步:编写自动配置类

自动配置类是整个Starter的心脏。我实现如下:

package com.example.trace.autoconfigure; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @AutoConfiguration @EnableConfigurationProperties(TraceProperties.class) @ConditionalOnClass(TraceService.class) @ConditionalOnProperty(prefix = "trace", name = "enabled", havingValue = "true", matchIfMissing = true) public class TraceAutoConfiguration { @Bean @ConditionalOnMissingBean public TraceService traceService(TraceProperties properties) { return new TraceService(properties); } }

逐行拆解一下为什么这么写:

@AutoConfiguration是Spring Boot 3引入的新注解,它替代了Spring Boot 2时代写在自动配置类上的@Configuration。这个注解专门标记“这是一个自动配置类”,让AutoConfigurationImportSelector能把它识别出来。如果你还在用Spring Boot 2.x,这里应该写@Configuration(proxyBeanMethods = false)。

@ConditionalOnClass(TraceService.class)表面上有点“多此一举”:TraceService本来就是本模块的类,类路径下肯定存在。但保持这个条件的意图是演示一个规范:自动配置类要尽量“宁缺毋滥”,当核心类不存在时不装配任何东西。假如后来有人把自动配置模块的依赖设为optional,那这个条件就能派上用场。

@ConditionalOnProperty(prefix = "trace", name = "enabled", havingValue = "true", matchIfMissing = true)表示:trace.enabled等于true时才生效,但如果用户完全没配置trace.enabled,也默认生效。matchIfMissing是个关键参数,没有它,用户不配置时组件反而不工作了,很容易造成“引入依赖即不可用”的困惑。

@ConditionalOnMissingBean放在@Bean方法上,给用户一个反悔机会。如果用户在自己代码里定义了TraceService类型的Bean,这个自动配置方法就跳过,用户自定义版本优先。

3.5 第四步:写注册清单文件

这一步最容易漏。直接在各模块里写完自动配置类没用,Spring Boot找不到它。根据我前面说的机制,必须在trace-spring-boot-autoconfigure模块的src/main/resources下新建固定路径文件:

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

文件内容只有一行:

com.example.trace.autoconfigure.TraceAutoConfiguration

注意这文件的编码和格式,千万别带BOM,也别写多个空行,否则Spring Boot解析时可能给出非常难排查的错误。这个路径针对Spring Boot 3;如果你做的是Spring Boot 2项目,则要写到META-INF/spring.factories里:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.trace.autoconfigure.TraceAutoConfiguration

只要这块注册文件写错,无论你的配置类代码多完美,启动后都是一个安静的无效果。

3.6 第五步:本地验证

我在Starter所在的父工程下执行mvn install,把模块发布到本地仓库。然后新建一个测试工程,pom里加入:

<dependency> <groupId>com.example</groupId> <artifactId>trace-spring-boot-starter</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency>

测试工程里写一个启动类,注入TraceService并调用:

@SpringBootApplication public class TraceConsumerApplication { public static void main(String[] args) { ConfigurableApplicationContext context = SpringApplication.run(TraceConsumerApplication.class, args); TraceService traceService = context.getBean(TraceService.class); traceService.trace("testMethod", () -> { try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); context.close(); } }

启动后控制台输出:

[TRACE] method[testMethod] cost 201 ms

这说明Bean被正确装配了。最开始的成就感并不来自于这一行日志,而是来自于我知道完整链路:注册清单文件被读取,TraceAutoConfiguration被解析,条件判断通过,TraceService被创建。

4. 手写过程中遇到的坑:条件不生效、属性不绑定、Bean被覆盖

如果只看上面的代码,会以为手写Starter很顺利,实际上我前几次实验踩了不少坑。这些坑都不复杂,但很典型,值得专门记录下来。

4.1 条件注解全写了,Bean却始终没被创建

有一次我把@ConditionalOnClass的类名写错,轮到排查时发现启动日志里的Negative matches一栏出现了自己写的自动配置类,下面标注的原因是ConditionalOnClass did not find required class。这类问题最大的迷惑点在于:应用能正常启动,只是某个Bean缺失,代码里一调getBean就报NoSuchBeanDefinitionException。

解决办法是先打开自动配置报告。方式很简单:测试环境运行时加上--debug参数,或者临时在配置里写debug: true。启动后Spring Boot会打印一段很长的“自动配置报告”,里面有Positive matches和Negative matches两栏,前者列出生效的自动配置,后者列出被否决的自动配置以及具体原因。排查这类问题,先看Negative matches里的原因,往往一眼就能定位。

有一点要提醒,debug: true在Spring Boot里相当于开启自动配置报告和部分调试日志,输出内容里可能包含配置项实际值,所以只建议在本地或测试环境用来排查,别开着上生产。

4.2 属性永远绑定成默认值,怎么查都查不出原因

这个坑出在@EnableConfigurationProperties的用法上。我第一次写的时候把@ConfigurationProperties放在TraceProperties上,又顺手加了@Component注解,同时自动配置类上也加了@EnableConfigurationProperties(TraceProperties.class)。结果项目能跑,但application.yml里配置的trace.prefix怎么改都不生效,日志里始终是默认前缀。

查下来发现,类被重复注册了。Spring里有两条路径都在管理同一个属性类,一个走组件扫描,一个走自动配置,二者对配置属性的绑定时机和处理方式有细微差别,最后表现成属性值能被读出来,但读取到的不是最新的配置。解决办法很干脆:属性类上不放任何Spring注解,把它变成纯POJO,只在自动配置类上一处开启@EnableConfigurationProperties。属性绑定入口保持单一,认知负担也小。

4.3 用户自定义Bean和我自动配置的Bean打起来了

后来我模拟一个使用方场景:使用方定义了自己的TraceService,覆盖默认实现。结果发现启动后容器里存在两个TraceService,注入时直接报冲突。原因也不复杂——我在自动配置方法上忘了写@ConditionalOnMissingBean。Spring Boot只遵从头上的条件,你没给让位逻辑,它就不会让。

处理办法很简单,给自动配置的@Bean方法加上@ConditionalOnMissingBean。这也让我重新理解了“自动配置”的本质:它不是霸道地把所有东西都初始化好,而是一套高度可退让的默认策略。条件设计得不好的Starter,用起来反而像一块石头;条件设计得好,使用方既享受开箱即用,又保留必要的定制空间。

5. 手写完Starter之后,值得做好的工程化细节

功能跑通只是第一步,真正让别人用起来舒服,还差几个工程化细节。这部分经验是我后面迭代组件时补上的,建议别省略。

5.1 命名规范和包结构

官方约定很清楚:SpringBoot官方提供的Starter叫spring-boot-starter-xxx,比如spring-boot-starter-web;你公司内部的Starter尽量不要叫这个格式,建议用xxx-spring-boot-starter,比如trace-spring-boot-starter。这么做既避免和官方命名冲突,也能在仓库里一眼辨认出自研组件。

包名方面,建议遵循com.公司域名.组件名.autoconfigure这类结构,保持与自动配置类路径一致。包名还要注意和Spring Boot自身的类包名区分开,别随便用org.springframework.boot打头,否则容易引起奇怪的问题。

5.2 配置属性不能只有Jetbrains踏雪无痕的提示

你可能注意到了一个细节:官方Starter在application.yml里写配置时,IDE会弹出字段提示和说明。这个体验来自配置元数据文件。要给自己的Starter生成这个文件,做法是在自动配置模块里引入:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency>

引入后在mvn compile时,它会扫描@ConfigurationProperties类,自动生成META-INF/spring-configuration-metadata.json。使用方一旦引入你的Starter,IDE就能智能提示配置键名和注释。这个细节我起初完全忽略,后来发现没有元数据的组件,使用方配置全靠猜,极不友好。

另外,属性类的字段注释建议写清楚,因为元数据文件会把这些注释带过去;如果你用了IDE生成,注释没写全,提示体验就很差。

5.3 Spring Boot 2.x 与 3.x 的注册兼容

如果你维护的组件还用于老项目,需要特别注意自动配置注册方式的变化。Spring Boot 2时代注册文件是META-INF/spring.factories,Spring Boot 3只认META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。如果直接把老项目升级到Boot 3,但Starter没有更新注册文件,自动配置类直接不生效。

我目前的做法是看目标版本:只支持Boot 3就用新路径;需要兼容Boot 2,则可以考虑同时放两份文件。不过我基本已经放弃双版本兼容了,因为Boot 2已进入生命周期末期,新组件直接按Boot 3标准写更省心。如果你的公司还有大量Boot 2存量项目,可以保留一份spring.factories用于老版本,同时更新AutoConfiguration.imports供Boot 3使用,但测试时要覆盖两条路径,避免出现配置类被重复加载的情况。

5.4 条件注解的“克制”原则

最后想聊一个设计层面的事:条件注解要克制地使用。我见过很多Starter,恨不得在自动配置类上堆满@ConditionalOnProperty加上各种开关,每个开关都按havingValue去判断。条件多了,表面上很灵活,实际上使用者根本记不住那么多开关,哪天配错一个值,组件就默默不生效了,且错误日志还不明显。

比较健康的做法是:组件有一个主开关,默认开启;核心服务Bean提供@ConditionalOnMissingBean让渡覆盖权;至于更细粒度行为,交给属性类去控制,而不是用一堆条件注解去开开关。条件注解只用来处理本质性的差异,比如“检测到某个可选依赖才装配”,而不是用来模拟功能开关。

写这个Starter花了我一天时间,但它带来的收益远不止那点代码量。以前看启动日志里那些auto-configuration报告,总觉得是框架内部信息;现在再看,我能立刻对应上背后的类、条件、注册清单,排查问题效率高了很多。如果你也在用Spring Boot却对自动配置一直半知半解,建议找个简单场景,从零写一个Starter,把注册文件、条件注解、配置绑定全部亲手过一遍。这套流程走完,你再看@SpringBootApplication背后的逻辑,会清晰得像是自己写的一样。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 4:58:06

鸢尾花数据集聚类分析实战:从KMeans到评估指标全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 4:57:34

MonoDepth2精读:自监督单目深度估计原理与复现全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 4:57:16

两串锂电池被动均衡方案:基于MP2642GR-Z与PIC24FV32KA302的实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 4:57:16

低显存跑大模型:airllm逐层卸载实现70B模型本地推理

四年前我第一次听说“把几十B参数的大模型塞进4GB显存”这个说法时&#xff0c;第一反应是“这怕不是标题党”。直到后来自己动手调 airllm&#xff0c;用一张老旧的入门级显卡跑通了 70B 级别的开源模型&#xff0c;才明白这件事在工程上是成立的。airllm 的核心思路不是把模型…

作者头像 李华
网站建设 2026/10/16 4:57:14

基于多尺度时序模式的家庭人员在位检测方法

简介&#xff1a;本资源是数据科学方向硕士毕业设计的完整实现代码包&#xff0c;面向Python中级学习者、智能楼宇与能源数据分析从业者&#xff0c;聚焦于利用家庭电力消耗数据无感检测住户占用状态这一实际问题。方案规避了传统PIR磁簧开关等硬件传感器的高成本与误报缺陷&am…

作者头像 李华
网站建设 2026/10/16 4:56:38

JDK8 HashMap源码拆解:散列、红黑树与扩容机制一次讲透

1. 先看清HashMap的底层结构&#xff1a;数组、链表、红黑树是怎么拼在一起的HashMap大概是Java容器里被面试官问得最多、被线上故障坑得最惨的一个类。我最早认真读它的源码&#xff0c;正因为某个跨平台系统的压测频繁出现CPU100%&#xff0c;抓完线程栈发现所有线程都卡在Ha…

作者头像 李华