Spring Boot 自动配置这块,很多人在项目里用过,但自己动手写一个 Starter 的人不多。我最早接触自定义 Starter 是在一个多服务拆分的时候——三个业务服务都需要一套操作日志记录的能力,最开始是每个服务各自复制一份工具类,后来发现小问题不断:一个服务修了 bug,另外两个没同步;接口签名改了,编译期没人发现,运行时才炸。后来就把这套逻辑抽成了一个自定义 Starter,统一维护、统一发版,这个模式一直用到现在。
如果你正在做微服务拆分、想把通用能力从业务代码里剥离开,或者只是单纯想搞懂 Spring Boot 自动配置到底是怎么运转的,这篇文章就是给你准备的。我会从底层的自动配置原理讲起,再用一个完整的操作日志 Starter 实操案例,带你从头到尾写一个能直接用的自定义 Starter,最后把我在实际项目中踩过的坑都整理出来。
1. 为什么要自己写 Starter:先弄懂它到底解决什么问题
自定义 Starter 的本质,是把一套可复用的能力连同它的自动配置逻辑一起打包,让使用方只需要引入依赖、填几个配置项就能直接用,不需要关心组件内部的 bean 是如何创建和装配的。
1.1 没有 Starter 的时候,我们在干什么
在 Starter 这种模式出现之前,我们复用一套通用能力通常有两条路。
第一条路:建立一个公共模块,把工具类、配置类都放进去,业务服务引入这个模块,然后在自己的代码里写@ComponentScan扫描公共模块的包,或者手动@Import相关配置类。这条路的问题在于"隐式约定"太多——公共模块的包路径一旦和业务服务的扫描路径对不上,bean 就是不加载,而且不同的业务服务引入同一版本的公共模块,行为还可能出现偏差,因为扫描规则和配置方式各不相同。
第二条路:每个服务各自维护一份代码拷贝。这个更原始,但很多团队背地里确实这么干。复制粘贴带来的问题大家都有体会:代码腐化快,修复不彻底,接口演变后多处调用点不一致。
自定义 Starter 解决的就是这个问题:它把"扫描逻辑"变成了"约定逻辑"。使用方不需要关心包路径、不需要写@Import,引入依赖后自动配置就生效了。这种体验上的变化,其实是 Spring Boot 自动配置机制带来的范式转变。
1.2 Starter 适合封装什么能力
不是所有代码都适合做成 Starter。根据我的实际经验,下面这几类场景是最适合的:
- 基础设施类能力:比如 Redis 连接工厂、消息队列的生产者消费者、分布式锁、ID 生成器等,这些能力配置复杂、初始化逻辑重,非常值得封装。
- 横切关注点:操作日志、链路追踪、接口幂等、限流熔断等,这些逻辑跨越所有业务模块,最容易出现每个服务一套实现的情况。
- 对接外部系统的客户端:比如对接某个短信平台、支付网关,把鉴权逻辑、签名逻辑、重试逻辑封装好,对外只暴露一个干净的调用入口。
有一类东西我不建议做成 Starter:业务逻辑比较重、需要频繁根据项目定制的能力。因为 Starter 层封得越死,使用方扩展起来越痛苦。通用性和灵活性之间的平衡,是设计 Starter 时最重要的考量。
1.3 自定义 Starter 和普通公共模块的区别
很多人把"公共模块"和"Starter"混为一谈,实际上差异很大。公共模块只是把代码放一起,依赖关系还是要使用方自己维护;而 Starter 自带自动配置,并且通过spring.factories或AutoConfiguration.imports文件与 Spring Boot 的自动配置机制绑定,实现了"引入即用"。
下表是我自己总结的几个关键区别:
| 维度 | 普通公共模块 | 自定义 Starter |
|---|---|---|
| 装配方式 | 手动扫描/手动 Import | 自动配置,引入即生效 |
| 配置管理 | 使用方自行读取配置并创建 bean | 通过@ConfigurationProperties自动绑定 |
| 条件控制 | 无 | @ConditionalOnXxx系列注解精准控制 |
| 使用成本 | 每接入一个服务都要写装配代码 | 加依赖 + 填配置 |
| 维护成本 | 多个调用方复制代码/各自适配 | 单点维护,统一发版 |
从这个对比能看出来,Starter 是把"装配逻辑"也一起复用了,而不仅仅是复用"业务代码"。这也是它和工具类库的本质区别——工具类库复用的是方法,Starter 复用的是整个组件的生命周期。
2. 自定义 Starter 的底层原理:自动配置机制是怎么串起来的
想写好自定义 Starter,必须先搞清楚 Spring Boot 的自动配置机制。很多时候配置不生效、bean 不知道为什么创建了,根源都在这里。
2.1 从启动注解到自动配置的完整链路
Spring Boot 应用启动时,@SpringBootApplication注解里包含了@EnableAutoConfiguration,这个注解通过@Import(AutoConfigurationImportSelector.class)引入了自动配置的装配逻辑。
关键步骤可以拆成四条链路:
AutoConfigurationImportSelector被 Spring 容器调用,它要做的事情是找到所有需要加载的自动配置类。- 它通过类加载器去读取所有 jar 包中的
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件(这是较新版本 Spring Boot 的机制,早期版本读取的是META-INF/spring.factories中EnableAutoConfiguration键对应的值)。 - 读取到的自动配置类全类名列表会经过过滤,移除掉被
exclude的类、被条件注解判定为不满足的类。 - 最终确定下来的自动配置类被解析、注册为 bean 定义,Spring 容器按普通配置类的方式实例化它们。
这里有一个很容易忽略的细节:自动配置类本身也是普通的配置类,只不过它们通常在应用程序主类的包路径之外,所以不能靠@ComponentScan扫描到,必须通过上述方式显式加载。这也是AutoConfiguration.imports文件存在的意义。
2.2 条件注解:自动配置的"开关"
自动配置不能无条件生效,否则每个服务启动时都会创建一堆用不到的 bean。Spring Boot 提供了一套条件注解,用来做精细化的开关控制:
@ConditionalOnClass/@ConditionalOnMissingClass:根据类路径中是否存在某个类来判断。这是最常用的条件,比如类路径里有某个第三方库的 class 时,才加载相关的自动配置。需要注意这个注解放在配置类上时,Spring Boot 在解析阶段会用ASM读取注解元数据,不会真正加载类,这是为了避免触发类初始化。@ConditionalOnBean/@ConditionalOnMissingBean:根据容器中是否已存在某个 bean 来判断。这个注解经常用来实现"用户自定义优先"的逻辑——如果用户已经在代码里声明了同类型的 bean,自动配置的默认实现就自动退让。@ConditionalOnProperty:根据配置文件中某个属性的值来判断。比如xxx.enabled=true时才启用,适合做组件开关。@ConditionalOnExpression:使用 SpEL 表达式进行复杂判断。@ConditionalOnWebApplication:判断当前应用是否为 Web 应用,适合区分 Web 环境和普通环境。@ConditionalOnMissingBean:这是我在自定义 Starter 中用得最多的一个注解,后面实操部分会重点讲。
条件注解的执行时机很讲究。Spring 容器在解析配置类时,会先收集所有条件注解的评估结果,再决定是否解析这个配置类。所以条件评估发生在 bean 实例化之前,基于的是"当前容器的注册状态和类路径状态"。
2.3 配置绑定:把 yml 里的配置项变成强类型对象
自动配置类负责创建 bean,但 bean 的参数往往来自配置文件。Spring Boot 的@ConfigurationProperties注解负责这块工作。
原理是:ConfigurationPropertiesBindingPostProcessor在 bean 初始化后介入,把配置文件中的值按规则绑定到被标注的 bean 上。这里的绑定规则支持松散绑定,比如配置项audit-log.enable可以对应 Java 字段enable,audit-log.topic-name可以对应topicName,你不用刻意保持一致的形式。
配置绑定的标准写法是:
- 一个配置属性类,标注
@ConfigurationProperties(prefix = "audit-log")。 - 在自动配置类中启用它:可以单独加
@EnableConfigurationProperties(AuditLogProperties.class),也可以让配置属性类与@Component组合。
我推荐用@EnableConfigurationProperties的方式,因为这样配置属性类的生命周期由 Spring Boot 统一管理,不会因为类路径扫描不到而失效。
3. 动手之前的设计准备:目录结构、命名规则与依赖管理
写代码前先定好结构和规范,看起来是小事,实际影响很大。一个命名混乱、目录层级随意的 Starter,后续维护成本会非常高。
3.1 单模块还是双模块?
自定义 Starter 有两种组织方式:单模块和双模块。
单模块就是把自动配置类和业务逻辑放在同一个模块里,打包成一个 jar。这种方式简单直接,适合内部规模不大、不需要单独管理自动配置依赖的场景。缺点是不太利于隔离——使用方需要引入整个 starter,即使只想用其中一部分能力。
双模块是 Maven 官方推荐的模式,拆成两个模块:
xxx-spring-boot-autoconfigure:放自动配置类、配置属性类。xxx-spring-boot-starter:空壳模块,只依赖 autoconfigure 模块,以及真正需要的第三方依赖。
为什么要这样拆?因为自动配置模块通常要设置optional依赖或者provided依赖,避免把不需要的 jar 传给业务服务;而 starter 模块作为正式引入门面,依赖声明在这里统一暴露。两个模块职责分开,改动 autoconfigure 不会影响 starter 的依赖结构,反之亦然。
对于个人项目或者小团队,单模块完全够用;如果是公司级的公共组件库,我建议一开始就拆双模块,避免后续再迁移。
3.2 命名规范:这个细节比你想象的更重要
Spring Boot 官方 starter 的命名格式是spring-boot-starter-xxx。自定义 starter 不能直接用这个名字前缀,官方文档明确要求自定义的 starter 命名要避免与官方冲突,推荐格式是xxx-spring-boot-starter。
比如你做了一个操作日志组件,推荐命名为audit-log-spring-boot-starter。自动配置模块对应audit-log-spring-boot-autoconfigure。这样一眼就能看出哪个是自动配置模块,哪个是用户引入的 starter。
这里有个实际经验:内部发布时,group 和 artifact 的命名要保持一致风格,比如 group 用com.example.middleware,artifact 都用xxx-spring-boot-starter结尾。一致性好,到了依赖冲突排查时,你看包名就能快速定位哪些业务服务用了哪些版本。
3.3 依赖处理:optional、provided 和 compile 的区别
做 Starter 最容易翻车的就是依赖管理。自动配置类的特点是你不知道使用方最终的项目里有什么依赖,因此:
- 自动配置模块里对第三方库的依赖应该设为
optional,意思是"这个依赖只在我这个模块内部编译时需要,不会传递给使用方"。典型的例子:你的自动配置类引用了 RedisTemplate,但使用方可能根本不引入 Redis 的场景,这种依赖就必须 optional。 - starter 模块里的依赖则是实打实要传递的,使用方引入 starter 后必须能拿到运行所需的依赖。
- 对 Spring Boot 自身的自动配置相关依赖,一般用
provided作用域,意思是编译时需要、运行时不传递,因为业务服务本身就是 Spring Boot 应用,肯定会有这些依赖。
一个判断方法:凡是使用方项目里"一定会有"的依赖,都可以用 provided;"可能有"的依赖用 optional;"必须有"的依赖,放在 starter 模块用 compile。
4. 从零实现一个操作日志 Starter:完整实操
理论说了一堆,现在进入实战。我以一个"业务操作日志自动记录"的 Starter 为例,演示从零到一的全过程。这个组件做的事情很简单:业务服务里某个方法被调用时,自动记录操作人、操作方法、参数、耗时、结果状态,异步写入日志存储接口。
4.1 第一步:创建 Maven 模块和基础结构
我采用双模块方案。先创建父模块:
<groupId>com.example.middleware</groupId> <artifactId>audit-log-spring-boot-parent</artifactId> <version>1.0.0.RELEASE</version> <packaging>pom</packaging>父模块下创建两个子模块:
audit-log-spring-boot-parent ├── audit-log-spring-boot-autoconfigure └── audit-log-spring-boot-starterautoconfigure 模块的 pom.xml 关键依赖:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-autoconfigure</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-autoconfigure-processor</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> <optional>true</optional> </dependency> </dependencies>aspectjweaver 是用来实现 AOP 拦截的依赖。这里用 optional 而不是 compile,是因为 AOP 功能只是我们组件内部需要,是否引入 aspectj 取决于使用方项目的 AOP 配置情况——但其实这里理由更充分一点:Spring Boot 用户大概率会自己引入 AOP,我们不需要强制传递。
4.2 第二步:定义对外暴露的注解和接口
Starter 对外要提供两个核心要素:一个标记注解、一个存储接口。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface AuditLog { // 操作类型,比如 query/create/update/delete String action() default ""; // 操作描述,比如"修改用户信息" String description() default ""; // 是否记录参数,默认记录 boolean logParams() default true; }接口设计为 SPI 形式,使用方可以自定义日志的最终处理方式:
public interface AuditLogHandler { void handle(AuditLogInfo logInfo); }另外定义一个信息载体类:
public class AuditLogInfo { private String action; private String description; private String operator; private String methodName; private String className; private String params; private Object result; private long costMs; private String status; // 省略 getter/setter }这里把AuditLogHandler定义成接口,是为了配合后面@ConditionalOnMissingBean的用法——我们提供的默认实现会被使用方自定义的实现替代。
4.3 第三步:实现自动配置类和核心逻辑
这是最核心的部分。自动配置类要做的事情:创建AuditLogAspect切面 bean,注入配置属性和日志处理器。
切片的核心逻辑:
@Aspect public class AuditLogAspect { private final AuditLogProperties properties; private final AuditLogHandler handler; public AuditLogAspect(AuditLogProperties properties, AuditLogHandler handler) { this.properties = properties; this.handler = handler; } @Around("@annotation(auditLog)") public Object around(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable { long start = System.currentTimeMillis(); String status = "success"; Object result = null; try { result = joinPoint.proceed(); return result; } catch (Throwable e) { status = "error:" + e.getClass().getName(); throw e; } finally { AuditLogInfo info = buildInfo(joinPoint, auditLog, start, status, result); handler.handle(info); } } // 省略 buildInfo 逻辑 }自动配置类如下:
@AutoConfiguration @ConditionalOnClass(AuditLogAspect.class) @EnableConfigurationProperties(AuditLogProperties.class) public class AuditLogAutoConfiguration { @Bean @ConditionalOnMissingBean(AuditLogHandler.class) public AuditLogHandler auditLogHandler() { // 默认实现:打日志输出到控制台 return logInfo -> System.out.println("audit log: " + logInfo); } @Bean @ConditionalOnMissingBean(AuditLogAspect.class) public AuditLogAspect auditLogAspect(AuditLogProperties properties, AuditLogHandler handler) { return new AuditLogAspect(properties, handler); } }这里包含了两个关键设计点:
一是@ConditionalOnMissingBean(AuditLogHandler.class)这一行。它表达的是"用户自定义的 Handler 优先于默认实现"。如果使用方在业务代码里写了一个AuditLogHandler的实现类并注册为 bean,我们的默认实现就自动失效了。这是自定义 Starter 里最常见的扩展点设计。
二是@AutoConfiguration注解。这是 Spring Boot 2.7 之后引入的注解,替代了之前的@Configuration加spring.factories的组合方式。如果你用 3.x 版本,甚至要求所有自动配置类都必须用@AutoConfiguration标注。
配置属性类:
@ConfigurationProperties(prefix = "audit-log") public class AuditLogProperties { /** * 是否启用审计日志 */ private boolean enabled = true; /** * 日志异步处理的线程池大小 */ private int threadPoolSize = 4; // 省略 getter/setter }4.4 第四步:注册自动配置类
这一步是很多新手最容易漏的。光有自动配置类不行,Spring Boot 不知道要加载它,必须在资源目录下建文件。
如果是 Spring Boot 2.7 及以上版本,在src/main/resources/META-INF/spring/下创建:
org.springframework.boot.autoconfigure.AutoConfiguration.imports文件内容:
com.example.middleware.audit.AuditLogAutoConfiguration如果是 Spring Boot 2.7 之前的版本,则是在resources/META-INF/spring.factories里配置:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.middleware.audit.AuditLogAutoConfiguration两条机制背后做的事情是相同的,只是加载入口不同。如果你要做一个兼容性好的组件,可以让两个文件都存在,这样不同版本的 Spring Boot 都能识别。
4.5 第五步:生成配置元数据
配置元数据文件的作用是让 IDE 在编写application.yml时能有提示。Spring Boot 提供了一个内置的注解处理器,你在 autoconfigure 模块的 pom 中加入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency>编译之后,就会自动生成META-INF/spring-configuration-metadata.json。如果你给了字段注释,这些注释还会直接出现在 IDE 的配置提示里。一个小经验:配置属性的注释要写在字段上而不是 getter 上,这样生成的元数据才有描述信息。
如果没有生成成功或者想手动补全,也可以在META-INF下放一个additional-spring-configuration-metadata.json文件来补充提示内容。比如给audit-log.enabled字段补充默认值的提示。
4.6 第六步:starter 模块打包和验证
starter 模块的 pom 非常简单:
<dependencies> <dependency> <groupId>com.example.middleware</groupId> <artifactId>audit-log-spring-boot-autoconfigure</artifactId> <version>1.0.0.RELEASE</version> </dependency> </dependencies>注意,这个模块本身不应该有任何代码,它的唯一职责就是把 autoconfigure 模块连同必要的传递依赖引入进来。
打包后测试接入流程:
- 新建一个普通 Spring Boot 项目。
- 引入
audit-log-spring-boot-starter依赖。 - 在配置文件里加上:
audit-log: enabled: true thread-pool-size: 8- 在任意一个 Service 方法上标注
@AuditLog(action = "create", description = "创建订单"),把项目跑起来,调用这个接口。
如果控制台窗口里出现了 audit log 输出,说明整条链路已经打通了。我第一次验证的时候输出没出现,排查了很久才发现是忘了在META-INF/spring目录下创建 imports 文件,导致自动配置类压根没被加载。这算是一个经典的低级错误,但确实很容易犯。
5. 进阶技巧:把 Starter 做得更稳定、更开放
基础版能跑通之后,离一个合格的 starter 还有距离。我在几个项目里迭代出来的这些细节,能帮你少走很多弯路。
5.1 条件注解的合理排列组合
自动配置类的条件判断不是越多越好,但也不是越少越好。我把常用组合总结成几个场景:
- 只有类存在才装配:
@ConditionalOnClass(SomeClient.class)+@ConditionalOnMissingBean(SomeClient.class)。第一个保证使用方确实引入了对应依赖,第二个保证使用方自定义的同类型 bean 不被覆盖。 - 属性开关控制:
@ConditionalOnProperty(prefix = "xxx", name = "enabled", havingValue = "true", matchIfMissing = true)。注意matchIfMissing这个参数,默认情况下配置不存在时会跳过装配,加上matchIfMissing = true后,没配置也默认启用。这个值怎么设,取决于你的组件是默认开启还是默认关闭。 - 环境相关控制:比如分布式锁组件,在本地单机模式下其实不需要真正的锁实现,
@ConditionalOnProperty加一个mode=local/remote的开关,就能在同一套配置下切换实现。
这里要特别提醒:@ConditionalOnBean和@ConditionalOnMissingBean的评估时机在 Spring Boot 中有一个容易踩坑的点。它们基于的是"当前容器中已有的 bean 定义"进行判断,而 bean 定义注册是有先后顺序的。如果使用方把自定义 Handler 定义在某个@Configuration类里,而这个配置类的加载顺序晚于自动配置类的处理顺序,那@ConditionalOnMissingBean判断时可能看不到自定义 bean,默认实现就会先注册上去,导致双 bean 冲突。
这个问题的规避方式:Spring Boot 提供了@AutoConfigureBefore和@AutoConfigureAfter注解来调整自动配置类之间的顺序。如果是自动配置类之间需要排顺序,可以通过这两个注解来控制。官方文档也专门说明了@ConditionalOnMissingBean在自动配置类中判断"用户 bean"时不一定可靠,最稳妥的兜底方案是,在自动配置类里不生成默认实现的 bean,而是提供一个默认的外部化配置,通过@ConditionalOnProperty按配置加载。
我在实际操作中发现,如果默认实现本身就承担了"最低可用能力"的角色,用@ConditionalOnMissingBean是够用的;但如果默认实现会和自定义实现产生副作用(比如定时任务扫描、消息监听),那就要谨慎设计了,必要时宁可让使用方手动指定。
5.2 自动配置的顺序控制
Spring Boot 2.7 开始,自动配置类的排序方式是从AutoConfiguration.imports文件中的顺序决定的,文件中靠前的类会先被处理。如果你需要明确指定顺序,可以用@AutoConfigureBefore或@AutoConfigureAfter注解,或者直接调整 import 文件里的排列顺序。
举个例子:你写了一个 Redis 操作日志 starter,它依赖另一个中间件团队提供的 Redis 连接自动配置。那么你的自动配置类就必须在 Redis 自动配置之后执行,这样RedisTemplatebean 才已经存在。此时:
@AutoConfiguration // 在 Redis 自动配置类完成之后执行 @AutoConfigureAfter(RedisAutoConfiguration.class) public class AuditLogRedisAutoConfiguration { // ... }如果顺序搞反了,你的配置类先跑,@ConditionalOnBean(RedisTemplate.class)判断为不满足,整个自动配置就被静默跳过了。这是自动配置"学起来简单、用起来复杂"的一个典型场景。
5.3 让使用方有充分的扩展空间
一个优秀的 Starter 不能把所有的路走死。我通常会在组件里预留这么几个扩展点:
- 存储实现扩展:定义存储接口,默认提供日志文件实现,但允许使用方自定义存储到数据库、ES 等。
- 操作人获取扩展:操作日志组件需要知道"当前操作人是谁",这个信息在 Web 场景下通常来自登录上下文或请求头。组件内部不应该固定写死踩进请求上下文的代码,而是提供一个
OperatorSupplier接口,由使用方决定如何获取操作人。 - 过滤规则扩展:有些方法不想被记录,除了在注解上加开关,还可以提供一个
AuditLogFilter接口,使用方可以按方法名、参数内容等自定义过滤规则。
这些扩展点不一定一开始就设计好,往往是使用方提出了需求再逐步补充的。但是有一点可以从第一天就坚持:对外暴露的都是接口,而不是具体类;内部实现都加@ConditionalOnMissingBean兜底。这样后续加扩展点不会破坏已有实现。
6. Starter 开发中的常见问题与排查技巧实录
这部分内容都是我在真实项目中遇到过的问题,不是从文档里抄来的。每个问题都配了排查思路。
6.1 引入依赖后,配置完全不生效
现象:业务服务引入了 starter 依赖,配置项也写了,但启动日志里完全没有相关输出,自定义的 bean 没有被创建。
排查路径:
- 先看自动配置是否被加载。Spring Boot 启动时加参数
--debug,或者配置debug=true,启动日志里会输出Positive matches和Negative matches,在里面直接搜你的自动配置类的类名。如果出现在 Negative matches 中,说明有某个条件注解不满足。这是最重要的第一步,不要跳过。 - 检查
AutoConfiguration.imports文件有没有被正确打进 jar 里。解压 autoconfigure 模块的 jar,确认文件路径是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,内容类名拼写无误。 - 如果条件是
@ConditionalOnClass,检查使用方项目里是不是根本没有对应的类。一个常见的场景是:自动配置模块里写了@ConditionalOnClass(AuditLogAspect.class)来标记这个组件自身存在,但使用方项目里没有引入 AOP 相关的 jar,导致判断类缺失而跳过。可以用启动日志确认具体是哪个条件不满足。
6.2 配置项在 IDE 里没有提示
现象:audit-log.enabled写在 yml 里不报错,但没有自动补全提示,也没有跳转到配置属性类的功能。
排查路径:确认 autoconfigure 模块的 pom 里有没有加spring-boot-configuration-processor依赖。加了之后需要重新编译,然后检查target/classes/META-INF/spring-configuration-metadata.json是否存在,内容里有没有audit-log前缀。如果文件存在但 IDE 依旧不提示,试试重新导入 Maven 项目,刷新 IDE 缓存。
6.3 多个 starter 之间的 bean 冲突
现象:引入了两个中间件 starter,结果启动报错,提示某类型有多个 bean 或者类型匹配失败。
排查思路:打开启动日志的自动配置报告,看两个 bean 分别是哪个配置类注册的。通常是我上面说过的@ConditionalOnMissingBean评估时机问题导致的。这时候要么通过@AutoConfigureBefore/@AutoConfigureAfter调整顺序,要么两个 starter 之间通过一个共享的排序接口协商好注册顺序。
这里有一个比较实用的兜底做法:如果两个 starter 在同一个场景下必然冲突,建议在文档里写明不兼容的组合,并在自动配置类上用@ConditionalOnClass检查对方 starter 的标记类,冲突时直接拒绝装配,而不是让用户启动到一半才报错。
6.4 使用方自定义的 bean 被自动配置覆盖了
现象:使用方写了一个AuditLogHandler的实现类,也加了@Component,但是运行起来发现默认实现还在生效,自定义的没被调用。
排查路径:
- 检查自定义 Handler 的
@Component是否被组件扫描覆盖到了。如果它位于主应用类包路径之外,Spring Boot 默认的扫描范围就覆盖不到。 - 检查自动配置类里的
@ConditionalOnMissingBean(AuditLogHandler.class)是否真的生效。如果自定义 Handler 是手动new出来然后手动注册的,没有经过 Spring 容器扫描,那这个条件判断到容器里确实"没有该 bean 的注册信息",自然也不会感知到。 - 还有一种隐蔽情况:自定义 Handler 的方法上有事务注解或者其他 AOP 切面,导致实际注册的是代理对象,类型匹配时看的是
AuditLogHandler接口,这部分没问题;但如果匹配的是具体类,代理对象匹配不上就会产生条件判断异常。
6.5 版本兼容性问题:Spring Boot 2.x 和 3.x 的差异点
现在市面上还有大量项目跑在 Spring Boot 2.4 到 2.7 的版本区间,也有项目开始升 3.x。自定义 Starter 需要考虑不同版本的兼容性。
主要的差异点有三个:
- 自动配置注册方式:3.x 强制使用
AutoConfiguration.imports,spring.factories只保留给非自动配置的其他 SPI;2.4 之前只有spring.factories。 - 注解替代:
@AutoConfiguration是 2.7 引入的,3.x 里所有自动配置类应该用它替代@Configuration。不过实际上 3.x 中@Configuration的配置类如果写在 imports 文件里也会被加载,官方更推荐@AutoConfiguration。 - JDK 基线:Spring Boot 3.x 要求 Java 17,如果你的组件要向下兼容 Java 8 的项目,就不能用 3.x 编译的 jar,得维护多版本。
我的做法是在模块里维护additional-spring-configuration-metadata.json来补充旧版本的配置提示,同时用 Maven profile 按 Spring Boot 版本切换不同的装配声明文件,发布时打两个版本的 jar。
7. 从我几个真实项目里沉淀出来的体会
写自定义 Starter 这几年,我最大的感受是:它看起来是写代码,实际上是在做"契约设计"——你在一开始约定好的配置项、接口、条件装配规则,后续都要长期被多个团队遵守。契约不稳定,使用方不找你麻烦才怪。
下面这几个建议是我踩坑踩出来的,分享给你。
第一,对外暴露的接口一定要稳定。可以在注解上不断加属性、加扩展点,但不要轻易修改一个已有的方法签名。一旦多个业务服务升级了新版本,某个服务忘了适配,线上行为就会不一致。我在做一个接口签名调整的时候,特意保留了一个 deprecated 方法,所有调用方逐步迁移,才没有引发大规模改动。
第二,条件注解的参数要写成"显式"的。比如@ConditionalOnProperty的matchIfMissing属性,不要依赖默认值,因为它很容易让使用方以为"我明明配置了,怎么不生效"。所有配置项设计时先问自己一个问题:如果使用方什么都不写,行为合理吗?如果合理,matchIfMissing = true就是对的;如果不合理,那这个组件就应该默认关闭,由使用方显式打开。
第三,自动配置类里尽量不要依赖使用方的包扫描结果。组件内部 bean 的加载走自动配置链路,组件外部的扩展实现靠使用方的@ComponentScan,这两者之间的时序关系很难保证。更稳妥的设计是:让使用方在一个固定的配置类里通过@Import引入扩展实现,或者在自动配置类里提供一个@Bean方法并配合@ConditionalOnMissingBean。
最后再分享一个小技巧:开发 Starter 时,最好在 autoconfigure 模块里写一些简单的契约测试,启动一个最简 Spring Boot 上下文,验证默认配置下自动配置是否生效、使用方自定义实现时默认 bean 是否被替换。这类测试不需要依赖真实中间件,只是把装配逻辑验证一遍。我后来接手过的几个内部 starter,普遍都缺这种测试,导致改一处配置逻辑,心里就发虚。
自定义 Starter 这条路,入门不难,写一个能跑的 demo 很简单,难的是把边界划清楚、把扩展点留够、把兼容性稳住。希望这篇文章能帮你迈过这几道坎。