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 Boot2.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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent groupIdcom.example/groupId artifactIdsms-spring-boot-starter/artifactId version1.0.0/version namesms-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.providerqcloud时注册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-processorIDE会提示合法写法。没有处理好的时候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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-configuration-processor/artifactId optionaltrue/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依赖体积很大可用optionaltrue/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-nameJava属性类里是signNameSpring 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时省了大量答疑时间强烈推荐你也这么做。