《Spring Boot 入门》4.1 @SpringBootApplication 拆解

从「我什么都没配,Tomcat 怎么就起来了」这个疑问出发,逐层拆开 @SpringBootApplication 背后的三个注解:@SpringBootConfiguration、@EnableAutoConfiguration 与 @ComponentScan,用等价代码验证,并给出包扫描与 exclude 的常见误用与排查方法。

本节目标:拆开 @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 自动配置如何生效 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计