本节目标:拆开
@SpringBootApplication,搞清楚它到底替你做了哪三件事,并用等价代码亲手验证一遍。
适用版本:Spring Boot 4.1.x(Java 21)
从一个让人不安的疑问说起
第 3 章我们只写了两个文件:一个 pom.xml,一个 ProbeApplication.java。启动类里除了一个 main 方法,几乎什么都没有:
package com.example.probe;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ProbeApplication {
public static void main(String[] args) {
SpringApplication.run(ProbeApplication.class, args);
}
}
可一运行,Tomcat 就监听了 8080,DispatcherServlet 也初始化好了:
2026-10-09T15:42:07.027+08:00 INFO 43496 --- [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
2026-10-09T15:42:07.039+08:00 INFO 43496 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-10-09T15:42:07.059+08:00 INFO 43496 --- [ main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 589 ms
2026-10-09T15:42:07.285+08:00 INFO 43496 --- [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
2026-10-09T15:42:07.840+08:00 INFO 43496 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 0 ms
没有人 new Tomcat(),没有人注册 DispatcherServlet,也没有一行 XML。这不是魔法,秘密全压在启动类头顶那一行注解上。本节的任务,就是把它拆到见底。
三合一的入口注解
@SpringBootApplication 本身不是一个「干活的」注解,它是一层组合。把它的定义展开看(省略了 @AliasFor 之类的细节),骨架大致是这样:
@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,把属性转发给下面三个注解
}
也就是说,@SpringBootApplication 一次性叠加了三个职责:
| 组合出来的注解 | 它的实质 | 负责什么 |
|---|---|---|
@SpringBootConfiguration | @Configuration 的特化 | 声明「这个类是配置类」,供测试框架定位主配置 |
@EnableAutoConfiguration | 一个 @Import 的封装 | 触发自动配置类的批量导入 |
@ComponentScan | 组件扫描 | 扫描启动类所在包及其子包里的 @Component |
三个注解各管一段:@SpringBootConfiguration 告诉容器「我是个配置类」,@ComponentScan 把你自己写的 Controller、Service 收进容器,@EnableAutoConfiguration 则负责把第三方依赖(Tomcat、Jackson、DataSource……)的默认配置塞进来。「Tomcat 自己起来了」这件事,主要归第三个注解。
为什么要合并成一个注解
既然拆开能跑,Spring Boot 为什么还要造 @SpringBootApplication?因为这三个注解总是同时出现在启动类上,且参数之间还有转发关系:
- 少了
@SpringBootConfiguration,@SpringBootTest找不到主配置; - 少了
@EnableAutoConfiguration,Tomcat、Jackson 全不生效; - 少了
@ComponentScan,你自己写的 Controller 不会被注册。
合成一个,既降低了「漏写一个」的概率,也通过 @AliasFor 把三个注解的属性统一到同一个入口。所以你能直接写 @SpringBootApplication(scanBasePackages = ...),而不必分别去改 @ComponentScan。属性转发关系如下:
@SpringBootApplication 属性 | 实际转发给 |
|---|---|
scanBasePackages / scanBasePackageClasses | @ComponentScan 的 basePackages / basePackageClasses |
exclude / excludeName | @EnableAutoConfiguration 的 exclude / excludeName |
nameGenerator | @ComponentScan 的 nameGenerator |
proxyBeanMethods | @SpringBootConfiguration(即 @Configuration) |
记住这张表,当你看到 @SpringBootApplication 上某个属性不知道从哪来时,就知道去翻哪个子注解的文档。
拆开写成三个注解,效果完全一样
既然它只是组合,那把它换回三个独立注解,程序行为应当一模一样。我们改一版:
package com.example.probe;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan
public class ProbeApplication {
public static void main(String[] args) {
SpringApplication.run(ProbeApplication.class, args);
}
}
启动日志与用 @SpringBootApplication 时逐行一致,Started ProbeApplication in 1.101 seconds 照旧。这个实验证明了一件事:@SpringBootApplication 是便利糖,不是必需的黑盒。理解了这一点,后面排查问题时就能精确地只针对某一个子注解下手,而不是对着一整块注解发呆。
@ComponentScan 这里没写参数,它默认扫描「当前类所在的包及其所有子包」——这是理解后面「扫描不到」类问题的一把钥匙,稍后细说。
@SpringBootConfiguration 与普通 @Configuration 的差别
@SpringBootConfiguration 内部标注了 @Configuration,所以它在容器眼里就是配置类。但它多做了一件事:它是 Spring Boot 测试框架用来「找到」应用主配置类的锚点。
当你写 @SpringBootTest 而不指定 classes 时,Spring Boot 会从当前测试类所在包向上查找,找到第一个标注了 @SpringBootConfiguration 的类当作入口。这就是为什么启动类必须带这个注解,而普通业务配置类通常只用 @Configuration。
由此引出一条硬规则:一个应用只应存在一个 @SpringBootConfiguration。 如果你手工再写一个 @SpringBootConfiguration,测试启动时会报「Found multiple @SpringBootConfiguration annotated classes」。普通 @Configuration 可以有很多个,@SpringBootConfiguration 只能有一个。
| 对比项 | @Configuration | @SpringBootConfiguration |
|---|---|---|
| 是否被容器当作配置类 | 是 | 是(内部就是 @Configuration) |
| 能否有多个 | 可以 | 只允许一个 |
| 测试定位主配置 | 不参与 | 参与(@SpringBootTest 的查找锚点) |
| 典型用途 | 业务配置类 | 应用启动类 |
@ComponentScan 与包扫描规则
@ComponentScan 决定「哪些类会被注册成 bean」。默认的扫描起点不是 classpath 根目录,而是启动类所在的包。规则很简单:
- 启动类在
com.example.probe→ 扫描com.example.probe及其所有子包。 - 启动类在
com.example→ 扫描com.example及其所有子包,范围更大。
需要跨包时,用 scanBasePackages 显式扩展:
@SpringBootApplication(scanBasePackages = { "com.example.probe", "com.example.shared" })
public class ProbeApplication {
public static void main(String[] args) {
SpringApplication.run(ProbeApplication.class, args);
}
}
scanBasePackages 是 @ComponentScan 的 basePackages 的别名,作用相同。也可以写成 scanBasePackageClasses = SharedMarker.class,用某个类所在的包作为扫描起点——它比字符串更抗重命名,重构时不会因为包名改了而静默失效。
一个容易踩的点:不要再在同一个类上同时写
@SpringBootApplication和@ComponentScan。两个@ComponentScan会叠加,excludeFilters也可能互相覆盖,行为变得难以预测。需要改扫描范围时,优先用scanBasePackages属性。
exclude 与 excludeName
有时某个自动配置你并不想要。比如不接数据库,却因为 classpath 上存在 JDBC 相关依赖而触发了 DataSourceAutoConfiguration,启动时报「Failed to configure a DataSource」。这时可以把它排除掉:
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
public class ProbeApplication {
// ...
}
exclude 直接引用类字面量,编译期就能校验,改包名时编译报错、不会静默失效,首选它。
excludeName 则接受字符串全限定名,适用于「编译期拿不到那个类」的场景(例如某个自动配置类只在运行期存在于可选依赖里):
@SpringBootApplication(excludeName = {
"org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration"
})
public class ProbeApplication {
// ...
}
代价是字符串写错不会编译报错,只会在启动时静默不生效,排查起来更费劲。能用 exclude 就别用 excludeName。
注意 exclude 只作用于自动配置类,不能用来排除你自己写的 @Component。要排除自己扫描到的组件,得靠 @ComponentScan 的 excludeFilters。
常见误用:启动类放错了包
最典型的一类「我明明写了 Controller,怎么 404」问题,根因都在包结构上。
误用一:启动类在子包,业务代码在父包。
com.example.probe
├── app
│ └── ProbeApplication.java <-- 启动类在这里
└── controller
└── HelloController.java <-- 扫描不到!
启动类在 com.example.probe.app,默认只扫 com.example.probe.app.**,com.example.probe.controller 在它的兄弟目录,不在扫描范围。结果就是 Controller 没注册,请求 404。修复方式是上移启动类,或用 scanBasePackages = "com.example.probe"。
误用二:启动类没有包声明(落在默认包)。
// 没有 package 语句
@SpringBootApplication
public class ProbeApplication { /* ... */ }
类落在默认包时,@ComponentScan 会退化为扫描整个 classpath,可能把不该注册的类扫进来,而且 Spring Boot 通常会直接给出告警甚至启动失败。启动类永远要放在一个有名字的包下。
误用三:手工又加了一个 @SpringBootConfiguration。 测试启动时报「multiple」错误,前面已说明,一个应用只留一个。
误用四:exclude 里写错类。 写了不存在的类编译直接报错,这算幸运;用 excludeName 写错字符串则静默失效,需要用 --debug 看自动配置报告才能发现(详见下一节)。
一个可复现的验证实验
光看结论容易记不住,动手做一遍最牢。按下面四步操作(只改代码与包结构,改完重启即可):
第一步,正常状态。 保持启动类在 com.example.probe,写一个 Controller:
package com.example.probe.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
}
请求 /hello 返回 hello。
第二步,制造故障。 把启动类移到子包 com.example.probe.app,HelloController 留在 com.example.probe.controller,重启应用。
第三步,观察现象。 /hello 返回 404,但启动日志一切正常,没有任何报错——因为「扫描不到」不会报错,只是不注册。这正是这类问题最难查的地方。
第四步,修复。 两种改法任选:把启动类移回顶层包,或加上 scanBasePackages = "com.example.probe"。重启后 404 消失。
这个实验说明:包扫描失败是「静默失败」,不会给你任何报错,只能靠对包结构的理解去定位。
小结
@SpringBootApplication=@SpringBootConfiguration+@EnableAutoConfiguration+@ComponentScan,拆开写效果完全一致。@SpringBootConfiguration是测试框架定位主配置类的锚点,全应用只允许一个。@ComponentScan默认从启动类所在包向下扫描;跨包用scanBasePackages或scanBasePackageClasses。- 排除自动配置优先用
exclude(类字面量),少用excludeName(字符串,易静默失效)。 - 「扫描不到 / 404」问题九成是启动类位置不对,先检查包结构,再谈其他。
但到这里,我们只是确认了「@EnableAutoConfiguration 负责把默认配置塞进来」。它究竟是怎么做到的?为什么恰好是 Tomcat 而不是 Jetty?为什么加一行依赖,行为就变了?这正是下一节要拆的机关。
阅读导航:上一节:3.3 打包成可执行 jar · 下一节:4.2 自动配置如何生效 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。