手写一个自定义 Starter:从 0 到被 Boot 自动装载
这一篇讲的不是「怎么用 Spring Boot」,而是怎么自己做一个让别人「一引就生效」的 Spring Boot 插件。你写好一套能力(比如发短信),把它打包成一个 jar;别人只需要在自己的项目里写一段 <dependency> 和几行 yml,就能直接把这个能力当现成组件用——不用他写任何注册代码。这件事在 Spring Boot 里叫 Starter(启动器)。
Starter 就是家里装的中央空调。厂家把主机、遥控器、安装说明书一起塞进一个纸箱发到我家,我只做两件事:插电(在项目里引依赖)、按两下按钮(在 yml 里写几行配置),空调就出风了。我完全不需要懂压缩机怎么绕线、冷媒怎么循环——那些是厂家的活。反过来,如果这个纸箱里只有「一根电源线 + 一张购物清单」,而主机、遥控器全在你家客厅另外三个柜子里,那这个箱子就没帮你省事。这正是本篇第一个硬结论:xxx-spring-boot-starter 这个箱子里只放「购物清单」(pom 依赖),主机和遥控(真正的 Java 代码)全部住在 xxx-spring-boot-autoconfigure 那个模块里。
先把这六个词一次性解释清楚,后文不再重复:
| 术语 | 一句话解释 |
|---|---|
| Starter(启动器) | 一个「加依赖即生效」的 jar 包,本身几乎不含代码 |
| 依赖(dependency) | 你在 pom.xml 里声明「我要用别人的 jar」,Maven 负责下载并放进 classpath |
| classpath | 程序运行时被搜索的 jar 与目录清单;不在上面,JVM 就找不到你的类 |
| 容器(IoC 容器) | Spring 在启动时创建并保管对象的那个「仓库」,你要谁它就给你谁 |
| Bean | 由容器负责创建和管理的对象(不是你到处 new 出来的) |
| yml / properties | 项目的配置文件,写成 sms.timeout: 2000 这种键值对,用来喂给上面的 Bean |
| 自动配置(auto-configuration) | Spring Boot 在启动时按一份候选清单挨个尝试装配,条件通过才真的建 Bean |

学完这一篇,你应该能回答三个问题:
- 我的 Java 代码到底该放
starter模块还是autoconfigure模块?放错了会看到哪句报错? - 容器是怎么「知道」我这个第三方配置类存在的?靠哪个文件、路径必须怎么写?
- 使用方想关掉我的 Starter,或者想用他自己的实现替换我的默认实现,我该留什么口子?
引入 spring-boot-starter-web 之后,你一行配置没写,却凭空得到了嵌入式 Tomcat、Spring MVC、Jackson 序列化……很多人的第一反应是「这 starter 真神奇」。但只要打开它的 pom,你会发现一个反直觉的事实:starter 这个 jar 里,一行 Java 代码都没有。
Spring Boot 官方把「starter」拆成了两个职责完全不同的东西:
| 形态 | 代表 | 里面装什么 | 一句话定位 |
|---|---|---|---|
| 依赖描述型(starter) | spring-boot-starter-web | 只有 pom,聚合一堆依赖 | 「买什么」 |
| 自动配置型(autoconfigure) | spring-boot-autoconfigure | 一堆 XxxAutoConfiguration 配置类 | 「怎么装」 |
spring-boot-starter-web 的 pom(精简后)大致是这样:
<project> <artifactId>spring-boot-starter-web</artifactId> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> <!-- 间接带来 spring-boot-autoconfigure --> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-json</artifactId> <!-- Jackson --> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <!-- 内嵌容器 --> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> </dependency> </dependencies></project>spring-boot-starter-web本身不含逻辑,它只负责把「做一个 Web 应用需要哪些 jar」打包成一份清单- 真正让 Tomcat、MVC 自动生效的
WebMvcAutoConfiguration、ServletWebServerFactoryAutoConfiguration,住在spring-boot-autoconfigure里 - 所以「引入 starter」= 引入依赖 + 引入一份预置的自动配置,这是两步,缺一不可
提示:官方为什么要把两者拆开?因为一个公司内部常有多个 starter 想复用同一套自动配置逻辑。拆开后,autoconfigure 可以被独立依赖、独立测试,而每个 starter 只负责「组合出不同套餐」。想清楚这层拆分,你就知道自己的项目该建几个模块。
下面以一个「短信发送」Starter 为例从零做出来。目标很具体:使用方引入依赖、写上几行 yml,就能直接 @Autowired SmsClient。

按官方建议拆成两个模块:
sms-spring-boot-starter/ # 聚合模块(父 pom)├── pom.xml├── sms-spring-boot-autoconfigure/ # 自动配置模块:装逻辑│ ├── pom.xml│ └── src/main/│ ├── java/com/example/sms/autoconfigure/│ │ ├── SmsProperties.java # 配置属性│ │ ├── SmsClient.java # 核心服务│ │ └── SmsAutoConfiguration.java # 自动配置类│ └── resources/META-INF/spring/│ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports└── sms-spring-boot-starter/ # 依赖描述模块:装依赖 └── pom.xmlautoconfigure 模块的 pom,关键是对只用于编译期的依赖使用 optional:
<project> <artifactId>sms-spring-boot-autoconfigure</artifactId> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-autoconfigure</artifactId> </dependency> <!-- 只在编译期需要,不传递给使用方 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency> </dependencies></project>starter 模块的 pom,只负责把 autoconfigure 打包带走:
<project> <artifactId>sms-spring-boot-starter</artifactId> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>sms-spring-boot-autoconfigure</artifactId> <version>${project.version}</version> </dependency> </dependencies></project>spring-boot-configuration-processor:编译期注解处理器,作用是在 yml 里为sms.*提供属性提示与文档。它只该出现在自己的编译期,所以标optionalstarter模块几乎零代码,它存在的价值就是「使用方依赖一个坐标就够」- 如果你的 starter 很小、不打算被复用,也可以只建一个模块;但把 autoconfigure 独立出来,是官方推荐的分层方式
两个 pom 该勾哪几项,别去抄别人的——自己生成一遍,顺便看清每一项的 scope 和 optional 是从哪句话来的:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>模块拆完了,四个文件各自管什么?这层「谁管谁」的关系比 pom 更容易记混,所以做成可以一格格点的堆叠图——从下往上点,每抽掉一层,使用方的体验就掉一格:
属性类负责把 yml 里的 sms.* 绑定成一个 Java 对象。它是整个 Starter 的「参数面板」:
package com.example.sms.autoconfigure;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.validation.annotation.Validated;import jakarta.validation.constraints.Min;import jakarta.validation.constraints.NotBlank;@Validated@ConfigurationProperties(prefix = "sms")public class SmsProperties { /** 总开关,默认开启 */ private boolean enabled = true; /** 网关地址 */ @NotBlank private String endpoint; @NotBlank private String accessKey; @NotBlank private String secretKey; /** 短信签名,例如「某某科技」 */ @NotBlank private String signName; /** 超时时间(毫秒),最小 500 */ @Min(500) private int timeout = 3000; // 省略 getter / setter}@ConfigurationProperties(prefix = "sms"):声明它就是sms.*的接收者,字段名与配置项的松散绑定由它负责(templateId能接住template-id)@Validated:必须加,否则@NotBlank、@Min这些校验注解形同虚设——这是新手最常漏的一行- 这里的校验注解来自 Jakarta Bean Validation,使用方只需保证 classpath 上有实现(如
hibernate-validator),配置写错时启动会立刻报错并指出是哪个字段
坑:只写 @ConfigurationProperties 而不写 @Validated,校验不会执行。更隐蔽的是:有人把校验注解加在字段上、却忘了引入校验实现,启动时同样不会报错——配置项为空会一路带到业务代码,直到短信发不出去才被发现。
属性类只是「接水的杯子」,水是怎么流进来的?这段动画把一条 yml 走到 SmsClient 手里的六拍全演了一遍,注意第 ③ 拍:access-key 和 accessKey 是在这一刻对上的。

SmsClient 就是这个 Starter 真正对外提供的能力。关键认知:它是一个普通 POJO,不做任何注册。
package com.example.sms.autoconfigure;import java.util.Map;public class SmsClient { private final SmsProperties props; // 构造注入:由自动配置类传入,不自己 new public SmsClient(SmsProperties props) { this.props = props; } public SmsResult send(String phone, String templateId, Map<String, String> params) { // 真实项目里这里会走 HTTP 调用短信网关 System.out.printf("[SMS] endpoint=%s sign=%s, send to %s with %s%n", props.getEndpoint(), props.getSignName(), phone, templateId); return SmsResult.ok("msg-" + System.currentTimeMillis()); }}- 不写
@Component、不写@Service、不写@Configuration:一旦它自己声明成组件,就会和自动配置里的注册逻辑打架,使用方也无法用自定义实现替换它 - 依赖通过构造函数接收
SmsProperties,而不是自己new。这样它天然可测:单元测试里传一个手工构造的SmsProperties即可 - 把「能力」和「装配」分开,是写可替换组件的第一原则:能力属于核心类,装配属于自动配置类
这是整个 Starter 的灵魂:它决定「什么时候把 SmsClient 注册进容器」。
package com.example.sms.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@ConditionalOnClass(SmsClient.class)@ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true", matchIfMissing = true)@EnableConfigurationProperties(SmsProperties.class)public class SmsAutoConfiguration { @Bean @ConditionalOnMissingBean public SmsClient smsClient(SmsProperties props) { return new SmsClient(props); }}逐注解解读,这才是读/写自动配置的正确姿势:
@AutoConfiguration:新式自动配置标记,等价于@Configuration(proxyBeanMethods = false),但额外获得排序能力(before/after)。它必须由 imports 清单注册,不能用@ComponentScan扫到@ConditionalOnClass(SmsClient.class):classpath 上必须存在该类。这正是「依赖没引入时整个自动配置直接跳过」的开关@ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true", matchIfMissing = true):给使用方一个总开关;matchIfMissing = true表示「yml 里不写也默认开启」@EnableConfigurationProperties(SmsProperties.class):把SmsProperties注册成 Bean 并完成绑定,这样下面的@Bean方法可以直接要一个参数@ConditionalOnMissingBean(写在@Bean方法上):用户优先的体现——使用方自己定义了SmsClient,这里就主动让路;没定义才兜底
上面五条各管一件事,而这五件事正是面试与工单里被追问最多的五处。背不住——来玩一局:先点你写下的那行注解,再点它唯一负责的那件事。
自动配置类写好了,但容器怎么知道它的存在?答案是一份清单文件。路径必须一字不差:
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件内容极其简单,一行一个类名,不需要任何 key:
com.example.sms.autoconfigure.SmsAutoConfiguration- 这是 Spring Boot 2.7 引入、3.0 起唯一有效的注册方式,取代了旧的
META-INF/spring.factories - 文件名本身就是「key」,所以文件名一旦写错,等于清单不存在——见第十节的坑
- 一个 starter 可以有多个自动配置类,省略包名前缀会被当成同包类解析,所以始终写全限定名最稳妥
有人会问:直接在自动配置类上写 @ComponentScan 把自己的包扫进来,不是更省事?省事是省事,但它把「条件什么时候求值」这件事从容器手里抢回了扫描顺序手里。两条入口的差别就在这张对照图里——同一个类,走左边那条门,@ConditionalOnMissingBean 就可能提前答「没有」:


Starter 做完,使用方需要做的只有三件事。
第一步,加依赖:
<dependency> <groupId>com.example</groupId> <artifactId>sms-spring-boot-starter</artifactId> <version>1.0.0</version></dependency>第二步,写配置:
sms: enabled: true endpoint: https://sms.example.com/api access-key: ak-123456 secret-key: sk-abcdef sign-name: 某某科技 timeout: 2000第三步,直接用:
@Servicepublic class NoticeService { private final SmsClient smsClient; // 直接注入,无需任何 @Bean 定义 public NoticeService(SmsClient smsClient) { this.smsClient = smsClient; } public void notifyUser(String phone) { smsClient.send(phone, "TPL_LOGIN", Map.of("code", "8848")); }}启动后控制台输出即证明接线成功:
[SMS] endpoint=https://sms.example.com/api sign=某某科技, send to 138****0000 with TPL_LOGIN- 使用方全程没有写过一行
@Bean,这正是 starter「开箱即用」的意义 - 如果使用方把
sms.enabled设为false,SmsClient不会被注册,注入时会报NoSuchBeanDefinitionException——这是条件生效的直接证据 - 如果使用方自己定义了
SmsClient,那么自动配置会让路,使用方的实现生效
Starter 「没生效」时,别猜,直接开条件报告:
java -jar app.jar --debug报告里找到自己的配置类,逐条核对每个条件:
Positive matches:----------------- SmsAutoConfiguration matched: - @ConditionalOnClass found required class 'com.example.sms.autoconfigure.SmsClient' (OnClassCondition) - @ConditionalOnProperty (sms.enabled=true) matched (OnPropertyCondition) - @ConditionalOnMissingBean (types: com.example.sms.autoconfigure.SmsClient) did not find any beans (OnBeanCondition) SmsAutoConfiguration#smsClient matched: - @ConditionalOnMissingBean (types: com.example.sms.autoconfigure.SmsClient) did not find any beans (OnBeanCondition)Negative matches:----------------- SmsAutoConfiguration: Did not match: - @ConditionalOnClass did not find required class 'com.example.sms.autoconfigure.SmsClient' (OnClassCondition)- 看
Positive matches:逐条列出你通过了哪些判断,等于告诉你「这几个条件是命中的」 - 看
Negative matches的Did not match::如果连SmsAutoConfiguration本身都没出现在报告里,那问题多半不在条件,而在 imports 清单没被读到 - 报告里根本不出现你的类,是「清单路径写错」和「条件不满足」的分水岭
官方对自定义 starter 有明确的命名与发布建议,照着做能少踩很多坑:
| 约定 | 建议 | 原因 |
|---|---|---|
| 命名 | xxx-spring-boot-starter | 与官方 spring-boot-starter-xxx 区分,一眼看出是第三方 |
| 模块拆分 | starter + autoconfigure 两个模块 | 逻辑可复用、可独立测试 |
| Spring Boot 依赖 | 用 optional 或 provided | 不要把版本强加给使用方,避免版本冲突 |
| 版本依赖深度 | 不要依赖具体 Boot 内部实现类 | 内部类可能在小版本间变动,升级即崩 |
| 属性前缀 | 用公司/项目统一前缀(如 sms) | 避免与官方 spring.* 撞名 |
| 文档 | 提供 additional-spring-configuration-metadata.json | 让 IDE 对自定义属性有提示 |
面试问「自定义 starter 怎么保证兼容性」,答两点即可——依赖尽量 optional、不由 starter 决定 Boot 版本;只用公开 API,不碰 internal 包下的类。做到这两点,使用方升级 Boot 时你的 starter 基本不用改。
| 现象 | 根因 | 解决 |
|---|---|---|
| 自动配置完全没被加载,报告里连类名都没有 | imports 文件路径写错(少写 spring/、写成了 spring.factories、或漏了包名) | 严格核对路径 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports |
| 默认实现把用户的实现覆盖了 / 或用户实现被忽略 | @ConditionalOnMissingBean 写在了错误位置或类型参数写错 | 写在提供默认值的 @Bean 方法上,类型写「要判断的那个类型」 |
imports 文件路径是最常见的错误,没有之一。 它有三个必须记住的细节:目录是 META-INF/spring/(多了一层 spring)、文件名是超长的全限定类名加 .imports、内容里不许写注释也不许有 key= 前缀。很多人的第一个 starter「怎么都不生效」,最后发现只是把文件放到了 META-INF/ 下。
@ConditionalOnMissingBean 的位置是第二个高频错误。 它要写在提供默认实现的 @Bean 方法上,用来表达「用户已经定义了同类型 Bean,我就让路」。如果把它写在配置类级别,会作用于该类所有 Bean;如果类型参数写成父类或另一个类型,判断的对象就变了,表现为「永远命中或永远不命中」——于是默认实现要么重复注册、要么永不生效。
下面这个演示把上面的三个条件做成了可切换开关。试着把「用户已自定义」打开,观察 SmsClient 的注册是如何被跳过的:
前面四个文件都写完了,现在把它们按启动顺序连成一条线。这条线是本篇最难自行推导的部分,也是面试里「说说自定义 starter 的原理」的标准答案骨架:

| 步 | 发生什么 | 谁负责 | 写错了会怎样 |
|---|---|---|---|
| 1 | 使用方 pom 里的 sms-spring-boot-starter 让 sms-spring-boot-autoconfigure 进入 classpath(classpath 就是运行时被搜索的 jar 清单) | Maven 依赖传递 | 坐标或版本号错 → 编译期就找不到类 |
| 2 | @SpringBootApplication 上的 @EnableAutoConfiguration 去读每个 jar 里的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | Spring Boot 自动配置导入机制 | 路径或文件名错 → 你的类根本不在候选名单里 |
| 3 | 逐个求值条件注解:@ConditionalOnClass 看类在不在、@ConditionalOnProperty 看开关开不开 | 你的自动配置类 | 任一不通过 → 该类进 Negative matches,Bean 不生成 |
| 4 | 条件通过后绑定 yml 到 SmsProperties,再调用 @Bean 方法造出 SmsClient;若使用方已有同类型 Bean,@ConditionalOnMissingBean 让你让路 | 你的 @Bean 方法 | 缺 setter / prefix 写错 → 属性绑不上,字段全为默认值 |
| 5 | 使用方的构造器参数一写 SmsClient,容器就把第 4 步造好的那个实例塞进去 | IoC 容器 | 第 3 步没过 → NoSuchBeanDefinitionException |
新手最常见的困惑是「我明明写了自动配置类,它凭什么会被执行?」。答案就在第 2 步——不是靠扫描,是靠一份必须一字不差的清单文件。理解了这一点,你就理解为什么第十节说路径错误是「头号翻车现场」。
上面那张表是「读」的,顺序还是得点。把这五步摊成一次单步执行:左边六行代码,右边同步刷新此刻的变量和调用栈。连点下一步,重点停在第 ② 拍——那一拍决定了后面四拍到底有没有机会发生:
// 使用方 pom:<artifactId>sms-spring-boot-starter</artifactId>SpringApplication.run(App.class, args) // @SpringBootApplication 里的 @EnableAutoConfigurationAutoConfigurationImportSelector.getCandidates() // 逐个 jar 读 META-INF/spring/*.importsfilter(SmsAutoConfiguration.class) // OnClass / OnProperty 在这里求值bind(SmsProperties.class, environment) // 名字归一化 + 类型转换 + @ValidatedsmsClient(props) 写下定义 → 使用方构造器拿到那个实例| 使用方写了 | 一行 <dependency> |
| classpath 上多了 | starter jar + autoconfigure jar |
| 你的类 | 还没被任何人读过 |
Maven 依赖解析classpath光看图记不住装配链路,因为它发生在启动的最初几秒、且全程「看不见对象」。下面五个实验在你的浏览器里真实执行 Spring Boot 的那几层,每个都能自己切参数。建议顺序:先 ① 建立整体感,再 ② 看清注册与排序,然后 ③ 逐个搞懂条件注解,④ 弄明白使用方的配置到底怎么进到 SmsProperties,最后 ⑤ 回头确认 SmsProperties 和 SmsClient 究竟是从哪条门进容器的。
第一个实验拆掉整个 Starter 的四根承重柱。元数据(metadata)指的是「描述自己配置的数据」,这里指那份 imports 清单;依次点四个按钮,你会看到「清单存在 → 属性绑上 → Bean 造出来 → 我把开关关掉」的完整过程,最后一个按钮正是第十二节决策卡里「让使用方能优雅降级」的落点:
第二个实验回答第 2 步那个最抽象的问题:候选清单是从哪些 jar 里捞出来的,捞出来之后为什么要排序。「排序与分组」那一步能看到官方自动配置之间的 before / after——这就是第五节强调 @AutoConfiguration 比裸 @Configuration 多出来的能力,你的第三方配置默认排在所有官方配置之后:
第三个实验把第五节那三个条件注解逐个拆开求值。条件注解(conditional annotation)就是「满足条件才装配」的判断开关。切到 @ConditionalOnMissingBean 那一格,你会亲眼看到用户已定义同名 Bean 时默认实现如何被跳过——这正是第十四节沙盘要你手动验证的同一件事:
第四个实验解释使用方最常问的一句话:「我在 yml 里写了 access-key,Java 字段叫 accessKey,凭什么对得上?」。松散绑定(relaxed binding)就是 @ConfigurationProperties 允许 kebab-case、camelCase、大写环境变量三种写法命中同一个字段的能力;顺手对比「谁覆盖谁」,能看懂 profile、环境变量、命令行三层的优先级关系:
@ConfigurationProperties 就像酒店房卡。前台(yml + 松散绑定)在你入住那一刻把楼层、房号、有效期一次性写进卡片;此后每次刷开门(业务代码调 props.getEndpoint())都不需要再解释一遍你是谁。房卡配错,后果不是「刷一次失败报你一次错」,而是后面每一次都进不去——这解释了为什么属性绑定必须在启动阶段就校验干净,而不是等发短信时才发现问题。
第五个实验回答的是一句很少被讲清的话:SmsProperties 和 SmsClient 都是 Bean,但它们进容器走的不是同一条门。依次点 scan、bean、import、auto,四条门(组件扫描、@Bean 方法、@Import 直送、清单登记的自动配置)会把各自的时机打给你看——你的属性类靠 @EnableConfigurationProperties 走「直送」,SmsClient 走 @Bean,自动配置类本身走清单。最后点 miss,看清「包没被扫到」和「清单没登记」为什么会报出同一句话:
实验按到这里,可以换成命令行自己敲。下面这台控制台连着浏览器里的同一个内核,回显全部由内核算出来:
cond configLoaded false 之后一定要 restart 再 beans——开关只改配置,不重建容器你不会看到任何变化。这一串动作就是第八节「用报告定位断点」的手工版。
这个沙盘模拟使用方项目的最终状态。左边两个旋钮:总开关怎么写、使用方有没有自己定义同类型 Bean。右边立刻给出容器里到底有哪个 SmsClient,以及注入处会不会炸。
先把结论摆在这里:一个设计合格的 Starter 必须同时提供「关得掉」(sms.enabled)和「换得掉」(@ConditionalOnMissingBean)两个口子。前者的目的是让使用方在自己的 application.yml 里就能表达意图,后者的目的是让用户自定义永远压过你的默认实现——这是第五节逐注解解读的落点。
Positive matches: SmsAutoConfiguration#smsClient matched- @ConditionalOnProperty (sms.enabled) matched (OnPropertyCondition)- @ConditionalOnMissingBean did not find any beans (OnBeanCondition)容器里 1 个 SmsClient —— 自动配置的那个
沙盘里最反直觉的一格是「false + 使用方自定义」。很多人以为关掉开关就等于彻底不用这个 starter,其实用户的 @Bean 属于使用方自己的配置类,跟你的条件毫不相干。真正决定「有没有 Bean」的是两件事:你的条件通不通过、他自己有没有定义。
下表第一列可以直接整段粘进搜索框,别意译也别缩写;最后一列指出去哪一篇深挖。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 | |
|---|---|---|---|---|
Cannot resolve symbol 'SmsClient'(使用方 import 时报红) | 类根本不在被依赖的那个 jar 里——你把 Java 代码写进了 xxx-spring-boot-starter 模块,而那个模块按规范只放 pom 依赖 | 打开 mvn dependency:tree 确认依赖指向;把 .java 全部挪进 xxx-spring-boot-autoconfigure,package 声明同步改掉 | 本篇第二、十三节 | |
yml 里输 sms. 一点提示都没有,注释也没有,但运行正常 | 缺少 spring-boot-configuration-processor 依赖(IDE 提示来自编译期生成的 META-INF/spring-configuration-metadata.json),或者 @ConfigurationProperties(prefix = ...) 的前缀与 yml 顶层键不一致 | 给 autoconfigure 模块加回 <optional>true</optional> 的 processor 并重新 build;核对 prefix 与 yml 顶层键 | 本篇第三、九节 · #17 配置体系 | |
Property: sms.endpoint / Required: not null / Property: sms.timeout / Value: "100" / Reason: must be greater than or equal to 500 | 属性绑上了但校验没过。这段是 Spring Boot 的启动失败诊断报告,约束来自 @NotBlank / @Min | 按报告点名的字段改 yml;如果你发现这些注解完全不生效,是因为漏了类级别的 @Validated | 本篇第三节 | |
BindingConfigurationPropertiesValidator 报错 / Could not bind properties to 'SmsProperties' : prefix=sms, ignoreInvalidFields=false, ignoreUnknownFields=true | 绑定阶段失败:字段没有 setter(@ConfigurationProperties 靠 JavaBean setter 或构造器绑定)、类型对不上(把 Duration 写成 int),或前缀拼错 | 补 setter 或改用构造器绑定;对照报错里的 prefix 检查 yml 顶层键;临时可加 ignoreUnknownFields = false 让未匹配项立刻报错 | 本篇第三、七节 | |
The corresponding listeners for existing spring.factories entries for <class> were not found / 自定义 starter 在 Boot 3 升级后「一行代码没改却彻底不生效」 | 自动配置仍注册在旧的 META-INF/spring.factories(key 为 org.springframework.boot.autoconfigure.EnableAutoConfiguration)。该 key 在 Boot 2.7 起弃用、3.0 已移除,Boot 3 只读 AutoConfiguration.imports | 全局搜 spring.factories,把自动配置那一项迁到 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,内容改成一行一个全限定类名(不要写 key=) | 本篇第六节 · #18 自动配置原理 | |
No qualifying bean of type 'com.example.sms.autoconfigure.SmsClient' available(但你确定 yml 也写了、依赖也引了) | 自动配置压根没进候选名单:imports 文件路径少了一层 spring/、文件名被简写成 auto-configuration.imports,或 jar 打包时 src/main/resources 没被 include 进去 | `jar tf target/sms-spring-boot-autoconfigure-1.0.0.jar \ | grep -i imports` 直接看包里到底有没有这份文件 | 本篇第六、十节 |
Parameter 0 of constructor in noticeService required a single bean, but 2 were found: + - smsClient - mySmsClient | 用户自定义实现和自动配置默认实现同时存在。通常是 @ConditionalOnMissingBean 位置错(写在了类级别),或使用方的 @Bean 注册时机晚于自动配置的条件判定 | 把 @ConditionalOnMissingBean 挪到提供默认值的那个 @Bean 方法上;应急时给用户实现标 @Primary 或在注入点加 @Qualifier | 本篇第五、十节 · #19 条件装配 | |
Consider defining a bean of type 'com.example.sms.autoconfigure.SmsProperties' in your configuration | @EnableConfigurationProperties(SmsProperties.class) 缺失,或你把 SmsProperties 放进了会被 @ComponentScan 扫到的包又指望它自动注册 | 在自动配置类上补 @EnableConfigurationProperties;确认使用方的组件扫描范围不包含你的包 | 本篇第五节 · #6 组件扫描 |
整张表里最容易被误诊的是第一行。Cannot resolve symbol 看起来像「Maven 没下下来」,于是使用者花半小时清仓库、reimport,其实问题在你这个 starter 作者这边:代码放错模块了。判断方法很简单——jar tf 列出那个 starter jar,如果里面只有 META-INF/maven/…pom.xml 而没有任何 .class,那就是符合规范的形态(见第二节)。
上面最后两行的区别,只有在真堆栈上点一次才分得清。这段是 starter 作者收到最多的那张工单:依赖引了、yml 写了、Bean 却说找不到。先别看解析——点出你认为的凶手行:
使用方把 sms-spring-boot-starter 1.0.0 引进 pom,yml 里 endpoint 也写了,启动却报找不到 SmsClient。他自己跑了 --debug,报告里连 SmsAutoConfiguration 这个名字都搜不到。
目标:15 分钟做一个最小可用 starter,把「引依赖 → 生效 → 关掉 → 不生效」四态走一遍。
// echo-spring-boot-autoconfigure/src/main/java/com/example/echo/EchoProperties.javapackage com.example.echo;import org.springframework.boot.context.properties.ConfigurationProperties;@ConfigurationProperties(prefix = "echo")public class EchoProperties { private boolean enabled = true; private String tag = "echo"; // getter / setter 全部保留 —— 没有 setter,属性就绑不上 public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } public String getTag() { return tag; } public void setTag(String tag) { this.tag = tag; }}// EchoAutoConfiguration.java —— 最小可用形态package com.example.echo;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(EchoProperties.class)@ConditionalOnProperty(prefix = "echo", name = "enabled", havingValue = "true", matchIfMissing = true)public class EchoAutoConfiguration { @Bean @ConditionalOnMissingBean public EchoService echoService(EchoProperties props) { return phone -> System.out.println("[" + props.getTag() + "] -> " + phone); }}// EchoService.java —— 只有一个方法,方便第三档扩展package com.example.echo;public interface EchoService { void echo(String target);}# echo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/# org.springframework.boot.autoconfigure.AutoConfiguration.importscom.example.echo.EchoAutoConfiguration使用方 application.yml 与预期输出:
echo: tag: DEV[DEV] -> 138****0000验收清单:① 把 echo.enabled 改成 false,注入处应报 NoSuchBeanDefinitionException;② jar tf 看 autoconfigure 包里有 .class 和那份 imports,starter 包里只有 pom;③ 说出「为什么删掉 setter 会导致 tag 变回默认值」。
每个变体只动一处,观察点各不相同:
- 给
EchoProperties加一个@Min(100) private int delay = 500;,但不加@Validated。你会观察到:把echo.delay写成50照样启动成功——这就是第三节的坑,校验静默失效;补上类级别的@Validated后再试,启动即报must be greater than or equal to 100。 - 把 imports 文件从
META-INF/spring/移到META-INF/(只挪位置,内容不动)。你会观察到:启动无任何报错,但EchoService再也不存在,--debug报告里连EchoAutoConfiguration这个名字都搜不到——这正是第十六节倒数第二行的成因。 - 在使用方项目里自己写一个
@Bean EchoService,然后把它所在的配置类分别放在「普通@Configuration」和「被@AutoConfiguration标注」两种位置。你会观察到:前者@ConditionalOnMissingBean正常让路,后者容易撞进第十五节沙盘「true + 有一个」那一格,出现两个候选 Bean。 - 把
@ConditionalOnProperty从类级别挪到@Bean方法级别,再把echo.enabled设为false。你会观察到:EchoProperties依然存在(因为@EnableConfigurationProperties还在类上),只是EchoService不再生成——开关的作用域变小了。
做一个公司内部 starter:oss-spring-boot-starter,把「阿里云 OSS 上传」包装成开箱即用。
- 结构必须是两个模块 + 四个文件(属性类、核心服务类、自动配置类、imports 清单),命名遵守第九节表格
- 属性至少含
oss.enabled/oss.endpoint/oss.bucket/oss.access-key/oss.secret-key/oss.timeout,其中 access-key、secret-key 用@NotBlank,timeout 用@Min(500),并带上@Validated - 提供
OssClient接口 + 一个默认实现,默认实现的@Bean方法必须标@ConditionalOnMissingBean,让使用方能换成自己的 mock 实现 - 附带
additional-spring-configuration-metadata.json,给oss.enabled写上description与defaultValue,让 IDE 有提示 - 写一个
OssAutoConfigurationTest,用ApplicationContextRunner覆盖四种组合:默认开、enabled=false、用户自定义实现、缺 endpoint 时启动失败
验收清单:① 使用方只写 <dependency> + 5 行 yml 即可注入 OssClient;② --debug 报告里能同时看到一条 Positive match 与一条因 enabled=false 产生的 Negative match;③ 故意把 yml 的 access-key 留空,启动立刻失败并点名该字段;④ 使用方定义自己的 OssClient 时,容器里只有他那个(用断言 doesNotHaveBean 或 hasSingleBean 证明);⑤ 把你的 starter 装进一个全新项目,全程不需要写一行 @Bean。
不看正文,说出 xxx-spring-boot-starter 与 xxx-spring-boot-autoconfigure 各自装什么、为什么拆两个。如果把 Java 类写进了前者,使用方看到的报错是哪一句?
自动配置类靠哪个文件被容器发现?写出完整路径,并说出旧写法在 Boot 3 上为什么不再生效。
@ConditionalOnClass / @ConditionalOnProperty / @ConditionalOnMissingBean 分别在判断什么?各对应中央空调类比里的哪一幕(插头没电、开关关了、客人自带雨伞)?
@ConditionalOnMissingBean 应该写在类上还是写在提供默认值的 @Bean 方法上?写错会分别造成什么现象?
为什么自动配置类里不要用 @ComponentScan?它破坏的是哪一个前提条件?
使用方在 yml 里写 access-key,你的字段叫 accessKey,凭什么对得上?这个能力的名字叫什么,谁提供的 IDE 提示又来自哪个依赖?
清单指路、条件把关、房卡配参、让路保客——imports 决定生不生效,条件决定要不要装,@ConfigurationProperties 一次配好参数,@ConditionalOnMissingBean 保证用户永远优先。
一个能用的 starter = 两个模块(starter 装依赖 + autoconfigure 装逻辑)+ 四个文件(属性类、核心服务类、自动配置类、imports 清单)+ 五个注解(@AutoConfiguration、@ConfigurationProperties、@ConditionalOnClass、@ConditionalOnProperty、@ConditionalOnMissingBean)。装配链路是:应用引入依赖 → @EnableAutoConfiguration 触发导入 → 读取 imports 清单 → 条件求值 → 绑定 yml 配置 → 注册 SmsClient。铭记两条铁律:核心服务类是普通 POJO,绝不自己声明成组件;注册清单的路径与 @ConditionalOnMissingBean 的位置,是新手翻车的两大现场。