Spring Boot项目一多,“重复造轮子”这件事就特别扎眼。你可能也遇到过这种情况:每个新服务落地时,都要把同一套配置类、同一个拦截器、同一份工具方法从老项目里拷贝过来,然后改包名、调参数、踩一遍之前踩过的坑。我一开始也这样做,直到有一天发现三个项目里维护着三份几乎一模一样但又各有“微妙差异”的配置代码,才下定决心把这些公共能力抽成一个自定义Starter。这篇文章就把我做这件事的全过程、原理和踩过的坑一次性说清楚,代码可以直接抄。
1. 为什么需要自定义Starter:从三套重复代码说起
自定义Starter听起来像个高大上的“框架设计”,其实本质特别朴素:它就是把“某个通用能力”连同它的自动配置逻辑打包成一个普通依赖,让任何Spring Boot项目引入之后,配置几个参数就能直接使用。短信发送、接口幂等校验、操作审计日志、分布式锁、内部RPC调用封装,这些都是适合做成Starter的典型场景。
当时最让我头疼的其实就是“重复三遍”这件事本身。第一遍写配置类的时候,觉得没什么;第二遍复制粘贴改个包名,也还能忍;到第三遍再复制时,已经有细微改动了——比如A项目里校验逻辑是白名单优先,B项目里改成了黑名单优先,C项目又加了缓存逻辑。到一个不熟悉代码的同事接手一个老项目时,根本分不清这份配置到底哪个版本是对的,线上出了诡异问题只能一行行对代码。这种“复制粘贴导致的版本漂移”,远比写一份新代码更容易出事故。
还有一个很现实的问题:升级能力时得同时改多个仓库。比如某个公共拦截器需要增加一个链路追踪参数,你得在三个项目里各改一遍,发三次验证。如果有一个公共Starter,改完发布一个新的版本号,下游项目升个依赖版本就结束了。对团队来说,这是从“人肉同步”到“版本治理”的转变,省下来的是实打实的开发时间。
那为什么不直接建一个公共模块,让所有项目把源码依赖进来就行?这个方案在常驻项目少、团队规模小的情况下勉强能用,但项目一多就会出现另一个问题:公共模块的代码变化非常频繁,每个项目都可能依赖它的不同提交,你很难追踪谁在用哪个版本。Starter用Maven坐标做版本边界之后,问题就变成了一个纯粹依赖管理问题,谁升了什么版本一目了然。
当然,自定义Starter也不是银弹。如果你的公共能力本身不稳定、需要频繁变动,那么把它做成一滩“会流动的代码”不如直接在服务里维护。判断标准很简单:如果这个能力在你手下超过两个项目都在用,而且核心逻辑半年内没大变过,那就值得封装;如果连你自己都说不清它的边界在哪里,先别急着动手。
2. 自动配置的底层原理:Spring Boot是怎么发现你的Starter的
在动手写代码前,我建议先把自动配置的原理过一遍。很多人能把Starter写出来,但问一句“为什么Spring Boot会自动加载你的配置类”就答不上来。知其然不知其所以然,遇到Bean冲突、配置不生效的时候就只能靠瞎试。
Spring Boot最核心的一个注解是@SpringBootApplication,它实际上是三个注解的合成:@SpringBootConfiguration、@EnableAutoConfiguration和@ComponentScan。其中@EnableAutoConfiguration做的事情就是引入了一个自动配置导入器,它会去classpath下找所有META-INF/spring.factories文件或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,读取里面配置的自动配置类全限定名,然后逐个加载评估。
这个机制的好处是:对使用方而言完全无感,就像乐高积木一样,引入依赖后自动拼上对应的零件。而对Starter作者而言,你得遵守这个约定,把自动配置类登记到指定文件里,Spring Boot才能找到它。
关键在于,Spring Boot不会把所有自动配置类都直接生效,而是逐个经过条件判断过滤。这就是条件注解发挥作用的时刻。条件注解可以理解为“带开关的Bean声明”,只有条件满足时才注册对应的Bean。比如@ConditionalOnClass看类是否存在,@ConditionalOnMissingBean看某个Bean是否已经存在,@ConditionalOnProperty看配置项是否有某个值。这些注解组合在一起,就实现了“聪明”的按需装配。
自动配置的执行顺序也有讲究。如果多个自动配置类之间需要控制先后顺序,可以通过在自动配置类上标注@AutoConfigureBefore、@AutoConfigureAfter或@AutoConfigureOrder来指定。常见场景比如:你的Starter需要在数据源配置之前完成某个数据源的装饰,或者在Redis连接创建之后才初始化缓存管理器。如果不关心顺序,一般不写也行,因为Spring Boot自己内置的配置类经过了精心排序,你的类通常晚于核心基础设施生效。
另外一个容易被忽略的点是:自动配置类本身不应该被@ComponentScan扫到。因为扫描的包路径通常是启动类所在的包,而Starter里的类在另一个jar包里,一般不在扫描路径下。真正让Starter生效的是上面说的自动配置导入机制,所以自动配置类也不要加上@Component或@Configuration后指望能被扫描到,把它老老实实登记到imports文件里才算数。
顺带一提,新版Spring Boot(2.7开始推荐,3.0后全面使用)的自动配置注册文件是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,每行一个自动配置类全限定名。旧版的spring.factories在Spring Boot 3.x里已经被移除了。如果你还在用老写法,建议尽早迁移。
3. 手写一个短信发送Starter:从属性绑定到自动装配
理论说了一堆,还是直接上手干最痛快。我用一个“通用短信发送Starter”举例:它内置一个控制台发送实现(方便本地调试),同时支持通过配置切换到某云厂商的SDK发送。这个小东西麻雀虽小,但把Starter开发的所有关键环节都覆盖了。
3.1 搭建基础工程与依赖声明
创建一个普通的Maven工程,打包方式设为jar。我这里取名为sms-spring-boot-starter,你可以根据自己实际情况改名。注意这个命名习惯:Spring Boot官方Starter命名是spring-boot-starter-xxx,自定义Starter官方推荐使用xxx-spring-boot-starter。这样做可以在以后通过Spring Boot的依赖管理统一处理时减少歧义,也让团队一眼认出这是一个Starter。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.4</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>sms-spring-boot-starter</artifactId> <version>1.0.0</version> <name>sms-spring-boot-starter</name>依赖方面,核心只需要两个:
spring-boot-autoconfigure:提供自动配置有关的注解和机制。spring-boot-starter:传递引入Spring Boot核心依赖。
如果你的Starter只是在Web应用里使用,可以不做额外依赖;如果涉及Web拦截器,则需要spring-boot-starter-web(或者使用optional级别,避免强制下游引入)。我这里短信发送不依赖Web,所以只保留最小的两个依赖就好。
另外有一个小细节:为了防止下游项目引错版本,建议在自动配置中使用@AutoConfiguration注解并指定after或before等属性,或者干脆不写顺序让它默认执行。
3.2 定义配置属性类:SmsProperties
配置属性类用来把application.yml里的配置项绑定到Java对象上。Spring Boot 2.x之后推荐使用@ConfigurationProperties配合@EnableConfigurationProperties绑定,不要直接往这个类上加@Component。原因我在后面的避坑章节会详细说。
package com.example.sms; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "example.sms") public class SmsProperties { /** * 发送方式:console 表示控制台输出,qcloud 表示使用某云厂商SDK */ private String provider = "console"; /** * 签名,实际生产环境一般从配置中心拉取 */ private String signName = "默认签名"; /** * 某云厂商SDK的SecretId,这里演示用法,生产环境请用加密配置 */ private String secretId = ""; /** * 某云厂商SDK的SecretKey */ private String secretKey = ""; /** * 区域,默认广州 */ private String region = "ap-guangzhou"; public String getProvider() { return provider; } public void setProvider(String provider) { this.provider = provider; } // 其余getter/setter省略,实际代码请补齐 }3.3 定义短信发送接口与两个实现类
既然要支持多厂商,自然要抽象出一个接口。这里我用了最简单方式,实际项目里你可以加更多参数结构。
package com.example.sms; public interface SmsTemplate { /** * 发送短信 * * @param phone 手机号 * @param content 内容 * @return 是否发送成功 */ boolean send(String phone, String content); }控制台实现类:本地调试时直接把日志打印出来,验证流程跑通。
package com.example.sms; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class ConsoleSmsTemplate implements SmsTemplate { private static final Logger log = LoggerFactory.getLogger(ConsoleSmsTemplate.class); @Override public boolean send(String phone, String content) { log.info("[console sms] to={}, content={}", phone, content); return true; } }某云SDK实现类:真实接入时你会在里面填充厂商的SDK调用代码,这里为了演示只打日志模拟。
package com.example.sms; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class QCloudSmsTemplate implements SmsTemplate { private static final Logger log = LoggerFactory.getLogger(QCloudSmsTemplate.class); private final SmsProperties properties; public QCloudSmsTemplate(SmsProperties properties) { this.properties = properties; } @Override public boolean send(String phone, String content) { // 这里如果是真实SDK,需要用 properties.getSecretId() 等参数做初始化 log.info("[qcloud sms] sign={}, to={}, content={}", properties.getSignName(), phone, content); return true; } }3.4 编写自动配置类:按配置选择装配哪个实现
自动配置类是Starter的心脏。在这里我们要做几件事:绑定属性、按条件选择Bean、把SmsTemplate作为可注入的Bean暴露出去。
package com.example.sms; import org.springframework.boot.autoconfigure.AutoConfiguration; 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(SmsProperties.class) public class SmsAutoConfiguration { @Bean @ConditionalOnMissingBean(SmsTemplate.class) @ConditionalOnProperty(prefix = "example.sms", name = "provider", havingValue = "qcloud", matchIfMissing = false) public SmsTemplate qcloudSmsTemplate(SmsProperties properties) { return new QCloudSmsTemplate(properties); } @Bean @ConditionalOnMissingBean(SmsTemplate.class) @ConditionalOnProperty(prefix = "example.sms", name = "provider", havingValue = "console", matchIfMissing = true) public SmsTemplate consoleSmsTemplate(SmsProperties properties) { return new ConsoleSmsTemplate(); } }这段配置的逻辑是:
- 默认情况下
provider没配置,会走consoleSmsTemplate,本地直接看日志,不依赖任何外部资源。 - 当配置了
example.sms.provider=qcloud时,注册qcloudSmsTemplate。 - 两个方法都加了
@ConditionalOnMissingBean(SmsTemplate.class),意思是如果业务项目里已经手动声明了一个SmsTemplateBean,那么自动配置就退位让贤,避免Bean冲突。 - 同一时刻只有一个
SmsTemplate被注册,因为两个方法上的@ConditionalOnProperty条件互斥。
3.5 注册自动配置:imports文件是关键
这是整篇文章最容易忽略但又最重要的一步。自动配置类写好了,如果没登记到Spring Boot的加载清单里,它永远不会被识别。
在src/main/resources/META-INF/spring目录下创建文件org.springframework.boot.autoconfigure.AutoConfiguration.imports,内容只有一行:
com.example.sms.SmsAutoConfiguration注意:这个文件每个Starter只能有一个,多行就是多个自动配置类。每个自动配置类全限定名占一行。
到这里,一个可用的Starter已经成型。运行mvn install把它安装到本地仓库,然后新建一个Spring Boot测试项目引入依赖,在application.yml里写:
example: sms: provider: console sign-name: 我的测试签名接着在任意@Service或@Component里直接注入:
@Autowired private SmsTemplate smsTemplate; public void doSomething() { boolean ok = smsTemplate.send("13800000000", "您的验证码是123456"); }启动应用后,调用这个方法,控制台会打印一条[console sms]日志。从“引入依赖”到“Bean可直接用”,中间没有写任何@Configuration,这就是自动配置的威力。
3.6 动手验证时最容易遇到的两个坑
第一次写得时候,我遇到两个情况,在这里先喂一颗定心丸,后面章节还有更详细的清单。
第一个是自动配置类不生效。检查点很简单:确认imports文件路径和名称完全正确,确认自动配置类没有放在启动类所在的包扫描范围内自动被扫描到(这会导致Bean重复定义),确认你的自动配置类至少有一个@Bean或条件注解,否则Spring Boot可能把它当普通类跳过。
第二个是配置项绑不上。我在属性类里用了signName,在YAML里写的是sign-name,如果你启用了spring-boot-configuration-processor,IDE会提示合法写法。没有处理好的时候Bean里拿到的值全是空,排查半天才发现是编辑器里带了下划线。配置绑定遵循Relaxed Binding规则,我建议统一用kebab-case(比如sign-name)。
4. 进阶打磨:条件注解、配置提示与Bean覆盖控制
一个能跑通的基础Starter只是起点,要让团队用得顺手、可靠,还需要在细节上打磨。
4.1 条件注解的选择策略
写自动配置最核心的决策就是“什么时候该生效”。条件注解有十几种,实际高频用到的也就几个。
@ConditionalOnClass常常用于“依赖某个类才装配”。比如你的Redis增强Starter只有在项目里已经引入了RedisTemplate之后才有意义,那就在配置类或Bean方法上标@ConditionalOnClass(RedisTemplate.class)。这样不依赖这个库的项目引入你的Starter也不会有副作用。
@ConditionalOnProperty是最灵活的控制开关。我通常用它做“开关”配置,比如:
example: sms: enabled: true配合@ConditionalOnProperty(prefix = "example.sms", name = "enabled", havingValue = "true", matchIfMissing = false)使用,相当于给整个Starter加了一个总电闸。对于默认开启还是默认关闭,我的经验是:如果这个Starter是团队必选项,默认开启更省心;如果它是可选项,默认关闭更安全。
@ConditionalOnMissingBean用于“给使用者留出覆盖余地”。只要用户已经声明了同类型Bean,你的自动装配就主动退让,让用户可以替换默认实现。这个注解是自定义Starter里最“礼貌”的机制,也是避免Bean冲突的关键。
4.2 让配置项在IDE里有提示
一个让使用体验瞬间提升的小步骤:引入spring-boot-configuration-processor依赖。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency>这个依赖在编译时会扫描@ConfigurationProperties类,生成META-INF/spring-configuration-metadata.json,这样下游项目在写application.yml时就能获得键名自动补全和注释提示。类似于“给你造的每个轮子都贴上了标签和说明书”,极大减少配置拼写错误。
别小看这一步骤,我见过团队因为某个Starter的配置项太多,同事记不住键名反复翻源码,加了metadata之后基本就不需要翻源码了。
4.3 Bean覆盖的“潜规则”
@ConditionalOnMissingBean虽然好用,但要清楚它的局限:它判断的是“当前容器里是否已经有这个Bean”。如果在同一个配置类里有两个方法都用了@ConditionalOnMissingBean(SmsTemplate.class),Spring Boot会挑选其中一个先注册,另一个就不生效,具体选哪个不保证,所以不要写出两个条件范围重叠的Bean方法。
更好的做法是把“选择哪个实现”的逻辑完全交给条件注解去做,而不是在运行时动态判断。比如用@ConditionalOnProperty让两个方法天然互斥,同时每个方法上都加@ConditionalOnMissingBean做二次保险。这样既保证可覆盖性,又不容易出现预期外的冲突。
还有一点要特别注意:不要让自动配置类被@ComponentScan扫描到。如果你的Starter内部类路径恰好落在主启动类的包路径下,Spring Boot会通过扫描注册一份Bean、自动配置又注册一份Bean,产生重复定义或覆盖顺序混乱。规避方式就是确保自动配置类包名和业务包名完全隔离,同时自动配置类只通过imports文件注册。
4.4 命名规范、依赖隔离与自动配置排序
自定义Starter的“非技术规范”其实也很重要。
命名规范和约定俗成的格式:xx-spring-boot-starter。如果是给框架做的自动配置,比如xx-spring-boot-autoconfigure,通常用于非starter形态的配套库。这样区分的好处是,万一某个库不需要spring-boot-autoconfigure依赖,也可以单独作为普通jar使用。
依赖隔离:Starter传递下来的依赖应该尽量克制,用optional尽量减少对下游的侵入。比如某云SDK依赖体积很大,可用<optional>true</optional>标记。但要注意,如果你用了@ConditionalOnClass来感知SDK是否存在,那SDK必须是optional的,否则根本不会“缺席”。
自动配置排序:如果Starter之间互相有先后依赖关系,用@AutoConfigureBefore、@AutoConfigureAfter声明。不要盲目依赖Bean的初始化顺序——它是反模式的根源。比如你的Starter想在某个内置的Web拦截器之前添加过滤器,那就显式声明顺序。
5. 实战避坑清单:这些坑我基本都踩过
这部分是我从实际使用中整理的踩坑实录,按出现频率排个序。
5.1 坑一:包扫描扫不到,自动配置也不生效
这个坑最容易出现在刚接触Starter的人身上。原因通常是把自动配置类放在了业务启动类能扫描到的包下面。如果自动配置类在启动类的子包中,@ComponentScan会直接把它当成普通配置类加载。而另一方面,如果你在imports文件里也登记了它,就会出现两个效果叠加,Bean被重复创建,或者某些顺序导致的完全失效。
正确做法很简单:自动配置类放在独立包下(如com.example.sms),业务启动类在com.example.app,两者完全没有包路径交集。自动配置类上不加@Component,只保留@AutoConfiguration。
5.2 坑二:@ConfigurationProperties的Bean模式下属性拿不到
我早期见过很多写法是在SmsProperties类上加@Component让它直接变成Bean。这在简易demo里能跑通,但在Starter里有隐患:你无法把属性绑定逻辑和自动配置解耦,而且在某些场景下,属性类被提前实例化、尚未完成配置绑定,导致属性全是空值。
更稳的用法是给属性类加@ConfigurationProperties(prefix = "..."),然后在自动配置类上写@EnableConfigurationProperties(SmsProperties.class)。这样Spring Boot会保证在构造配置类之前完成属性绑定,同时属性类只在自动配置生效时才创建,不会总是“白占”一个Bean的坑位。我在多个项目验证下来,这种写法从未出现过属性为空的情况。
5.3 坑三:条件注解之间的依赖顺序问题
@ConditionalOnBean和@ConditionalOnMissingBean有一个天然缺陷:条件判断是按照配置类注册的先后顺序进行的。如果你引用了另一个Starter里的Bean,而那个Starter还没被注册,@ConditionalOnBean会直接返回false,你的Bean就不生效了。
这种“跨Starter协作”场景,最好的办法不是依赖Bean条件的判断顺序,而是显式在自动配置类上标注依赖的先后关系,比如@AutoConfigureAfter(SomeAutoConfiguration.class)。把自己的自动配置排在正确位置之后,再进行Bean条件判断,基本都能得到预期结果。
5.4 坑四:Spring Boot 2.7/3.x时代的文件迁移问题
如果你在网上搜到旧教程,它的步骤可能是:在resources/META-INF下创建spring.factories,里面写上org.springframework.boot.autoconfigure.EnableAutoConfiguration=你的配置类。这套写法在Spring Boot 2.7之前有效,但2.7开始官方引入新的imports文件机制,并逐步弃用spring.factories中的自动配置条目;到了Spring Boot 3.0,这条路径已经移除了。如果你在3.x项目里还在用旧的spring.factories,自动配置会静默失效,而且大概率不报错。
建议:新项目统一使用META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。如果你的Starter需要同时兼容Spring Boot 2.x和3.x,最好用两种文件都写上,但要注意自动配置类上的注解在不同版本下略有差异(如@AutoConfiguration在旧版本里并不是官方注解,要小心处理)。能升级就尽早升级,维护两套注册文件就是双倍的坑。
5.5 坑五:配置文件键名不一致导致绑定为空
这个纯粹是“细节决定成败”。YAML里我用sign-name,Java属性类里是signName,Spring Boot的Relaxed Binding能正确映射。但如果你在YAML里拼成sign_name,虽然有些场景下也可能被识别,但显式声明example.sms.sign-name是最保险的。为了避免这种低级错误,建议加上spring-boot-configuration-processor并尽量使用IDE的提示自动补全。
还有一个很常见的运行时“假绑定”场景:属性类没有无参构造器和getter/setter,或者setter是私有的,也会导致绑定不正确或报错。检查一下有没有手误把setter写成了“只有内部使用”的方法。
5.6 坑六:要提供一个“本地可跑”的默认实现
很多Starter默认走真实第三方服务,这样在本地启动项目时没有配置好账号,启动直接报错,结果排查半天发现是Starter在“搞事”。我的建议是:所有Starter都留一个“本地默认实现”。比如短信Starter里的ConsoleSmsTemplate,不依赖任何外部资源,启动即用,确认整个链路没问题后再切到真实实现。这个设计对开发体验拔高非常多,也让新成员接手项目时不会被外部依赖卡住跑不起来。
6. 我最后想分享的一点体会
我把一个项目组里七八个重复配置整理成Starter之后,最深的感触是:自定义Starter不是一个“炫技”的功能,它解决的是工程规模变大之后的“重复与漂移”问题。自动配置机制把“怎么装配”这件事下沉到了依赖包内部,业务项目只需要关心“我要不要开、怎么配置”,这种分工本身就让团队协作清晰了很多。
如果你打算动手做第一个Starter,建议从一个非常具体的小能力开始,比如“接口幂等校验”“日志脱敏”“自定义线程池”,先不要做那种“大而全”的基础框架。因为你会在实际落地中不断发现新的边界条件和需求,小而美的Starter更容易迭代。
最后留一个实操上的小技巧:做完Starter后,建议顺手写一个使用了它的最小demo项目放同仓库里,路径类似sms-spring-boot-starter-example。任何新同事接手看了demo就能跑起来。这个习惯让我后来维护多个Starter时省了大量答疑时间,强烈推荐你也这么做。