@SpringBootApplication 三合一注解拆解
上一篇你已经会跑 Hello World 了,但那一行 @SpringBootApplication 里到底塞了什么,你还没拆开看过。这一篇就干这件事:把这一个注解拆成三个职责,再讲清每个职责的边界在哪。为什么值得花一整篇?因为 Boot 项目里最劝退的两类报错——「Bean 找不到」和「404」——九成都不是业务代码写错,而是这三个职责中某一个的作用域没覆盖到你。搞清边界,你就有了排查地图。
@SpringBootApplication 像公司发的三合一门禁卡。刷第一道门(@SpringBootConfiguration)确认「这张卡有权限定义工位」;刷第二道门(@ComponentScan)决定「你自己部门的人能进这层楼」;刷第三道门(@EnableAutoConfiguration)决定「物业配好的会议室、饮水机、打印机要不要启用」。三张卡合在一张上很方便,但走错楼层时你必须知道是哪道闸机拦的你——这就是本篇的全部内容。

学完这一篇,你应该能回答三个问题:
- 「Bean 找不到」到底是哪个注解的作用域没盖住?我怎么从报错里的完整类名反推出来?
scanBasePackages写了之后,启动类自己那个包还扫不扫?exclude/excludeName/spring.autoconfigure.exclude三种关自动配置的手段,各自的适用场合是什么?
新建一个 Spring Boot 工程,写了个 UserService 并打了 @Service,在 UserController 里注入,启动却直接失败:
Description:Field userService in com.example.demo.web.UserController required a bean of type'com.example.demo.service.UserService' that could not be found.类没错、注解没错、编译也通过,为什么容器里就是没有这个 Bean?九成情况下,问题不在 UserService 本身,而在启动类上那个 @SpringBootApplication 把它该扫的包扫丢了。
要弄明白这件事,最好的办法是把这个「三合一」注解拆开看。Spring Boot 3 里它的定义是这样的:
@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上

@SpringBootApplication 里没有一行业务逻辑,它只是「三个注解叠在一起」的语法糖。所谓「入口注解」,本质是用声明的方式告诉容器三件事:去哪里找配置、去哪里扫 Bean、要不要开启自动配置。
把外层剥掉,@SpringBootConfiguration 的定义短得可怜:
@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」,而且排查起来相当反直觉——报错指向的是测试,根因却在某个业务配置类上。
第三个兄弟是自动配置的开关,它的定义同样简洁:
@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。把这两个名字记牢,等你看自动配置源码时会反复遇到它们。
这句话有个能亲手验证的推论:自动配置的入场券是 classpath,不是任何开关。你每勾一个 starter,就是在给一批自动配置类发通行证;exclude 只能拦住已经进门的那些,拦不住「类根本不在」的那一类。所以下面这个生成器请这样做:先只勾 Spring Web,记下产物里那一个依赖;再加 Data JPA 与 MySQL 驱动,数一数多出来的条目,然后想想第四节会看到的那些 Bean 是从哪一条依赖被「放行」进来的;最后勾上 Security——它是第八节里最常被顺手排除的那一个,勾完就顺便看一眼它带来的那串条目。
<?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>注意上面 @SpringBootApplication 里的那个 @ComponentScan:它根本没写 basePackages。没写就意味着用默认值,而默认值只有一句话——
扫描范围 = 启动类所在的那个包,以及它的所有子包。
这句话解释了无数「Bean 找不到」的报错。看下面这张对照表,com.example.demo.service 能不能被扫到,完全取决于启动类摆在哪:
| 启动类位置 | 能否扫到 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 不会被向上扫描 |
把启动类放错位置,是一种极其常见、又极其难查的错误:
// 项目真正的根包是 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要修有两种思路,第一种永远优先:
// ✅ 正例一:把启动类挪到最外层根包package com.example.demo; // 与 service / web / dao 同级// ✅ 正例二:实在不能挪,就显式扩大扫描范围@SpringBootApplication(scanBasePackages = "com.example.demo")public class WebApplication {}@ComponentScan 还支持 excludeFilters,用来把「扫得到但不想注册」的类挑出去:
@SpringBootApplication(excludeFilters = { // 按类型排除 @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = LegacyConfig.class), // 按正则排除整个包 @ComponentScan.Filter(type = FilterType.REGEX, pattern = "com\\.example\\.demo\\.legacy\\..*")})public class DemoApplication {}
上面那张动图给的是「范围」,下面这张对照图给的是「代价」——同一个工程,仅仅把主类往下挪了一层包:

「只向下、不向上」这五个字读起来像常识,但报错现场没人会想起它。把它摊成六行单步执行,右边同步刷新「此刻的基准包」和「候选清单里都有谁」——连点下一步,重点看第 4 步与第 6 步之间那个从没被填进去过的名字:
package com.example.demo.web; // 你把启动类放进了 web 子包@SpringBootApplication // 里面那个 @ComponentScan 没写 basePackagespublic class DemoApplication { /* main */ }// 容器读取注解:默认基准包 = 启动类所在包 = com.example.demo.web// 递归扫描 com.example.demo.web.** → 命中 UserController,登记 BeanDefinition// UserController 的构造器要 UserService —— 它在 com.example.demo.service,不在这棵子树里| 启动类的包 | com.example.demo.web |
| UserService 的包 | com.example.demo.service |
| 两者的关系 | 兄弟包,谁也含不住谁 |
编译期:一切正常scanBasePackages 是替换默认基准包,不是追加。写小了会把原本能扫到的包排除在外;写成 com 这种超大范围,则会拖慢启动、还可能误扫到第三方 jar 里的组件。最稳的做法始终是「把启动类放在根包,一个属性都不写」。
既然扫描规则是「所在包 + 子包」,那么包结构与启动类位置其实是被同一条规则绑定的。最不容易出错的布局长这样:
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)。两种都可以,唯一不变的是:启动类必须待在所有需要被扫描的包的最外层。
自动配置不是越多越好。比如你自己用 @Bean 配好了 DataSource,或者引入了某个 starter 却暂时不想要它带来的安全模块,这时就要「关掉某几条自动配置」。常见做法有三种:
| 方式 | 写法 | 生效范围 | 适用场景 |
|---|---|---|---|
exclude / excludeName 属性 | @SpringBootApplication(exclude = XxxAutoConfiguration.class) | 仅本启动类 | 项目里零散排除一两个 |
spring.autoconfigure.exclude | 配置文件里写全限定类名,逗号分隔 | 全局 | 不想改代码 / 多环境差异 / 写测试 |
| 自定义组合注解 | 自己写一个注解,把 @SpringBootApplication 连同排除清单封装进去 | 团队约定 | 多个服务共享同一套排除策略 |
第一种,直接写在启动类上,编译期就能校验类型:
@SpringBootApplication( exclude = DataSourceAutoConfiguration.class, // 类型方式,可重构、IDE 能跳转 excludeName = "org.springframework.boot.autoconfigure.jdbc.XADataSourceAutoConfiguration")public class DemoApplication {}第三种,把上面这行封装成团队统一的入口注解:
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documented@Inherited@SpringBootConfiguration@EnableAutoConfiguration(exclude = { SecurityAutoConfiguration.class })@ComponentScanpublic @interface ServiceApplication {}// 各服务的启动类只写一行团队约定,排除策略集中在一处维护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 里写:
spring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration第二种手段(写在配置文件里)真正的价值不在「少一行代码」,而在它能按环境分别生效:开发环境排除掉数据源自动配置、生产环境保留,这是注解写法做不到的(注解在编译期就定死了)。所以下面这份 yml 请这样用:先勾「Profile」,看 spring.profiles.active 与分文档写法怎么把上面那两行搬进 --- 之后的段落;再叠「数据源」,对照排除清单与连接池配置同时出现时谁说了算;最后加「日志」,因为 logging.level 是排查自动配置时最常顺手要改的那一项。
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现在回头看第一节那个问题:为什么 @SpringBootApplication(scanBasePackages = "x") 能起作用?毕竟 scanBasePackages 明明是 @ComponentScan 的属性。
答案就是 @AliasFor 这个元注解。它干两件事:声明别名、跨注解转发。@SpringBootApplication 是这样写的:
@AliasFor(annotation = ComponentScan.class, attribute = "basePackages")String[] scanBasePackages() default {};annotation = ComponentScan.class:告诉 Spring,这个属性其实属于@ComponentScanattribute = "basePackages":指明转发到对方的哪个属性上- 于是你在外层写
scanBasePackages,Spring 读到的却是内层的basePackages——两个名字、一个值
正因为有它,Spring Boot 才能把「三个注解」在用户视角压成一个。面试里常被追问「@SpringBootApplication 到底是不是三个注解」,答出「是、而且靠 @AliasFor 把属性透传下去」就足够加分。
@AliasFor 只有两种用法——同一个注解内两个属性互为别名(如 value ↔ path),或者显式指名 annotation = X, attribute = "y" 实现跨注解转发。它是「组合注解」这座大厦的地基。
这条转发链走完六帧,就是下面这段动画:

@SpringBootApplication 上一共挂了四个 @AliasFor,加上 @SpringBootConfiguration 里那一个,正好五个名字要记。背是背不住的——来配一遍:左边是你在启动类上写的属性名,右边点它最终落到哪个注解的哪个属性上。配错了当场告诉你为什么(尤其注意最后两组,那是最常见的翻车点):
单个模块里把启动类放对位置就万事大吉,但一上多模块就容易翻车。典型现场:
project├── user-module (com.example.user) ← 里面有 UserService└── order-module (com.example.order) ← 依赖 user-module,启动类在这里order 模块想注入 user 模块的 UserService,启动却报找不到 Bean。根因还是那条扫描规则:order 的启动类在 com.example.order,它只向下扫 com.example.order.*,而 UserService 在 com.example.user——两个是兄弟包,谁也扫不到谁。
两种修法,看团队约定选一种:
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 包路径共祖 | 各模块统一用 com.example.xxx,启动类提到 com.example | 零额外注解,最自然 | 需要一开始就规划好包名 |
| 显式声明扫描 | @SpringBootApplication(scanBasePackages = {"com.example.order", "com.example.user"}) | 不改包名,见效快 | 每加一个模块就要补一处,易漏 |
| 做成自动配置 | 被依赖模块写 AutoConfiguration.imports 暴露自己的 Bean | 模块真正自洽、可复用 | 需要理解下一章自动配置机制 |
// 方案二的写法:显式列出需要扫描的兄弟模块包@SpringBootApplication(scanBasePackages = { "com.example.order", "com.example.user"})public class OrderApplication {}坑:scanBasePackages 一旦显式声明,就不再自动扫描启动类所在包。也就是说,如果你只写了兄弟模块的包名、忘了把自己模块的包也列进去,本模块的 Bean 反而会集体消失——这种「修好一个、弄坏一批」的情况在多模块里非常典型。
@EnableAutoConfiguration 导入的配置类并不是无条件生效的。下面这个演示把条件求值的过程可视化:每个自动配置类都会被逐条条件「盘问」,通过才留下。试着关掉 classpath 条件,观察自动配置类是怎么被整批跳过的:
再往前追一问:一个对象到底有几种进容器的方式?第一篇讲「扫不扫得到」,这一节讲「一共有几条路」。beanin 把四条路摆在一起:组件扫描、@Bean 方法、@Import 直送、自动配置替你注册;最后一档「扫不到的现场」会原样复现第一节那段 required a bean that could not be found——你会看清它不是「注解失效」,而是那个包从没被打开过。
按钮按够了就换成命令行。下面这台控制台连着浏览器里的同一个内核,回答全部现算——本篇的重点「谁进了容器、谁被拦在门外」正好是它最擅长的三件事:beans 数人、conditions 问条件、di 看注入关系。
按这个顺序敲一遍,你会在第 4 步看到清单里少了一个名字:
boot—— 建容器,读装配日志beans—— 你的userService在不在里面?lab triple scan—— 只保留扫描这一层,看容器里剩下谁lab beanin miss—— 直接复现扫不到的现场conditions—— 自动配置那批类各自被哪条条件放行或拦下di—— 注入关系图,谁等着谁,一眼看出断点在哪
lab triple config 和 lab triple all 要连着敲才有画面——前者容器里几乎没有框架 Bean,后者一次性涌进几十个。中间那两步 scan / auto 各自补上了哪一批,就是本篇第十一节那张分层图的活教材。
补充:如果某个模块希望**彻底自洽、被谁依赖都自动生效**(比如 common 里的通用组件),那更推荐把它做成自动配置(写 `AutoConfiguration.imports`),这正好是下一章的内容。
前面讲的是「它由三个注解组成」。下面这个实验让你逐层关掉,亲眼看到每一层各负责什么——triple 场景模拟的正是 @SpringBootApplication 的装配过程,四个按钮对应四种拆法:
@SpringBootConfiguration(config):只保留「这是一个配置类」。你会看到启动类上的@Bean方法仍然被注册,而@Service和自动配置都不生效——这一层只管「这张卡能不能定义工位」@ComponentScan(scan):加上扫描。此时你自己的UserService/HelloController进来了,但容器里没有DispatcherServlet、没有内嵌 Tomcat 的那些 Bean——说明「扫你的」和「配框架的」是两件事@EnableAutoConfiguration(auto):加上自动配置。一大批框架 Bean 涌进来,beanDefinitionCount从十几个跳到几十个——这就是上一篇那句「为什么什么都不配也能跑」的答案- 三者合力(all):完整还原
@SpringBootApplication,并演示四个@AliasFor如何把scanBasePackages/exclude转发到内层
config 与 all 之间的差值最大,而这个差值几乎全部来自自动配置。把这组数字记住,下一篇讲自动配置时你会立刻接上。
四档点完,把顺序在脑子里叠成一张栈——它正好对应本篇开头那张门禁卡的三道闸。下面这张图一格一格点,每格都写清「这一层放行了谁、拦住了谁」:
光知道「三个注解」还不够,你需要知道它们分别在启动的哪一刻起作用。这三个实验按顺序做,正好覆盖一条完整链路:
第一个实验回到第 16 篇那条时间轴。bootrun 里能看到:@SpringBootApplication 是在「准备容器环境 / 后置处理容器」这两步被读取并生效的,而 Bean 实例化发生在之后的 refresh()。也就是说注解只决定「名单」,不决定「什么时候造对象」——这是很多人混淆的一层:
第二个实验专门盘问条件注解。@ConditionalOnMissingBean(客人没带伞,店里才借一把)这类条件的求值时机和结果,cond 会一条条打给你看;切到 report 还能看到「通过 / 未通过」的分组,和第 18 篇的 --debug 报告格式一致:
第三个实验解决第八节那个「多模块互扫不到」的第三种修法。starter 场景演示的是:被依赖的模块自己写一份 AutoConfiguration.imports,把自己的 Bean 登记进去——这样无论谁依赖它、包名是什么,Bean 都会自动到位,完全不依赖扫描范围:
先看这条动画——它把第四节那段文字变成一条可复述的链路:一个打了 @Service 的类,究竟经过哪五步才变成能被 @Autowired 的对象。「Bean 找不到」九成发生在第 2 步和第 3 步之间:

左边选一个「你以为没问题」的写法,右边直接给出启动结果和你该改哪一行:
scanned: com.example.demo.** -> helloController, userServiceauto-config imported: DispatcherServlet / Tomcat / Jackson …GET /hello -> 200#beanDefinitionCount 约 60+
沙盘第四格值得多做一遍——exclude 掉的往往是「你以为没用」的自动配置,而它其实管着响应字符集。这也是本篇反复强调的方法论:不要凭感觉关自动配置,先 --debug 看它管了什么。
报错原文都能整段粘进搜索框:
| 报错原文(片段) | 真实原因 | 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 里把字符串换成常量引用 | 本篇第七节 |
ConflictingBeanDefinitionException 和「Bean 找不到」是两个相反方向的错——前者是扫得太多(两个同名类都进了范围),后者是扫得太少。看到 conflicts with existing, non-compatible bean definition of same name,第一件事是把报错里的两个全类名读出来,问自己「我为什么要同时扫这两个包」。
上面那张表里第二行是本篇的主场。下面这段是它的真实堆栈——先别看解析,点出你认为的凶手行。这道题的难点在于:栈里没有一个帧指向出错的原因,答案藏在两行文本的全限定名里。
启动类在 com.example.demo.web,UserService 在 com.example.demo.service 并打了 @Service。编译通过,启动即失败。
目标:搭一个「主类在根包 + 三层子包」的最小工程,然后用三种方式亲手制造并修复「Bean 找不到」。
pom.xml(只需 Web 一个 starter):
<?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>三个文件,注意包名层级(src/main/java/com/example/scanlab/):
// 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")); }}// 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"; }}// 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(); }}运行 mvn spring-boot:run,预期启动日志(关键三行):
:: 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@5c8eee2acurl http://localhost:8080/greet 返回 hello from scanned package。
然后故意做错三次,每次只看一个现象:
- 把
ScanlabApplication移到com.example.scanlab.web→ 启动即失败,Description:指向Greeter - 把它移回根包,另在启动类上加
scanBasePackages = "com.example.scanlab.controller"→ 仍然失败,这次缺的是Greeter(controller 进来了、service 出局) - 保持第 2 步,再补成
{"com.example.scanlab.controller", "com.example.scanlab.service"}→ 成功,但你刚刚手动维护了一份扫描清单——体会一下为什么第八节说这是债
验收清单:① 能说出第一、二次失败分别少了哪个 Bean、为什么;② 三次实验的报错原文都截了图或复制存档;③ 最终版本回到「主类在根包、一个属性都不写」。
每个变体只改一处,做完写下观察结论:
- 撞名实验:新建
com.example.scanlab.v1.UserController和com.example.scanlab.v2.UserController(都不写名字)。你会观察到ConflictingBeanDefinitionException,报错里明确给出两个全类名。然后给其中一个写@RestController("v2UserController"),你会观察到 启动恢复。 - excludeFilters 实验:在上一步基础上删掉显式命名,改用
@SpringBootApplication(excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = V1UserController.class))。你会观察到 不用改任何类也能把 v1 挑出去;顺手试试FilterType.REGEX排除整个包。 - exclude 自动配置实验:加
exclude = HttpEncodingAutoConfiguration.class,再请求/greet。你会观察到 Exclusions 段落里出现它(配合--debug),并注意到中文响应的字符集变化——第十三节沙盘最后一格的真人版。 - 唯一性实验:在任意一个
@Configuration类上改加@SpringBootConfiguration,然后跑一个空的@SpringBootTest。你会观察到Found multiple @SpringBootConfiguration,报错指向测试类、根因却在业务代码。
做一个双模块项目 multi-scan:父 pom 聚合 common-module 与 app-module,app-module 里有启动类。要求依次实现三种「让 app 用上 common 的 Bean」的方案,并留下书面比较。
- 方案 A:包路径共祖(两边都在
com.example.xxx下,启动类放在com.example) - 方案 B:
scanBasePackages显式列出两个模块的包 - 方案 C:
common-module自带META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,把自己的配置类登记进去(下一篇会正式讲这个文件),并在 app 侧不加任何扫描配置
验收清单:① 三种方案都能启动并访问同一个接口;② README 里写明每种方案的「新增模块时要改哪里」;③ 故意在方案 B 里漏写一个包,贴出报错并说明为什么它比 A/C 危险;④ 方案 C 里能用 --debug 找到自己那条自动配置的正向匹配记录。
不看上文,说出 @SpringBootApplication 里三个注解各自的一句话职责,以及职责出错时典型的报错长什么样。
scanBasePackages 是「替换」还是「追加」默认基准包?这条规则为什么同时解释了「Bean 找不到」和「本模块 Bean 集体消失」两种相反现象?
@SpringBootConfiguration 和 @Configuration 有什么区别?为什么全局只能有一个前者?
exclude、excludeName、spring.autoconfigure.exclude 三种手段各自的适用场合是什么?编译期可见性怎么影响选择?
为什么说「主类放错层」不能用来关自动配置?正确的替代动作是什么?
主类住根包、清单不追加、配置只一个、关它用 exclude。
@SpringBootApplication 是三个注解的叠加——@SpringBootConfiguration 声明配置类(且全局唯一)、@EnableAutoConfiguration 导入自动配置、@ComponentScan 扫描业务 Bean,四个 @AliasFor 把属性透传下去。真正需要刻进肌肉记忆的只有两条:扫描范围永远是「启动类所在包 + 子包」,所以启动类必须住在最外层根包;多模块要互相可见,就让包路径共祖。把这两条理顺,90% 的「Bean 找不到」都会当场消失。