@SpringBootApplication 三合一注解拆解

bee2026-10-0869 分钟0 次阅读
一个注解顶三个:@SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan。逐层拆开它们,顺便弄清包扫描边界、排除自动配置与启动类的摆放哲学。
1 / 143
小节
〇、30 秒看懂
2 / 143

上一篇你已经会跑 Hello World 了,但那一行 @SpringBootApplication 里到底塞了什么,你还没拆开看过。这一篇就干这件事:把这一个注解拆成三个职责,再讲清每个职责的边界在哪。为什么值得花一整篇?因为 Boot 项目里最劝退的两类报错——「Bean 找不到」和「404」——九成都不是业务代码写错,而是这三个职责中某一个的作用域没覆盖到你。搞清边界,你就有了排查地图。

3 / 143
类比

@SpringBootApplication 像公司发的三合一门禁卡。刷第一道门(@SpringBootConfiguration)确认「这张卡有权限定义工位」;刷第二道门(@ComponentScan)决定「你自己部门的人能进这层楼」;刷第三道门(@EnableAutoConfiguration)决定「物业配好的会议室、饮水机、打印机要不要启用」。三张卡合在一张上很方便,但走错楼层时你必须知道是哪道闸机拦的你——这就是本篇的全部内容。

4 / 143
架构图
图 · 本篇地图:一个注解的元注解结构
图 · 本篇地图:一个注解的元注解结构
5 / 143

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

6 / 143
  • 「Bean 找不到」到底是哪个注解的作用域没盖住?我怎么从报错里的完整类名反推出来?
  • scanBasePackages 写了之后,启动类自己那个包还扫不扫?
  • exclude / excludeName / spring.autoconfigure.exclude 三种关自动配置的手段,各自的适用场合是什么?
7 / 143
小节
一、从一个「Bean 找不到」的报错说起
8 / 143

新建一个 Spring Boot 工程,写了个 UserService 并打了 @Service,在 UserController 里注入,启动却直接失败:

9 / 143
text
Description:Field userService in com.example.demo.web.UserController required a bean of type'com.example.demo.service.UserService' that could not be found.
10 / 143

类没错、注解没错、编译也通过,为什么容器里就是没有这个 Bean?九成情况下,问题不在 UserService 本身,而在启动类上那个 @SpringBootApplication 把它该扫的包扫丢了。

11 / 143

要弄明白这件事,最好的办法是把这个「三合一」注解拆开看。Spring Boot 3 里它的定义是这样的:

12 / 143
代码对照
代码java
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documented@Inherited@SpringBootConfiguration@EnableAutoConfiguration@ComponentScan(excludeFilters = {    @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),    @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)})public @interface SpringBootApplication {    @AliasFor(annotation = EnableAutoConfiguration.class)    Class<?>[] exclude() default {};    @AliasFor(annotation = EnableAutoConfiguration.class)    String[] excludeName() default {};    @AliasFor(annotation = ComponentScan.class, attribute = "basePackages")    String[] scanBasePackages() default {};    @AliasFor(annotation = ComponentScan.class, attribute = "basePackageClasses")    Class<?>[] scanBasePackageClasses() default {};}
解读
  • @SpringBootConfiguration:声明「这是一个配置类」,让启动类本身也能用 @Bean 定义组件
  • @EnableAutoConfiguration:打开自动配置的总开关,把 starter 里的配置类导入进来
  • @ComponentScan:扫描当前包及子包,把你自己写的 @Component / @Service / @Repository / @Controller 收进容器
  • 四个 @AliasFor:把这一个注解上的属性「转发」给里面三个注解,于是你写 @SpringBootApplication(exclude = ...) 时,等价于写在内层的 @EnableAutoConfiguration 上
13 / 143
架构图
图 1 · 一个注解,三层职责
图 1 · 一个注解,三层职责
14 / 143
说明

@SpringBootApplication 里没有一行业务逻辑,它只是「三个注解叠在一起」的语法糖。所谓「入口注解」,本质是用声明的方式告诉容器三件事:去哪里找配置、去哪里扫 Bean、要不要开启自动配置。

15 / 143
小节
二、@SpringBootConfiguration:它真的就是 @Configuration
16 / 143

把外层剥掉,@SpringBootConfiguration 的定义短得可怜:

17 / 143
代码对照
代码java
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documented@Configuration@Indexedpublic @interface SpringBootConfiguration {    @AliasFor(annotation = Configuration.class)    boolean proxyBeanMethods() default true;}
解读
  • 核心就是 @Configuration。也就是说,启动类天生就是一个配置类,你完全可以在上面直接写 @Bean 方法
  • @Indexed 是给「组件索引」用的:编译期生成一份 META-INF/spring.components 清单,扫描时不必逐个打开 class 文件,启动能快一点;少了它也不影响正确性
  • 它额外背负一个唯一性约束:Spring Boot 要求整个应用只有一个 @SpringBootConfiguration。因为 @SpringBootTest 会靠它来定位「主配置类」,有两个就会直接报「Found multiple @SpringBootConfiguration」

坑:不要在自己写的 Config 类上再随手加 @SpringBootConfiguration。想声明配置类,用 @Configuration 就够了。多写一个,测试时 @SpringBootTest 会报「found multiple @SpringBootConfiguration」,而且排查起来相当反直觉——报错指向的是测试,根因却在某个业务配置类上。

18 / 143
小节
三、@EnableAutoConfiguration:一行 @Import 的总入口
19 / 143

第三个兄弟是自动配置的开关,它的定义同样简洁:

20 / 143
代码对照
代码java
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documented@Inherited@AutoConfigurationPackage@Import(AutoConfigurationImportSelector.class)public @interface EnableAutoConfiguration {    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";    Class<?>[] exclude() default {};    String[] excludeName() default {};}
解读
  • @AutoConfigurationPackage:把启动类所在包注册为「自动配置的根包」,让 JPA 实体扫描、MyBatis Mapper 扫描等有共同的基准包可依赖
  • @Import(AutoConfigurationImportSelector.class):真正干活的类。它负责读取 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports、逐条做条件过滤、把命中的配置类注册进来——这套链路就是下一篇的主角,这里先记住这个名字
  • spring.boot.enableautoconfiguration=false:全局关掉自动配置的总闸,一般只在写测试或排查问题时临时用
  • exclude / excludeName:精确关掉某几个自动配置类,第六节会展开

提示:@EnableAutoConfiguration = 「开关」+「选择器」。开关是它自己(一个 @Import),选择器是 AutoConfigurationImportSelector。把这两个名字记牢,等你看自动配置源码时会反复遇到它们。

21 / 143

这句话有个能亲手验证的推论:自动配置的入场券是 classpath,不是任何开关。你每勾一个 starter,就是在给一批自动配置类发通行证;exclude 只能拦住已经进门的那些,拦不住「类根本不在」的那一类。所以下面这个生成器请这样做:先只勾 Spring Web,记下产物里那一个依赖;再加 Data JPA 与 MySQL 驱动,数一数多出来的条目,然后想想第四节会看到的那些 Bean 是从哪一条依赖被「放行」进来的;最后勾上 Security——它是第八节里最常被顺手排除的那一个,勾完就顺便看一眼它带来的那串条目。

22 / 143
生成器
生成器你勾的每个 starter,都是一批自动配置的入场券pom.xml1 / 9
只勾当前真正需要的那几项。第三节那句话在这里可以直接验证:勾得越多,被评估的条件越多,启动越慢,可排除的东西也越多
产物
<?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-web</artifactId>
        </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,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
23 / 143
小节
四、@ComponentScan:Bean 找不到的头号根因
24 / 143

注意上面 @SpringBootApplication 里的那个 @ComponentScan:它根本没写 basePackages。没写就意味着用默认值,而默认值只有一句话——

25 / 143

扫描范围 = 启动类所在的那个包,以及它的所有子包。

26 / 143

这句话解释了无数「Bean 找不到」的报错。看下面这张对照表,com.example.demo.service 能不能被扫到,完全取决于启动类摆在哪:

27 / 143
对照表
启动类位置能否扫到 com.example.demo.service原因
com.example.demo.DemoApplication能service 是 demo 的子包
com.example.DemoApplication能service 仍在 demo 之下
com.example.demo.web.WebApplication不能service 是 web 的兄弟包
com.example.demo.web.admin.AdminApp不能上层 demo 不会被向上扫描
28 / 143

把启动类放错位置,是一种极其常见、又极其难查的错误:

29 / 143
java
// 项目真正的根包是 com.example.demo// ❌ 反例:启动类被放进了 web 子包package com.example.demo.web;@SpringBootApplicationpublic class DemoApplication {          // 只会扫 com.example.demo.web.*    public static void main(String[] args) {        SpringApplication.run(DemoApplication.class, args);    }}// 而 com.example.demo.service.UserService 上有 @Service// → 不在 web 包下,永远扫不到 → 注入时报 NoSuchBeanDefinition
30 / 143

要修有两种思路,第一种永远优先:

31 / 143
java
// ✅ 正例一:把启动类挪到最外层根包package com.example.demo;      // 与 service / web / dao 同级// ✅ 正例二:实在不能挪,就显式扩大扫描范围@SpringBootApplication(scanBasePackages = "com.example.demo")public class WebApplication {}
32 / 143

@ComponentScan 还支持 excludeFilters,用来把「扫得到但不想注册」的类挑出去:

33 / 143
java
@SpringBootApplication(excludeFilters = {    // 按类型排除    @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = LegacyConfig.class),    // 按正则排除整个包    @ComponentScan.Filter(type = FilterType.REGEX, pattern = "com\\.example\\.demo\\.legacy\\..*")})public class DemoApplication {}
34 / 143
原理动画
动图 · 包扫描的边界
动图 · 包扫描的边界
35 / 143

上面那张动图给的是「范围」,下面这张对照图给的是「代价」——同一个工程,仅仅把主类往下挪了一层包:

36 / 143
架构图
图 · 启动类住在哪一层
图 · 启动类住在哪一层
37 / 143

「只向下、不向上」这五个字读起来像常识,但报错现场没人会想起它。把它摊成六行单步执行,右边同步刷新「此刻的基准包」和「候选清单里都有谁」——连点下一步,重点看第 4 步与第 6 步之间那个从没被填进去过的名字:

38 / 143
单步调试台
单步台逐行走一遍:@Service 明明写了,容器里为什么没有它1 / 6
六拍走完,盯住右侧「基准包」和「候选清单」两格——第 4 步定下的基准包,决定了第 6 步那份清单里有没有 service
被调试的代码
1package com.example.demo.web; // 你把启动类放进了 web 子包
2@SpringBootApplication // 里面那个 @ComponentScan 没写 basePackages
3public class DemoApplication { /* main */ }
4// 容器读取注解:默认基准包 = 启动类所在包 = com.example.demo.web
5// 递归扫描 com.example.demo.web.** → 命中 UserController,登记 BeanDefinition
6// UserController 的构造器要 UserService —— 它在 com.example.demo.service,不在这棵子树里
此刻的变量
启动类的包com.example.demo.web
UserService 的包com.example.demo.service
两者的关系兄弟包,谁也含不住谁
调用栈
1编译期:一切正常
1包声明只是目录结构。到这一步为止没有任何错误——这也是这类 bug 难查的第一层原因:javac 不认识扫描规则,它只认 import。
39 / 143
注意

scanBasePackages 是替换默认基准包,不是追加。写小了会把原本能扫到的包排除在外;写成 com 这种超大范围,则会拖慢启动、还可能误扫到第三方 jar 里的组件。最稳的做法始终是「把启动类放在根包,一个属性都不写」。

40 / 143
小节
五、包结构最佳实践:启动类永远住在最外层
41 / 143

既然扫描规则是「所在包 + 子包」,那么包结构与启动类位置其实是被同一条规则绑定的。最不容易出错的布局长这样:

42 / 143
代码对照
代码text
com.example.demo├── DemoApplication.java        ← 启动类放最外层(根包的直接子节点)├── config/                     ← 配置类│   ├── WebConfig.java│   └── SecurityConfig.java├── controller/                 ← Web 层│   └── UserController.java├── service/                    ← 业务层│   ├── UserService.java│   └── impl/UserServiceImpl.java├── mapper/                     ← 数据层│   └── UserMapper.java├── domain/                     ← 领域模型│   └── User.java└── common/                     ← 公共组件:工具、异常、常量    ├── Result.java    └── GlobalExceptionHandler.java
解读
  • 启动类位于根包 → 上面所有子包(config / controller / service / mapper / common)统统落进扫描范围,一个都不会漏
  • 你自己写的 @Configuration 也在 config 子包里,同样会被自动收集,不必手动 @Import

要点:包结构可以按「技术分层」(controller/service/mapper)组织,也可以按「业务域」划分(user/order/pay 各含自己的 controller、service)。两种都可以,唯一不变的是:启动类必须待在所有需要被扫描的包的最外层。

43 / 143
小节
六、排除自动配置:三种手段,各有场合
44 / 143

自动配置不是越多越好。比如你自己用 @Bean 配好了 DataSource,或者引入了某个 starter 却暂时不想要它带来的安全模块,这时就要「关掉某几条自动配置」。常见做法有三种:

45 / 143
对照表
方式写法生效范围适用场景
exclude / excludeName 属性@SpringBootApplication(exclude = XxxAutoConfiguration.class)仅本启动类项目里零散排除一两个
spring.autoconfigure.exclude配置文件里写全限定类名,逗号分隔全局不想改代码 / 多环境差异 / 写测试
自定义组合注解自己写一个注解,把 @SpringBootApplication 连同排除清单封装进去团队约定多个服务共享同一套排除策略
46 / 143

第一种,直接写在启动类上,编译期就能校验类型:

47 / 143
java
@SpringBootApplication(    exclude = DataSourceAutoConfiguration.class,     // 类型方式,可重构、IDE 能跳转    excludeName = "org.springframework.boot.autoconfigure.jdbc.XADataSourceAutoConfiguration")public class DemoApplication {}
48 / 143

第三种,把上面这行封装成团队统一的入口注解:

49 / 143
java
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documented@Inherited@SpringBootConfiguration@EnableAutoConfiguration(exclude = { SecurityAutoConfiguration.class })@ComponentScanpublic @interface ServiceApplication {}
50 / 143
代码对照
代码java
// 各服务的启动类只写一行团队约定,排除策略集中在一处维护package com.example.order;@ServiceApplicationpublic class OrderApplication {    public static void main(String[] args) {        SpringApplication.run(OrderApplication.class, args);    }}
解读

提示:exclude 与 excludeName 的区别只在「编译期可见性」——类在 classpath 上就用 exclude(IDE 能重构),不可见(比如可选依赖)才退回字符串形式的 excludeName。配置文件方式(第二种)则在 application.yml 里写:

51 / 143
yaml
spring:  autoconfigure:    exclude:      - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
52 / 143

第二种手段(写在配置文件里)真正的价值不在「少一行代码」,而在它能按环境分别生效:开发环境排除掉数据源自动配置、生产环境保留,这是注解写法做不到的(注解在编译期就定死了)。所以下面这份 yml 请这样用:先勾「Profile」,看 spring.profiles.active 与分文档写法怎么把上面那两行搬进 --- 之后的段落;再叠「数据源」,对照排除清单与连接池配置同时出现时谁说了算;最后加「日志」,因为 logging.level 是排查自动配置时最常顺手要改的那一项。

53 / 143
生成器
生成器哪些排除该写进配置文件,而不是写在注解上application.yml1 / 4
勾「Profile」看同一份文件怎么按环境给不同答案;再勾「数据源」与「日志」,对照第六节那张表想清楚:exclude 属于代码,还是属于环境?
产物
server:
  port: 8080

spring:
  application:
    name: demo-service

---
spring:
  config:
    activate:
      on-profile: prod
logging:
  level: { root: WARN }
---
spring:
  config:
    activate:
      on-profile: dev
spring:
  jpa:
    show-sql: true
勾了这些,代价与理由在这里
profiles + 分档配置多文档块用 --- 分隔,spring.config.activate.on-profile 指定生效条件。
54 / 143
小节
七、@AliasFor:一行注解为什么等于另一行注解
55 / 143

现在回头看第一节那个问题:为什么 @SpringBootApplication(scanBasePackages = "x") 能起作用?毕竟 scanBasePackages 明明是 @ComponentScan 的属性。

56 / 143

答案就是 @AliasFor 这个元注解。它干两件事:声明别名、跨注解转发。@SpringBootApplication 是这样写的:

57 / 143
代码对照
代码java
@AliasFor(annotation = ComponentScan.class, attribute = "basePackages")String[] scanBasePackages() default {};
解读
  • annotation = ComponentScan.class:告诉 Spring,这个属性其实属于 @ComponentScan
  • attribute = "basePackages":指明转发到对方的哪个属性上
  • 于是你在外层写 scanBasePackages,Spring 读到的却是内层的 basePackages——两个名字、一个值
58 / 143

正因为有它,Spring Boot 才能把「三个注解」在用户视角压成一个。面试里常被追问「@SpringBootApplication 到底是不是三个注解」,答出「是、而且靠 @AliasFor 把属性透传下去」就足够加分。

59 / 143
要点

@AliasFor 只有两种用法——同一个注解内两个属性互为别名(如 value ↔ path),或者显式指名 annotation = X, attribute = "y" 实现跨注解转发。它是「组合注解」这座大厦的地基。

60 / 143

这条转发链走完六帧,就是下面这段动画:

61 / 143
原理动画
动图 · 一行属性是怎么转发进内层注解的
动图 · 一行属性是怎么转发进内层注解的
62 / 143

@SpringBootApplication 上一共挂了四个 @AliasFor,加上 @SpringBootConfiguration 里那一个,正好五个名字要记。背是背不住的——来配一遍:左边是你在启动类上写的属性名,右边点它最终落到哪个注解的哪个属性上。配错了当场告诉你为什么(尤其注意最后两组,那是最常见的翻车点):

63 / 143
配对闯关
闯关外层属性名 ↔ 它真正改动的内层注解已配对 0/6 · 配错 0
六组都是名字到归属的硬映射,两列都打乱了;靠顺序猜不了
先点左边一个
64 / 143
小节
八、坑:多模块项目扫不到兄弟模块
65 / 143

单个模块里把启动类放对位置就万事大吉,但一上多模块就容易翻车。典型现场:

66 / 143
text
project├── user-module    (com.example.user)    ← 里面有 UserService└── order-module   (com.example.order)   ← 依赖 user-module,启动类在这里
67 / 143

order 模块想注入 user 模块的 UserService,启动却报找不到 Bean。根因还是那条扫描规则:order 的启动类在 com.example.order,它只向下扫 com.example.order.*,而 UserService 在 com.example.user——两个是兄弟包,谁也扫不到谁。

68 / 143

两种修法,看团队约定选一种:

69 / 143
对照表
方案做法优点代价
包路径共祖各模块统一用 com.example.xxx,启动类提到 com.example零额外注解,最自然需要一开始就规划好包名
显式声明扫描@SpringBootApplication(scanBasePackages = {"com.example.order", "com.example.user"})不改包名,见效快每加一个模块就要补一处,易漏
做成自动配置被依赖模块写 AutoConfiguration.imports 暴露自己的 Bean模块真正自洽、可复用需要理解下一章自动配置机制
70 / 143
代码对照
代码java
// 方案二的写法:显式列出需要扫描的兄弟模块包@SpringBootApplication(scanBasePackages = {    "com.example.order",    "com.example.user"})public class OrderApplication {}
解读

坑:scanBasePackages 一旦显式声明,就不再自动扫描启动类所在包。也就是说,如果你只写了兄弟模块的包名、忘了把自己模块的包也列进去,本模块的 Bean 反而会集体消失——这种「修好一个、弄坏一批」的情况在多模块里非常典型。

71 / 143
小节
九、动手体验:条件装配如何决定 Bean 的去留
72 / 143

@EnableAutoConfiguration 导入的配置类并不是无条件生效的。下面这个演示把条件求值的过程可视化:每个自动配置类都会被逐条条件「盘问」,通过才留下。试着关掉 classpath 条件,观察自动配置类是怎么被整批跳过的:

73 / 143
内核实验
74 / 143

再往前追一问:一个对象到底有几种进容器的方式?第一篇讲「扫不扫得到」,这一节讲「一共有几条路」。beanin 把四条路摆在一起:组件扫描、@Bean 方法、@Import 直送、自动配置替你注册;最后一档「扫不到的现场」会原样复现第一节那段 required a bean that could not be found——你会看清它不是「注解失效」,而是那个包从没被打开过。

75 / 143
内核实验
TeaVM一个对象进容器的四条路,和那条走不通的未启动
先看清四条路各自在什么时候读到你的类,再停在最后一档:同一个 @Service,只是包位置不同,就一条路都不通
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
76 / 143

按钮按够了就换成命令行。下面这台控制台连着浏览器里的同一个内核,回答全部现算——本篇的重点「谁进了容器、谁被拦在门外」正好是它最擅长的三件事:beans 数人、conditions 问条件、di 看注入关系。

77 / 143

按这个顺序敲一遍,你会在第 4 步看到清单里少了一个名字:

78 / 143
  1. boot —— 建容器,读装配日志
  2. beans —— 你的 userService 在不在里面?
  3. lab triple scan —— 只保留扫描这一层,看容器里剩下谁
  4. lab beanin miss —— 直接复现扫不到的现场
  5. conditions —— 自动配置那批类各自被哪条条件放行或拦下
  6. di —— 注入关系图,谁等着谁,一眼看出断点在哪
79 / 143
内核控制台
80 / 143
说明

lab triple config 和 lab triple all 要连着敲才有画面——前者容器里几乎没有框架 Bean,后者一次性涌进几十个。中间那两步 scan / auto 各自补上了哪一批,就是本篇第十一节那张分层图的活教材。

81 / 143
小节
十、决策:多模块项目到底怎么组织包结构
82 / 143
决策
决策你要新起一个 6 个模块的后端项目(user / order / pay / notify / common / gateway),团队打算先把包结构定死,避免以后互相扫不到 Bean。下面哪种方案最该选?
83 / 143

补充:如果某个模块希望**彻底自洽、被谁依赖都自动生效**(比如 common 里的通用组件),那更推荐把它做成自动配置(写 `AutoConfiguration.imports`),这正好是下一章的内容。

84 / 143
小节
十一、动手体验一:把三合一一层层剥开看
85 / 143

前面讲的是「它由三个注解组成」。下面这个实验让你逐层关掉,亲眼看到每一层各负责什么——triple 场景模拟的正是 @SpringBootApplication 的装配过程,四个按钮对应四种拆法:

86 / 143
  • @SpringBootConfiguration(config):只保留「这是一个配置类」。你会看到启动类上的 @Bean 方法仍然被注册,而 @Service 和自动配置都不生效——这一层只管「这张卡能不能定义工位」
  • @ComponentScan(scan):加上扫描。此时你自己的 UserService / HelloController 进来了,但容器里没有 DispatcherServlet、没有内嵌 Tomcat 的那些 Bean——说明「扫你的」和「配框架的」是两件事
  • @EnableAutoConfiguration(auto):加上自动配置。一大批框架 Bean 涌进来,beanDefinitionCount 从十几个跳到几十个——这就是上一篇那句「为什么什么都不配也能跑」的答案
  • 三者合力(all):完整还原 @SpringBootApplication,并演示四个 @AliasFor 如何把 scanBasePackages / exclude 转发到内层
87 / 143
内核实验
TeaVM把 @SpringBootApplication 一层层剥开:config → scan → auto → all未启动
按顺序点四个按钮,每点一次记下右侧 beanDefinitionCount 的变化——那个跳变就是各层的贡献
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
88 / 143
要点

config 与 all 之间的差值最大,而这个差值几乎全部来自自动配置。把这组数字记住,下一篇讲自动配置时你会立刻接上。

89 / 143

四档点完,把顺序在脑子里叠成一张栈——它正好对应本篇开头那张门禁卡的三道闸。下面这张图一格一格点,每格都写清「这一层放行了谁、拦住了谁」:

90 / 143
交互图解
分层三道闸机叠起来才是 @SpringBootApplication(点着看)1 / 5
从上往下点五格。第 ② 与第 ③ 之间的分界线,就是「你的 Bean」和「框架的 Bean」的分界线
→
→
→
→
① @SpringBootConfiguration:这张卡有资格定义工位
最薄的一层,本质就是 `@Configuration` 加上一个 `@Indexed`。它只保证一件事:启动类上的 `@Bean` 方法会被当成配置来读。这一层不扫描、不导入,所以只挂它的话,你的 `@Service` 一个都进不来。顺带说一句它的唯一性约束——全应用只能有一个,否则 `@SpringBootTest` 报 Found multiple @SpringBootConfiguration。
全部看懂了一句话:①管资格,②管你的,③管框架的,④把主类位置递给③,⑤才真的造对象。
91 / 143
小节
十二、动手体验二:三道闸机各自拦住了什么
92 / 143

光知道「三个注解」还不够,你需要知道它们分别在启动的哪一刻起作用。这三个实验按顺序做,正好覆盖一条完整链路:

93 / 143

第一个实验回到第 16 篇那条时间轴。bootrun 里能看到:@SpringBootApplication 是在「准备容器环境 / 后置处理容器」这两步被读取并生效的,而 Bean 实例化发生在之后的 refresh()。也就是说注解只决定「名单」,不决定「什么时候造对象」——这是很多人混淆的一层:

94 / 143
内核实验
TeaVM三合一在哪一步被读取:八步主线里的位置未启动
盯住「准备容器环境」和「后置处理容器」两步,注解的处理就发生在这里
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
95 / 143

第二个实验专门盘问条件注解。@ConditionalOnMissingBean(客人没带伞,店里才借一把)这类条件的求值时机和结果,cond 会一条条打给你看;切到 report 还能看到「通过 / 未通过」的分组,和第 18 篇的 --debug 报告格式一致:

96 / 143
内核实验
TeaVM条件注解逐条求值:谁的 Bean 被拦在了门外未启动
依次点 onclass / onbean / onprop / missing,再看 report 的分组
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
97 / 143

第三个实验解决第八节那个「多模块互扫不到」的第三种修法。starter 场景演示的是:被依赖的模块自己写一份 AutoConfiguration.imports,把自己的 Bean 登记进去——这样无论谁依赖它、包名是什么,Bean 都会自动到位,完全不依赖扫描范围:

98 / 143
内核实验
TeaVM把公共模块做成自动配置:不再依赖扫描未启动
先看 meta(清单登记),再看 props/bean 验证属性绑定与 Bean 生成;最后用 off 关掉它
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
99 / 143
小节
十三、沙盘:三个职责的作用域,改一格看一格
100 / 143

先看这条动画——它把第四节那段文字变成一条可复述的链路:一个打了 @Service 的类,究竟经过哪五步才变成能被 @Autowired 的对象。「Bean 找不到」九成发生在第 2 步和第 3 步之间:

101 / 143
原理动画
动图 · 一个 @Service 是怎么进到容器里的
动图 · 一个 @Service 是怎么进到容器里的
102 / 143

左边选一个「你以为没问题」的写法,右边直接给出启动结果和你该改哪一行:

103 / 143
沙盘
沙盘三合一的作用域:换一种写法,看谁被拦住
运行结果
scanned: com.example.demo.** -> helloController, userService
auto-config imported: DispatcherServlet / Tomcat / Jackson …
GET /hello -> 200
#beanDefinitionCount 约 60+
默认值就是最好的值:主类在根包,扫描覆盖全部业务,自动配置补齐框架。
104 / 143
说明

沙盘第四格值得多做一遍——exclude 掉的往往是「你以为没用」的自动配置,而它其实管着响应字符集。这也是本篇反复强调的方法论:不要凭感觉关自动配置,先 --debug 看它管了什么。

105 / 143
小节
十四、常见报错速查
106 / 143

报错原文都能整段粘进搜索框:

107 / 143
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
org.springframework.context.annotation.ConflictingBeanDefinitionException: Annotation-specified bean name 'userController' for bean class [com.example.demo.v2.UserController] conflicts with existing, non-compatible bean definition of same name and class [com.example.demo.v1.UserController]两个不同包下的同名类都被扫进来了,默认 Bean 名取「类名首字母小写」,于是撞名报错里已给出两个全类名:留一个,或者给其中一个显式命名 @RestController("v2UserController"),或用 excludeFilters 把不要的那个挑出去本篇第四节 excludeFilters
Description: Field xxx in com.example.demo.web.UserController required a bean of type 'com.example.demo.service.UserService' that could not be found.启动类不在根包,@ComponentScan 的「所在包 + 子包」没覆盖到 service对比报错里的类名前缀和启动类的包名;把启动类提到根包最省事本篇第四节 · 第十三节沙盘
404 Not Found 且日志里 Started XxxApplication 一切正常Controller 在扫描范围外,或用了 @Controller 却没配视图解析器按「URL → 包路径 → 注解」三步查;打印 ctx.getBeanDefinitionCount() 看 Controller 到底进没进来第 16 篇第九节 · 第 22 篇
Found multiple @SpringBootConfiguration (通常出现在 @SpringBootTest 里)有人在业务配置类上又加了一个 @SpringBootConfiguration,主配置类不再唯一全局搜这个注解,只保留启动类那一个;其它配置类改回 @Configuration本篇第二节
No qualifying bean of type '...' available: expected single matching bean but found 2同一类型有两个候选,注入时无从挑选(常见于自己又写了一个同类型 Bean)用 @Qualifier("beanName") 指名,或在其中一个上加 @Primary;顺带确认是不是自动配置也给了一个第 6 篇 DI · 第 19 篇条件装配
加了 scanBasePackages 之后本模块 Bean 集体消失显式声明基准包后不再自动扫启动类所在包,而清单里漏了自己把自己模块的包也列进数组;长期解法是包路径共祖本篇第八节
Unsupported source version / 编译期报 @AliasFor attributes must be present in the aliased annotation手写组合注解时 @AliasFor(annotation = X.class, attribute = "y") 里的 y 拼错,或目标注解根本没有该属性打开目标注解源码核对属性名;IDEA 里把字符串换成常量引用本篇第七节
108 / 143
坑

ConflictingBeanDefinitionException 和「Bean 找不到」是两个相反方向的错——前者是扫得太多(两个同名类都进了范围),后者是扫得太少。看到 conflicts with existing, non-compatible bean definition of same name,第一件事是把报错里的两个全类名读出来,问自己「我为什么要同时扫这两个包」。

109 / 143

上面那张表里第二行是本篇的主场。下面这段是它的真实堆栈——先别看解析,点出你认为的凶手行。这道题的难点在于:栈里没有一个帧指向出错的原因,答案藏在两行文本的全限定名里。

110 / 143
报错急救
报错急救NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.demo.service.UserService' available
@Service 明明写了:栈里没有一帧指向包扫描

启动类在 com.example.demo.web,UserService 在 com.example.demo.service 并打了 @Service。编译通过,启动即失败。

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'userController' defined in file [D:\demo\target\classes\com\example\demo\web\UserController.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.demo.service.UserService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:801)
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1375)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1212)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:201)
at com.example.demo.web.DemoApplication.main(DemoApplication.java:23)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
111 / 143
小节
十五、随堂自测
112 / 143
随堂自测
随堂自测你在启动类上写了 `@SpringBootApplication(scanBasePackages = "com.example.order")`,启动类本身位于 `com.example.order`,而公共模块的 Bean 在 `com.example.common`。结果公共模块的 Bean 全都注不上。最准确的原因是?
先自己选一个,选中立刻告诉你对不对
113 / 143
随堂自测
随堂自测同事为了让某个自动配置不生效,直接把启动类挪到了 `com.example.demo.web`,然后说「反正现在启动正常」。这个做法最大的隐患是?
先自己选一个,选中立刻告诉你对不对
114 / 143
小节
十六、动手练习
115 / 143
小节
第一档 · 照做
116 / 143

目标:搭一个「主类在根包 + 三层子包」的最小工程,然后用三种方式亲手制造并修复「Bean 找不到」。

117 / 143

pom.xml(只需 Web 一个 starter):

118 / 143
xml
<?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.2.5</version>        <relativePath/>    </parent>    <groupId>com.example</groupId>    <artifactId>scanlab</artifactId>    <version>0.0.1-SNAPSHOT</version>    <properties>        <java.version>17</java.version>    </properties>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-web</artifactId>        </dependency>    </dependencies>    <build>        <plugins>            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>            </plugin>        </plugins>    </build></project>
119 / 143

三个文件,注意包名层级(src/main/java/com/example/scanlab/):

120 / 143
java
// 1) ScanlabApplication.java —— 包:com.example.scanlab(根包)package com.example.scanlab;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.ConfigurableApplicationContext;@SpringBootApplicationpublic class ScanlabApplication {    public static void main(String[] args) {        ConfigurableApplicationContext ctx =                SpringApplication.run(ScanlabApplication.class, args);        System.out.println("[check] beanDefinitionCount = " + ctx.getBeanDefinitionCount());        System.out.println("[check] greeter = " + ctx.getBean("greeter"));    }}
121 / 143
java
// 2) service/Greeter.java —— 包:com.example.scanlab.service(子包)package com.example.scanlab.service;import org.springframework.stereotype.Service;@Servicepublic class Greeter {    public String greet() {        return "hello from scanned package";    }}
122 / 143
java
// 3) controller/GreetController.java —— 包:com.example.scanlab.controller(子包)package com.example.scanlab.controller;import com.example.scanlab.service.Greeter;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;@RestControllerpublic class GreetController {    private final Greeter greeter;    public GreetController(Greeter greeter) {   // 构造器注入        this.greeter = greeter;    }    @GetMapping("/greet")    public String greet() {        return greeter.greet();    }}
123 / 143

运行 mvn spring-boot:run,预期启动日志(关键三行):

124 / 143
text
 :: Spring Boot ::                (v3.2.5)... o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path ''... com.example.scanlab.ScanlabApplication  : Started ScanlabApplication in 1.4 seconds (process running for 1.7)[check] beanDefinitionCount = 63[check] greeter = com.example.scanlab.service.Greeter@5c8eee2a
125 / 143

curl http://localhost:8080/greet 返回 hello from scanned package。

126 / 143

然后故意做错三次,每次只看一个现象:

127 / 143
  1. 把 ScanlabApplication 移到 com.example.scanlab.web → 启动即失败,Description: 指向 Greeter
  2. 把它移回根包,另在启动类上加 scanBasePackages = "com.example.scanlab.controller" → 仍然失败,这次缺的是 Greeter(controller 进来了、service 出局)
  3. 保持第 2 步,再补成 {"com.example.scanlab.controller", "com.example.scanlab.service"} → 成功,但你刚刚手动维护了一份扫描清单——体会一下为什么第八节说这是债
128 / 143

验收清单:① 能说出第一、二次失败分别少了哪个 Bean、为什么;② 三次实验的报错原文都截了图或复制存档;③ 最终版本回到「主类在根包、一个属性都不写」。

129 / 143
小节
第二档 · 变体
130 / 143

每个变体只改一处,做完写下观察结论:

131 / 143
  1. 撞名实验:新建 com.example.scanlab.v1.UserController 和 com.example.scanlab.v2.UserController(都不写名字)。你会观察到 ConflictingBeanDefinitionException,报错里明确给出两个全类名。然后给其中一个写 @RestController("v2UserController"),你会观察到 启动恢复。
  2. excludeFilters 实验:在上一步基础上删掉显式命名,改用 @SpringBootApplication(excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = V1UserController.class))。你会观察到 不用改任何类也能把 v1 挑出去;顺手试试 FilterType.REGEX 排除整个包。
  3. exclude 自动配置实验:加 exclude = HttpEncodingAutoConfiguration.class,再请求 /greet。你会观察到 Exclusions 段落里出现它(配合 --debug),并注意到中文响应的字符集变化——第十三节沙盘最后一格的真人版。
  4. 唯一性实验:在任意一个 @Configuration 类上改加 @SpringBootConfiguration,然后跑一个空的 @SpringBootTest。你会观察到 Found multiple @SpringBootConfiguration,报错指向测试类、根因却在业务代码。
132 / 143
小节
第三档 · 造一个
133 / 143

做一个双模块项目 multi-scan:父 pom 聚合 common-module 与 app-module,app-module 里有启动类。要求依次实现三种「让 app 用上 common 的 Bean」的方案,并留下书面比较。

134 / 143
  • 方案 A:包路径共祖(两边都在 com.example.xxx 下,启动类放在 com.example)
  • 方案 B:scanBasePackages 显式列出两个模块的包
  • 方案 C:common-module 自带 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,把自己的配置类登记进去(下一篇会正式讲这个文件),并在 app 侧不加任何扫描配置
135 / 143

验收清单:① 三种方案都能启动并访问同一个接口;② README 里写明每种方案的「新增模块时要改哪里」;③ 故意在方案 B 里漏写一个包,贴出报错并说明为什么它比 A/C 危险;④ 方案 C 里能用 --debug 找到自己那条自动配置的正向匹配记录。

136 / 143
小节
十七、要点自查
137 / 143
自检

不看上文,说出 @SpringBootApplication 里三个注解各自的一句话职责,以及职责出错时典型的报错长什么样。

138 / 143
自检

scanBasePackages 是「替换」还是「追加」默认基准包?这条规则为什么同时解释了「Bean 找不到」和「本模块 Bean 集体消失」两种相反现象?

139 / 143
自检

@SpringBootConfiguration 和 @Configuration 有什么区别?为什么全局只能有一个前者?

140 / 143
自检

exclude、excludeName、spring.autoconfigure.exclude 三种手段各自的适用场合是什么?编译期可见性怎么影响选择?

141 / 143
自检

为什么说「主类放错层」不能用来关自动配置?正确的替代动作是什么?

142 / 143
口诀

主类住根包、清单不追加、配置只一个、关它用 exclude。

143 / 143
总结

@SpringBootApplication 是三个注解的叠加——@SpringBootConfiguration 声明配置类(且全局唯一)、@EnableAutoConfiguration 导入自动配置、@ComponentScan 扫描业务 Bean,四个 @AliasFor 把属性透传下去。真正需要刻进肌肉记忆的只有两条:扫描范围永远是「启动类所在包 + 子包」,所以启动类必须住在最外层根包;多模块要互相可见,就让包路径共祖。把这两条理顺,90% 的「Bean 找不到」都会当场消失。