手写一个自定义 Starter:从 0 到被 Boot 自动装载

bee2026-10-0873 分钟0 次阅读
一个能用的 starter 需要几个模块、几个文件、几个注解?从短信服务 Starter 完整实操:自动配置类、条件装配、配置属性绑定、imports 文件与测试验证,一步不落。
1 / 142
小节
〇、30 秒看懂
2 / 142

这一篇讲的不是「怎么用 Spring Boot」,而是怎么自己做一个让别人「一引就生效」的 Spring Boot 插件。你写好一套能力(比如发短信),把它打包成一个 jar;别人只需要在自己的项目里写一段 <dependency> 和几行 yml,就能直接把这个能力当现成组件用——不用他写任何注册代码。这件事在 Spring Boot 里叫 Starter(启动器)。

3 / 142
类比

Starter 就是家里装的中央空调。厂家把主机、遥控器、安装说明书一起塞进一个纸箱发到我家,我只做两件事:插电(在项目里引依赖)、按两下按钮(在 yml 里写几行配置),空调就出风了。我完全不需要懂压缩机怎么绕线、冷媒怎么循环——那些是厂家的活。反过来,如果这个纸箱里只有「一根电源线 + 一张购物清单」,而主机、遥控器全在你家客厅另外三个柜子里,那这个箱子就没帮你省事。这正是本篇第一个硬结论:xxx-spring-boot-starter 这个箱子里只放「购物清单」(pom 依赖),主机和遥控(真正的 Java 代码)全部住在 xxx-spring-boot-autoconfigure 那个模块里。

4 / 142

先把这六个词一次性解释清楚,后文不再重复:

5 / 142
对照表
术语一句话解释
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
6 / 142
架构图
图 · 本篇地图:一个 Starter 由哪几块拼成
图 · 本篇地图:一个 Starter 由哪几块拼成
7 / 142

学完这一篇,你应该能回答三个问题:

8 / 142
  • 我的 Java 代码到底该放 starter 模块还是 autoconfigure 模块?放错了会看到哪句报错?
  • 容器是怎么「知道」我这个第三方配置类存在的?靠哪个文件、路径必须怎么写?
  • 使用方想关掉我的 Starter,或者想用他自己的实现替换我的默认实现,我该留什么口子?
9 / 142
小节
一、starter 的两种形态:一个装依赖,一个装逻辑
10 / 142

引入 spring-boot-starter-web 之后,你一行配置没写,却凭空得到了嵌入式 Tomcat、Spring MVC、Jackson 序列化……很多人的第一反应是「这 starter 真神奇」。但只要打开它的 pom,你会发现一个反直觉的事实:starter 这个 jar 里,一行 Java 代码都没有。

11 / 142

Spring Boot 官方把「starter」拆成了两个职责完全不同的东西:

12 / 142
对照表
形态代表里面装什么一句话定位
依赖描述型(starter)spring-boot-starter-web只有 pom,聚合一堆依赖「买什么」
自动配置型(autoconfigure)spring-boot-autoconfigure一堆 XxxAutoConfiguration 配置类「怎么装」
13 / 142

spring-boot-starter-web 的 pom(精简后)大致是这样:

14 / 142
代码对照
代码xml
<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 只负责「组合出不同套餐」。想清楚这层拆分,你就知道自己的项目该建几个模块。

15 / 142
小节
二、先搭骨架:sms-spring-boot-starter 的目录结构
16 / 142

下面以一个「短信发送」Starter 为例从零做出来。目标很具体:使用方引入依赖、写上几行 yml,就能直接 @Autowired SmsClient。

17 / 142
架构图
图 1 · 一个 Starter 的完整结构
图 1 · 一个 Starter 的完整结构
18 / 142

按官方建议拆成两个模块:

19 / 142
text
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.xml
20 / 142

autoconfigure 模块的 pom,关键是对只用于编译期的依赖使用 optional:

21 / 142
xml
<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>
22 / 142

starter 模块的 pom,只负责把 autoconfigure 打包带走:

23 / 142
代码对照
代码xml
<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.* 提供属性提示与文档。它只该出现在自己的编译期,所以标 optional
  • starter 模块几乎零代码,它存在的价值就是「使用方依赖一个坐标就够」
  • 如果你的 starter 很小、不打算被复用,也可以只建一个模块;但把 autoconfigure 独立出来,是官方推荐的分层方式
24 / 142

两个 pom 该勾哪几项,别去抄别人的——自己生成一遍,顺便看清每一项的 scope 和 optional 是从哪句话来的:

25 / 142
生成器
生成器两个 pom 该勾哪几项:清单装依赖,autoconfigure 装逻辑pom.xml2 / 5
先只勾 Validation 与 Test 生成一次:前者是属性类里 @NotBlank 真正干活的实现,后者给第十八节第三档的 ApplicationContextRunner。再叠上 Redis,按第九节的规范把它做成可选依赖——它就是第五节那句「依赖没引入时整个自动配置直接跳过」在 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>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Validation给了 @Valid 才有人干活;漏掉它,@NotNull 会安静地什么也不校验。
Testscope=test;@SpringBootTest、MockMvc、AssertJ 都在里面,漏了就找不到 @Test。
26 / 142

模块拆完了,四个文件各自管什么?这层「谁管谁」的关系比 pom 更容易记混,所以做成可以一格格点的堆叠图——从下往上点,每抽掉一层,使用方的体验就掉一格:

27 / 142
交互图解
分层四个文件,一层只管一件事1 / 4
从下往上点四格:能力、参数、装配、入口;最后一格是第十节头号翻车现场
→
→
→
SmsClient|能力
只有业务方法,一个 Spring 注解都不带:不写 `@Component`、不写 `@Service`。它是「能力」本身,可测试、可替换——单元测试里直接 new 一个把 props 传进去。让它自己声明成组件,就是使用方再也换不掉你这颗实现的开始(第四节)。
全部看懂了记法:能力不注册、参数不校验等于没配、装配管让路、清单管被看见。
28 / 142
小节
三、第一步:配置属性类
29 / 142

属性类负责把 yml 里的 sms.* 绑定成一个 Java 对象。它是整个 Starter 的「参数面板」:

30 / 142
代码对照
代码java
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,校验不会执行。更隐蔽的是:有人把校验注解加在字段上、却忘了引入校验实现,启动时同样不会报错——配置项为空会一路带到业务代码,直到短信发不出去才被发现。

31 / 142

属性类只是「接水的杯子」,水是怎么流进来的?这段动画把一条 yml 走到 SmsClient 手里的六拍全演了一遍,注意第 ③ 拍:access-key 和 accessKey 是在这一刻对上的。

32 / 142
原理动画
动图 · 一行 yml 是怎么走进 SmsProperties 的
动图 · 一行 yml 是怎么走进 SmsProperties 的
33 / 142
小节
四、第二步:核心服务类 SmsClient
34 / 142

SmsClient 就是这个 Starter 真正对外提供的能力。关键认知:它是一个普通 POJO,不做任何注册。

35 / 142
代码对照
代码java
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 即可
  • 把「能力」和「装配」分开,是写可替换组件的第一原则:能力属于核心类,装配属于自动配置类
36 / 142
小节
五、第三步:自动配置类 SmsAutoConfiguration
37 / 142

这是整个 Starter 的灵魂:它决定「什么时候把 SmsClient 注册进容器」。

38 / 142
java
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);    }}
39 / 142

逐注解解读,这才是读/写自动配置的正确姿势:

40 / 142
  • @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,这里就主动让路;没定义才兜底
41 / 142

上面五条各管一件事,而这五件事正是面试与工单里被追问最多的五处。背不住——来玩一局:先点你写下的那行注解,再点它唯一负责的那件事。

42 / 142
配对闯关
闯关这五个注解,各管哪一件事已配对 0/6 · 配错 0
左列是自动配置类上你会写下的东西,右列是它唯一负责的那件事;其中有一对长得极像,配错的人最多
先点左边一个
43 / 142
小节
六、第四步:注册清单 imports
44 / 142

自动配置类写好了,但容器怎么知道它的存在?答案是一份清单文件。路径必须一字不差:

45 / 142
text
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
46 / 142

文件内容极其简单,一行一个类名,不需要任何 key:

47 / 142
代码对照
代码text
com.example.sms.autoconfigure.SmsAutoConfiguration
解读
  • 这是 Spring Boot 2.7 引入、3.0 起唯一有效的注册方式,取代了旧的 META-INF/spring.factories
  • 文件名本身就是「key」,所以文件名一旦写错,等于清单不存在——见第十节的坑
  • 一个 starter 可以有多个自动配置类,省略包名前缀会被当成同包类解析,所以始终写全限定名最稳妥
48 / 142

有人会问:直接在自动配置类上写 @ComponentScan 把自己的包扫进来,不是更省事?省事是省事,但它把「条件什么时候求值」这件事从容器手里抢回了扫描顺序手里。两条入口的差别就在这张对照图里——同一个类,走左边那条门,@ConditionalOnMissingBean 就可能提前答「没有」:

49 / 142
架构图
图 · 两条入口:扫描 vs 清单
图 · 两条入口:扫描 vs 清单
50 / 142
原理动画
动图 · Starter 被装载的六步
动图 · Starter 被装载的六步
51 / 142
小节
七、第五步:使用方怎么用
52 / 142

Starter 做完,使用方需要做的只有三件事。

53 / 142

第一步,加依赖:

54 / 142
xml
<dependency>    <groupId>com.example</groupId>    <artifactId>sms-spring-boot-starter</artifactId>    <version>1.0.0</version></dependency>
55 / 142

第二步,写配置:

56 / 142
yaml
sms:  enabled: true  endpoint: https://sms.example.com/api  access-key: ak-123456  secret-key: sk-abcdef  sign-name: 某某科技  timeout: 2000
57 / 142

第三步,直接用:

58 / 142
java
@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"));    }}
59 / 142

启动后控制台输出即证明接线成功:

60 / 142
代码对照
代码text
[SMS] endpoint=https://sms.example.com/api sign=某某科技, send to 138****0000 with TPL_LOGIN
解读
  • 使用方全程没有写过一行 @Bean,这正是 starter「开箱即用」的意义
  • 如果使用方把 sms.enabled 设为 false,SmsClient 不会被注册,注入时会报 NoSuchBeanDefinitionException——这是条件生效的直接证据
  • 如果使用方自己定义了 SmsClient,那么自动配置会让路,使用方的实现生效
61 / 142
小节
八、调试:在条件报告里找到自己的自动配置
62 / 142

Starter 「没生效」时,别猜,直接开条件报告:

63 / 142
bash
java -jar app.jar --debug
64 / 142

报告里找到自己的配置类,逐条核对每个条件:

65 / 142
代码对照
代码text
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 清单没被读到
  • 报告里根本不出现你的类,是「清单路径写错」和「条件不满足」的分水岭
66 / 142
小节
九、命名与版本规范
67 / 142

官方对自定义 starter 有明确的命名与发布建议,照着做能少踩很多坑:

68 / 142
对照表
约定建议原因
命名xxx-spring-boot-starter与官方 spring-boot-starter-xxx 区分,一眼看出是第三方
模块拆分starter + autoconfigure 两个模块逻辑可复用、可独立测试
Spring Boot 依赖用 optional 或 provided不要把版本强加给使用方,避免版本冲突
版本依赖深度不要依赖具体 Boot 内部实现类内部类可能在小版本间变动,升级即崩
属性前缀用公司/项目统一前缀(如 sms)避免与官方 spring.* 撞名
文档提供 additional-spring-configuration-metadata.json让 IDE 对自定义属性有提示
69 / 142
要点

面试问「自定义 starter 怎么保证兼容性」,答两点即可——依赖尽量 optional、不由 starter 决定 Boot 版本;只用公开 API,不碰 internal 包下的类。做到这两点,使用方升级 Boot 时你的 starter 基本不用改。

70 / 142
小节
十、两个高频坑
71 / 142
对照表
现象根因解决
自动配置完全没被加载,报告里连类名都没有imports 文件路径写错(少写 spring/、写成了 spring.factories、或漏了包名)严格核对路径 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
默认实现把用户的实现覆盖了 / 或用户实现被忽略@ConditionalOnMissingBean 写在了错误位置或类型参数写错写在提供默认值的 @Bean 方法上,类型写「要判断的那个类型」
72 / 142
坑

imports 文件路径是最常见的错误,没有之一。 它有三个必须记住的细节:目录是 META-INF/spring/(多了一层 spring)、文件名是超长的全限定类名加 .imports、内容里不许写注释也不许有 key= 前缀。很多人的第一个 starter「怎么都不生效」,最后发现只是把文件放到了 META-INF/ 下。

73 / 142
坑

@ConditionalOnMissingBean 的位置是第二个高频错误。 它要写在提供默认实现的 @Bean 方法上,用来表达「用户已经定义了同类型 Bean,我就让路」。如果把它写在配置类级别,会作用于该类所有 Bean;如果类型参数写成父类或另一个类型,判断的对象就变了,表现为「永远命中或永远不命中」——于是默认实现要么重复注册、要么永不生效。

74 / 142
小节
十一、动手体验:看条件如何决定你的 Starter 是否生效
75 / 142

下面这个演示把上面的三个条件做成了可切换开关。试着把「用户已自定义」打开,观察 SmsClient 的注册是如何被跳过的:

76 / 142
内核实验
77 / 142
小节
十二、决策:公司内部要不要自建 starter
78 / 142
决策
决策团队有三个项目都要调用同一个「短信发送 + 日志埋点 + 统一异常」的组合,目前每个项目各复制一份工具类。要不要抽成一个公司内部的 starter?
79 / 142
小节
十三、把整条链路串起来:从「引依赖」到「注入成功」
80 / 142

前面四个文件都写完了,现在把它们按启动顺序连成一条线。这条线是本篇最难自行推导的部分,也是面试里「说说自定义 starter 的原理」的标准答案骨架:

81 / 142
原理动画
动图 · 引依赖到注入成功:五步接线
动图 · 引依赖到注入成功:五步接线
82 / 142
对照表
步发生什么谁负责写错了会怎样
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.importsSpring Boot 自动配置导入机制路径或文件名错 → 你的类根本不在候选名单里
3逐个求值条件注解:@ConditionalOnClass 看类在不在、@ConditionalOnProperty 看开关开不开你的自动配置类任一不通过 → 该类进 Negative matches,Bean 不生成
4条件通过后绑定 yml 到 SmsProperties,再调用 @Bean 方法造出 SmsClient;若使用方已有同类型 Bean,@ConditionalOnMissingBean 让你让路你的 @Bean 方法缺 setter / prefix 写错 → 属性绑不上,字段全为默认值
5使用方的构造器参数一写 SmsClient,容器就把第 4 步造好的那个实例塞进去IoC 容器第 3 步没过 → NoSuchBeanDefinitionException
83 / 142
提示

新手最常见的困惑是「我明明写了自动配置类,它凭什么会被执行?」。答案就在第 2 步——不是靠扫描,是靠一份必须一字不差的清单文件。理解了这一点,你就理解为什么第十节说路径错误是「头号翻车现场」。

84 / 142

上面那张表是「读」的,顺序还是得点。把这五步摊成一次单步执行:左边六行代码,右边同步刷新此刻的变量和调用栈。连点下一步,重点停在第 ② 拍——那一拍决定了后面四拍到底有没有机会发生:

85 / 142
单步调试台
单步台逐行走一遍:从「引入依赖」到「注入成功」1 / 6
六拍。右侧盯「候选清单长度」和「你的类在里面吗」这两格;第 ② 拍是新手最容易彻底想岔的地方
被调试的代码
1// 使用方 pom:<artifactId>sms-spring-boot-starter</artifactId>
2SpringApplication.run(App.class, args) // @SpringBootApplication 里的 @EnableAutoConfiguration
3AutoConfigurationImportSelector.getCandidates() // 逐个 jar 读 META-INF/spring/*.imports
4filter(SmsAutoConfiguration.class) // OnClass / OnProperty 在这里求值
5bind(SmsProperties.class, environment) // 名字归一化 + 类型转换 + @Validated
6smsClient(props) 写下定义 → 使用方构造器拿到那个实例
此刻的变量
使用方写了一行 <dependency>
classpath 上多了starter jar + autoconfigure jar
你的类还没被任何人读过
调用栈
1Maven 依赖解析
2classpath
1第一步跟 Spring 一点关系都没有:Maven 只是把两颗 jar 放进运行时搜索路径。starter 那颗里没有 .class,只有 pom——所以「引依赖」此刻真正的含义是 autoconfigure 那颗 jar 进场了。
86 / 142
小节
十四、内核实验:把这五件事在浏览器里真跑一遍
87 / 142

光看图记不住装配链路,因为它发生在启动的最初几秒、且全程「看不见对象」。下面五个实验在你的浏览器里真实执行 Spring Boot 的那几层,每个都能自己切参数。建议顺序:先 ① 建立整体感,再 ② 看清注册与排序,然后 ③ 逐个搞懂条件注解,④ 弄明白使用方的配置到底怎么进到 SmsProperties,最后 ⑤ 回头确认 SmsProperties 和 SmsClient 究竟是从哪条门进容器的。

88 / 142

第一个实验拆掉整个 Starter 的四根承重柱。元数据(metadata)指的是「描述自己配置的数据」,这里指那份 imports 清单;依次点四个按钮,你会看到「清单存在 → 属性绑上 → Bean 造出来 → 我把开关关掉」的完整过程,最后一个按钮正是第十二节决策卡里「让使用方能优雅降级」的落点:

89 / 142
内核实验
TeaVM一个 Starter 的四根承重柱:清单、属性、Bean、开关未启动
依次点四个按钮;最后点「关掉它」,看容器里 SmsClient 是怎么消失的
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
90 / 142

第二个实验回答第 2 步那个最抽象的问题:候选清单是从哪些 jar 里捞出来的,捞出来之后为什么要排序。「排序与分组」那一步能看到官方自动配置之间的 before / after——这就是第五节强调 @AutoConfiguration 比裸 @Configuration 多出来的能力,你的第三方配置默认排在所有官方配置之后:

91 / 142
内核实验
TeaVM自动配置装配链:候选从哪来、怎么过滤、按什么顺序生效未启动
先看「候选清单从哪来」,再看「装配报告」,对照第八节的 --debug 输出格式
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
92 / 142

第三个实验把第五节那三个条件注解逐个拆开求值。条件注解(conditional annotation)就是「满足条件才装配」的判断开关。切到 @ConditionalOnMissingBean 那一格,你会亲眼看到用户已定义同名 Bean 时默认实现如何被跳过——这正是第十四节沙盘要你手动验证的同一件事:

93 / 142
内核实验
TeaVM条件注解逐个体检:OnClass / OnProperty / OnMissingBean未启动
切到 @ConditionalOnMissingBean,看「用户已经定义了」和「没有定义」两种结果的区别
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
94 / 142

第四个实验解释使用方最常问的一句话:「我在 yml 里写了 access-key,Java 字段叫 accessKey,凭什么对得上?」。松散绑定(relaxed binding)就是 @ConfigurationProperties 允许 kebab-case、camelCase、大写环境变量三种写法命中同一个字段的能力;顺手对比「谁覆盖谁」,能看懂 profile、环境变量、命令行三层的优先级关系:

95 / 142
内核实验
TeaVM配置怎么进到 SmsProperties:优先级、Profile 与松散绑定未启动
切到「谁覆盖谁」,理解为什么服务器上改 yml 常常没生效
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
96 / 142
类比

@ConfigurationProperties 就像酒店房卡。前台(yml + 松散绑定)在你入住那一刻把楼层、房号、有效期一次性写进卡片;此后每次刷开门(业务代码调 props.getEndpoint())都不需要再解释一遍你是谁。房卡配错,后果不是「刷一次失败报你一次错」,而是后面每一次都进不去——这解释了为什么属性绑定必须在启动阶段就校验干净,而不是等发短信时才发现问题。

97 / 142

第五个实验回答的是一句很少被讲清的话:SmsProperties 和 SmsClient 都是 Bean,但它们进容器走的不是同一条门。依次点 scan、bean、import、auto,四条门(组件扫描、@Bean 方法、@Import 直送、清单登记的自动配置)会把各自的时机打给你看——你的属性类靠 @EnableConfigurationProperties 走「直送」,SmsClient 走 @Bean,自动配置类本身走清单。最后点 miss,看清「包没被扫到」和「清单没登记」为什么会报出同一句话:

98 / 142
内核实验
TeaVM两条门进容器:属性类走直送,SmsClient 走 @Bean未启动
先把 scan / bean / import / auto 各点一次,再点 miss,对照第十六节报错表最后两行——同样是「容器里没有」,成因完全不同
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
99 / 142

实验按到这里,可以换成命令行自己敲。下面这台控制台连着浏览器里的同一个内核,回显全部由内核算出来:

100 / 142
内核控制台
101 / 142
说明

cond configLoaded false 之后一定要 restart 再 beans——开关只改配置,不重建容器你不会看到任何变化。这一串动作就是第八节「用报告定位断点」的手工版。

102 / 142
小节
十五、沙盘:Starter 的开关到底怎么配
103 / 142

这个沙盘模拟使用方项目的最终状态。左边两个旋钮:总开关怎么写、使用方有没有自己定义同类型 Bean。右边立刻给出容器里到底有哪个 SmsClient,以及注入处会不会炸。

104 / 142

先把结论摆在这里:一个设计合格的 Starter 必须同时提供「关得掉」(sms.enabled)和「换得掉」(@ConditionalOnMissingBean)两个口子。前者的目的是让使用方在自己的 application.yml 里就能表达意图,后者的目的是让用户自定义永远压过你的默认实现——这是第五节逐注解解读的落点。

105 / 142
沙盘
沙盘开关怎么写 × 用户有没有自定义 Bean
运行结果
Positive matches: SmsAutoConfiguration#smsClient matched
- @ConditionalOnProperty (sms.enabled) matched (OnPropertyCondition)
- @ConditionalOnMissingBean did not find any beans (OnBeanCondition)
容器里 1 个 SmsClient —— 自动配置的那个
因为代码里写了 matchIfMissing = true,所以「不写」等于「开着」。这是开箱即用的默认态。
106 / 142
说明

沙盘里最反直觉的一格是「false + 使用方自定义」。很多人以为关掉开关就等于彻底不用这个 starter,其实用户的 @Bean 属于使用方自己的配置类,跟你的条件毫不相干。真正决定「有没有 Bean」的是两件事:你的条件通不通过、他自己有没有定义。

107 / 142
小节
十六、常见报错速查
108 / 142

下表第一列可以直接整段粘进搜索框,别意译也别缩写;最后一列指出去哪一篇深挖。

109 / 142
对照表
报错原文(片段)真实原因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 组件扫描
110 / 142
坑

整张表里最容易被误诊的是第一行。Cannot resolve symbol 看起来像「Maven 没下下来」,于是使用者花半小时清仓库、reimport,其实问题在你这个 starter 作者这边:代码放错模块了。判断方法很简单——jar tf 列出那个 starter jar,如果里面只有 META-INF/maven/…pom.xml 而没有任何 .class,那就是符合规范的形态(见第二节)。

111 / 142

上面最后两行的区别,只有在真堆栈上点一次才分得清。这段是 starter 作者收到最多的那张工单:依赖引了、yml 写了、Bean 却说找不到。先别看解析——点出你认为的凶手行:

112 / 142
报错急救
报错急救No qualifying bean of type 'com.example.sms.autoconfigure.SmsClient' available
清单没登记:一个从来没进过候选名单的自动配置

使用方把 sms-spring-boot-starter 1.0.0 引进 pom,yml 里 endpoint 也写了,启动却报找不到 SmsClient。他自己跑了 --debug,报告里连 SmsAutoConfiguration 这个名字都搜不到。

APPLICATION FAILED TO START
Field smsClient in com.example.notice.NoticeService required a bean of type 'com.example.sms.autoconfigure.SmsClient' that could not be found:
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:785)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.inject(AutowiredAnnotationBeanPostProcessor.java:659)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1420)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:599)
at com.example.notice.NoticeService.<init>(NoticeService.java:19)
Action:
Consider defining a bean of type 'com.example.sms.autoconfigure.SmsClient' in your configuration.
$ jar tf target/sms-spring-boot-autoconfigure-1.0.0.jar | grep -i imports
META-INF/auto-configuration.imports
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
113 / 142
小节
十七、随堂自测
114 / 142
随堂自测
随堂自测你在做 sms-spring-boot-starter。同事把 SmsClient、SmsProperties、SmsAutoConfiguration 三个类都写进了 sms-spring-boot-starter 这个模块(那个只用来聚合依赖的模块),项目照常编译通过。这样做的后果是什么?
先自己选一个,选中立刻告诉你对不对
115 / 142
随堂自测
随堂自测为了让 SmsAutoConfiguration 被加载,有人提议在它上面加 @ComponentScan("com.example.sms")。为什么自动配置类里不该这么做?
先自己选一个,选中立刻告诉你对不对
116 / 142
小节
十八、动手练习
117 / 142
小节
第一档 · 照做
118 / 142

目标:15 分钟做一个最小可用 starter,把「引依赖 → 生效 → 关掉 → 不生效」四态走一遍。

119 / 142
java
// 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; }}
120 / 142
java
// 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);    }}
121 / 142
java
// EchoService.java —— 只有一个方法,方便第三档扩展package com.example.echo;public interface EchoService {    void echo(String target);}
122 / 142
text
# echo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/# org.springframework.boot.autoconfigure.AutoConfiguration.importscom.example.echo.EchoAutoConfiguration
123 / 142

使用方 application.yml 与预期输出:

124 / 142
yaml
echo:  tag: DEV
125 / 142
text
[DEV] -> 138****0000
126 / 142

验收清单:① 把 echo.enabled 改成 false,注入处应报 NoSuchBeanDefinitionException;② jar tf 看 autoconfigure 包里有 .class 和那份 imports,starter 包里只有 pom;③ 说出「为什么删掉 setter 会导致 tag 变回默认值」。

127 / 142
小节
第二档 · 变体
128 / 142

每个变体只动一处,观察点各不相同:

129 / 142
  1. 给 EchoProperties 加一个 @Min(100) private int delay = 500;,但不加 @Validated。你会观察到:把 echo.delay 写成 50 照样启动成功——这就是第三节的坑,校验静默失效;补上类级别的 @Validated 后再试,启动即报 must be greater than or equal to 100。
  2. 把 imports 文件从 META-INF/spring/ 移到 META-INF/(只挪位置,内容不动)。你会观察到:启动无任何报错,但 EchoService 再也不存在,--debug 报告里连 EchoAutoConfiguration 这个名字都搜不到——这正是第十六节倒数第二行的成因。
  3. 在使用方项目里自己写一个 @Bean EchoService,然后把它所在的配置类分别放在「普通 @Configuration」和「被 @AutoConfiguration 标注」两种位置。你会观察到:前者 @ConditionalOnMissingBean 正常让路,后者容易撞进第十五节沙盘「true + 有一个」那一格,出现两个候选 Bean。
  4. 把 @ConditionalOnProperty 从类级别挪到 @Bean 方法级别,再把 echo.enabled 设为 false。你会观察到:EchoProperties 依然存在(因为 @EnableConfigurationProperties 还在类上),只是 EchoService 不再生成——开关的作用域变小了。
130 / 142
小节
第三档 · 造一个
131 / 142

做一个公司内部 starter:oss-spring-boot-starter,把「阿里云 OSS 上传」包装成开箱即用。

132 / 142
  • 结构必须是两个模块 + 四个文件(属性类、核心服务类、自动配置类、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 时启动失败
133 / 142

验收清单:① 使用方只写 <dependency> + 5 行 yml 即可注入 OssClient;② --debug 报告里能同时看到一条 Positive match 与一条因 enabled=false 产生的 Negative match;③ 故意把 yml 的 access-key 留空,启动立刻失败并点名该字段;④ 使用方定义自己的 OssClient 时,容器里只有他那个(用断言 doesNotHaveBean 或 hasSingleBean 证明);⑤ 把你的 starter 装进一个全新项目,全程不需要写一行 @Bean。

134 / 142
小节
十九、要点自查
135 / 142
自检

不看正文,说出 xxx-spring-boot-starter 与 xxx-spring-boot-autoconfigure 各自装什么、为什么拆两个。如果把 Java 类写进了前者,使用方看到的报错是哪一句?

136 / 142
自检

自动配置类靠哪个文件被容器发现?写出完整路径,并说出旧写法在 Boot 3 上为什么不再生效。

137 / 142
自检

@ConditionalOnClass / @ConditionalOnProperty / @ConditionalOnMissingBean 分别在判断什么?各对应中央空调类比里的哪一幕(插头没电、开关关了、客人自带雨伞)?

138 / 142
自检

@ConditionalOnMissingBean 应该写在类上还是写在提供默认值的 @Bean 方法上?写错会分别造成什么现象?

139 / 142
自检

为什么自动配置类里不要用 @ComponentScan?它破坏的是哪一个前提条件?

140 / 142
自检

使用方在 yml 里写 access-key,你的字段叫 accessKey,凭什么对得上?这个能力的名字叫什么,谁提供的 IDE 提示又来自哪个依赖?

141 / 142
口诀

清单指路、条件把关、房卡配参、让路保客——imports 决定生不生效,条件决定要不要装,@ConfigurationProperties 一次配好参数,@ConditionalOnMissingBean 保证用户永远优先。

142 / 142
总结

一个能用的 starter = 两个模块(starter 装依赖 + autoconfigure 装逻辑)+ 四个文件(属性类、核心服务类、自动配置类、imports 清单)+ 五个注解(@AutoConfiguration、@ConfigurationProperties、@ConditionalOnClass、@ConditionalOnProperty、@ConditionalOnMissingBean)。装配链路是:应用引入依赖 → @EnableAutoConfiguration 触发导入 → 读取 imports 清单 → 条件求值 → 绑定 yml 配置 → 注册 SmsClient。铭记两条铁律:核心服务类是普通 POJO,绝不自己声明成组件;注册清单的路径与 @ConditionalOnMissingBean 的位置,是新手翻车的两大现场。