写手写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背后的逻辑,会清晰得像是自己写的一样。