Skip to content

Spring Boot 自动配置与项目结构 ​

S3-009必学进阶约 90 分钟JDK 21企业开发基础 · Spring Boot

1. 本节定位 ​

在 《IOC 与依赖注入》 里,我们手动装配了一个应用——用 @Configuration + @Bean 一个个写出来,容器里总共 9 个 Bean。

本节要做同一件事,但换一种方式:引两个 starter,写一个注解——容器里有 160 个 Bean,其中包括内嵌的 Tomcat、Spring MVC 的前端控制器、JSON 序列化器、参数校验器。

这一个注解替我们做了什么——就是本节的内容。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 @SpringBootApplication 包含的三个注解及各自作用;
  • 说出 starter 的机制(依赖聚合)以及它为什么能"引一个依赖带一堆";
  • 说出自动配置的核心机制(条件注解 + 候选配置类清单);
  • 说出 @ConditionalOnMissingBean 为什么是"自动配置可被覆盖"的关键;
  • 用 --debug 查看自动配置报告,判断"某个自动配置为什么没生效";
  • 说出配置外置的四种来源与优先级顺序;
  • 按规范组织一个 Spring Boot 项目的包结构。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

手写配置的问题不是"难",而是"规模"。

一个真实的 Web 应用需要什么?至少要:内嵌 Tomcat、Spring MVC 的 DispatcherServlet、请求映射器、参数解析器、JSON 序列化器、异常解析器、参数校验器、静态资源处理……几十上百个组件,还要保证它们的装配顺序正确。

在 Spring Boot 之前,这些要么写在 XML 里(几百行),要么写在 @Configuration 里(同样几百行),而且每换一个技术栈就要重写一遍。

Spring Boot 的答案:

xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
java
@SpringBootApplication
public class WebApplication {
    public static void main(String[] args) {
        SpringApplication.run(WebApplication.class, args);
    }
}

两处改动,换来了 160 个 Bean。

5. 核心概念 ​

5.1 @SpringBootApplication 是三合一 ​

java
@SpringBootApplication
// 等价于下面三个:
// @SpringBootConfiguration    → 声明这是一个配置类(本质是 @Configuration)
// @EnableAutoConfiguration    → 开启自动配置(本节的机制)
// @ComponentScan              → 扫描当前包及子包下的 @Component/@Service/@RestController
public class WebApplication {

三者分工:

注解作用对应到 《Spring:IOC 与依赖注入》
@SpringBootConfiguration允许在这里定义 @Bean就是 《Spring:IOC 与依赖注入》 的 AppConfig
@EnableAutoConfiguration自动装配本节新增的部分
@ComponentScan扫描业务组件《Spring:IOC 与依赖注入》 用的是显式 @Bean

@ComponentScan 的一个关键约定:它扫描"启动类所在包及其子包"。

text
cn.kb.learn.web
├── WebApplication.java          ← 启动类放在最外层
├── controller/                  ← 会被扫到 ✓
├── service/                     ← 会被扫到 ✓
├── dto/                         ← 会被扫到 ✓
└── exception/                   ← 会被扫到 ✓

如果启动类放错位置(如 cn.kb.learn.web.app),那么 cn.kb.learn.web.controller 就扫不到——症状是"Bean 找不到"或"接口 404"。

5.2 starter:依赖聚合 ​

xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

这一个依赖带来了什么:

传递依赖作用
spring-boot-starter核心(含 spring-boot-autoconfigure)
spring-boot-starter-tomcat内嵌 Tomcat
spring-web / spring-webmvcSpring MVC
jackson-databindJSON 序列化

starter 的本质是"一组协调过的依赖集合"——它解决的不是"能不能找到依赖",而是"版本之间是否兼容"。

命名约定:

starter场景
spring-boot-starter-webWeb 应用(含内嵌 Tomcat)
spring-boot-starter-validation参数校验
spring-boot-starter-data-jpa / -mybatis数据访问
spring-boot-starter-test测试(JUnit + AssertJ + Mockito + MockMvc)
spring-boot-starter-data-redisRedis

5.3 自动配置的机制 ​

核心问题:Spring Boot 怎么知道"引了 web starter 就该配 Tomcat"?

三步机制:

text
1. @EnableAutoConfiguration 去读一份"候选配置类清单"
   (Spring Boot 3.x:META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,
     Spring Boot 2.x:META-INF/spring.factories)

2. 逐个评估这些候选配置类的条件注解
   (@ConditionalOnClass / @ConditionalOnMissingBean / @ConditionalOnProperty ...)

3. 条件成立的才生效,注册成 Bean

三类最常见的条件注解:

注解含义典型用法
@ConditionalOnClass类路径上有某个类才生效"有 Tomcat 的类 → 才配内嵌容器"
@ConditionalOnMissingBean容器里还没有这个 Bean 才生效"你没自己配,我才给你配默认的"
@ConditionalOnProperty配置项满足条件才生效spring.servlet.multipart.enabled=true

@ConditionalOnMissingBean 是"自动配置可覆盖"的关键:

text
自动配置:if (容器里没有 ObjectMapper) { 注册一个默认的 }
你的代码:@Bean ObjectMapper objectMapper() { ... }     ← 你配了,默认的就不生效

这解释了一个重要现象:为什么"自定义一个 Bean"就能覆盖默认行为——不需要关掉自动配置,只要自己提供一个同类型的 Bean 即可 ✓

这也解释了另一个现象:为什么删掉一个 starter,相关功能就"自然消失"——条件不成立,配置类不加载。

5.4 实测:9 个 Bean 变成 160 个 ​

启动一次,看容器里有什么(StartupReporter 的实现见第 7 节):

text
===== 启动完成 =====
应用名:spring-boot-web
端口:18080
BeanDefinition 总数:160
自动配置带来的关键能力(按类型统计):
  内嵌 Servlet 容器 → 1 个
  DispatcherServlet(前端控制器) → 1 个
  HandlerMapping(请求映射) → 8 个
  ObjectMapper(JSON 序列化) → 1 个
  Validator(参数校验) → 1 个
当前生效的 profile:[]
==================

四个值得注意的点:

输出含义
160 vs 《Spring:IOC 与依赖注入》 的 9自动配置的规模——这 151 个 Bean 是你没写的部分
内嵌 Servlet 容器 1 个引了 web starter,Tomcat 被自动配上
HandlerMapping 8 个不是一个——Spring MVC 注册了多个(请求映射、静态资源、欢迎页……)
profile:[]没有激活任何 profile,用的是默认配置

Tomcat 也确实起来了(启动日志):

text
Tomcat initialized with port 18080 (http)
Starting Servlet engine: [Apache Tomcat/10.1.34]
Tomcat started on port 18080 (http) with context path '/'
Started WebApplication in 4.608 seconds

注意 4.608 seconds:这是整个应用启动的时间——包含 160 个 Bean 的创建。启动慢是因为创建的东西多(《Spring:IOC 与依赖注入》 的 5.5 节讲过"单例在启动时创建")。

5.5 配置外置与优先级 ​

四种配置来源(从低到高,后面的覆盖前面的):

text
1. 代码里的默认值(@Bean 中的硬编码、默认属性)
2. application.yml / application.properties(打包在 jar 里)
3. 外部配置文件(jar 同目录的 config/ 目录、命令行 --spring.config.location)
4. 环境变量与命令行参数(--server.port=9090)

本项目的配置(src/main/resources/application.yml):

yaml
server:
  port: 18080                    # 换端口,避免与本机其他服务冲突

spring:
  application:
    name: spring-boot-web        # 应用名(日志、监控里会用到)
  jackson:
    default-property-inclusion: non_null      # 序列化时忽略 null 字段

logging:
  level:
    cn.kb.learn.web: info

三种覆盖方式(同一个配置项):

方式命令/写法
配置文件server.port: 18080
命令行参数java -jar app.jar --server.port=9090
环境变量SERVER_PORT=9090(大写下划线,Spring 会自动映射)

优先级的意义:jar 打包一次,可以在不同环境用不同配置运行——这就是"同一份构建产物"能部署到测试与生产的原因(T2-017 CI 分层执行与报告归档会展开)。

5.6 profile:按环境切换 ​

yaml
# application.yml(公共配置)
spring:
  application:
    name: spring-boot-web

---
# 开发环境
spring:
  config:
    activate:
      on-profile: dev
logging:
  level:
    cn.kb.learn.web: debug

---
# 生产环境
spring:
  config:
    activate:
      on-profile: prod
logging:
  level:
    cn.kb.learn.web: info

激活方式:

powershell
java -jar app.jar --spring.profiles.active=prod

注意 profile 的边界:

该放 profile不该放 profile
日志级别、连接串、开关业务逻辑分支(if (profile == "prod"))
与部署环境相关的配置密码(用环境变量或密钥管理)

"按 profile 走不同业务逻辑"是反模式——它会让"测试环境通过、生产环境失败"。业务逻辑只应该有一份。

5.7 项目结构规范 ​

text
src/main/java/cn/kb/learn/web/
├── WebApplication.java          ← 启动类放在最外层
├── controller/                  ← 接收请求、参数绑定、返回响应
│   └── OrderController.java
├── service/                     ← 业务逻辑(事务边界通常在这一层)
│   └── OrderService.java
├── repository/ 或 mapper/       ← 数据访问
├── dto/                         ← 对外契约(请求/响应对象)
│   ├── CreateOrderRequest.java
│   ├── OrderResponse.java
│   └── PageResponse.java
├── exception/                   ← 自定义异常与全局处理
│   ├── BusinessException.java
│   ├── ResourceNotFoundException.java
│   ├── ErrorResponse.java
│   └── GlobalExceptionHandler.java
└── config/                      ← 自定义配置类(如拦截器、线程池)

src/main/resources/
├── application.yml
└── (mapper/*.xml 之类的资源)

三条分层原则:

原则说明
Controller 不写业务逻辑它只做"参数绑定 → 调 Service → 包装响应"
DTO 与实体分离不把数据库实体直接返回给前端(S3-REST 的 8.5 节)
异常在边界统一处理Controller 里不写 try-catch,交给 @RestControllerAdvice(《参数校验与全局异常处理》)

5.8 自动配置的调试 ​

当"某个功能没生效"时,第一步是打开自动配置报告:

powershell
java -jar app.jar --debug

报告分两部分:

text
============================
CONDITIONS EVALUATION REPORT
============================

Positive matches:                    ← 生效的自动配置
-----------------
   TomcatWebServerFactoryAutoConfiguration matched:
      - @ConditionalOnClass found required classes 'jakarta.servlet.Servlet', ...

Negative matches:                    ← 未生效的自动配置(含原因)
-----------------
   RedisAutoConfiguration did not match:
      - @ConditionalOnClass did not find required class 'redis.clients.jedis.Jedis'

读法:

问题看哪里
"我引了依赖,为什么功能没生效?"Negative matches 里找对应配置,看 did not match 的原因
"为什么我的自定义 Bean 没被用?"找 @ConditionalOnMissingBean 相关的配置,检查是否与你的 Bean 类型冲突
"到底加载了多少自动配置?"Positive matches 的数量

另一个更轻量的方式:启动时加 --debug 只在需要时用(它输出很多);日常可以在配置里指定:

yaml
logging:
  level:
    org.springframework.boot.autoconfigure: debug

6. 递进示例 ​

6.1 最小示例:一个能跑起来的 Web 应用 ​

java
@SpringBootApplication
public class WebApplication {
    public static void main(String[] args) {
        SpringApplication.run(WebApplication.class, args);
    }
}

就这两行,Tomcat 起来了、Spring MVC 配好了 ✓

6.2 业务示例:观察自动配置的成果 ​

见 5.4 节与第 7 节的 StartupReporter。

6.3 反例:什么都自己配 ​

java
@Configuration
public class MyWebConfig {
    @Bean
    public TomcatServletWebServerFactory tomcatFactory() { /* 几十行 */ }

    @Bean
    public DispatcherServlet dispatcherServlet() { /* ... */ }

    @Bean
    public ObjectMapper objectMapper() { /* ... */ }
    // ... 还有几十个
}

问题:你在重新实现 Spring Boot 已经做好的事——而且大概率做得更差(版本兼容、装配顺序、边界情况)。

正确做法:

需求做法
用默认行为什么都不做
改某个细节只定义那一个 Bean(@ConditionalOnMissingBean 会让默认的失效)
改配置项写进 application.yml
关掉某个自动配置@SpringBootApplication(exclude = XxxAutoConfiguration.class) 或配置项

判断标准:"我要覆盖的是"配置"还是"实现"? 配置写 application.yml,实现提供自己的 Bean——都不需要写一大坨 @Configuration。

7. 完整可运行代码 ​

文件结构(labs/spring-boot-web/):

text
pom.xml
src/main/resources/application.yml
src/main/java/cn/kb/learn/web/
├── WebApplication.java
├── StartupReporter.java          ← 观察自动配置的成果
├── controller/OrderController.java
├── service/OrderService.java
├── dto/{CreateOrderRequest,OrderResponse,PageResponse}.java
└── exception/{BusinessException,ResourceNotFoundException,ErrorResponse,GlobalExceptionHandler}.java

POM 关键部分(不继承 spring-boot-starter-parent,改用 BOM):

xml
<properties>
  <spring-boot.version>3.4.1</spring-boot.version>
</properties>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>${spring-boot.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
</dependencies>

StartupReporter(启动后打印自动配置的成果):

java
@Component
public class StartupReporter implements ApplicationRunner {
    private final ApplicationContext context;
    private final Environment environment;

    public StartupReporter(ApplicationContext context, Environment environment) {
        this.context = context;
        this.environment = environment;
    }

    @Override
    public void run(ApplicationArguments args) {
        System.out.println("BeanDefinition 总数:" + context.getBeanDefinitionCount());
        System.out.println("自动配置带来的关键能力(按类型统计):");
        report("内嵌 Servlet 容器", ServletWebServerFactory.class);
        report("DispatcherServlet(前端控制器)", DispatcherServlet.class);
        report("HandlerMapping(请求映射)", HandlerMapping.class);
        report("ObjectMapper(JSON 序列化)", ObjectMapper.class);
        report("Validator(参数校验)", Validator.class);
    }

    private void report(String label, Class<?> type) {
        System.out.println("  " + label + " → " + context.getBeanNamesForType(type).length + " 个");
    }
}

运行命令:

powershell
cd labs
./mvnw -pl spring-boot-web package -DskipTests
java -jar spring-boot-web/target/spring-boot-web.jar

预期输出(节选):

text
  .   ____          _            __ _ _
 /\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/

2026-09-21T14:21:28.625+08:00  INFO --- [spring-boot-web] [main] cn.kb.learn.web.WebApplication
    : No active profile set, falling back to 1 default profile: "default"
2026-09-21T14:21:31.632+08:00  INFO --- [spring-boot-web] [main] o.s.b.w.embedded.tomcat.TomcatWebServer
    : Tomcat started on port 18080 (http) with context path '/'
2026-09-21T14:21:31.878+08:00  INFO --- [spring-boot-web] [main] cn.kb.learn.web.WebApplication
    : Started WebApplication in 4.608 seconds (process running for 5.655)

===== 启动完成 =====
应用名:spring-boot-web
端口:18080
BeanDefinition 总数:160
自动配置带来的关键能力(按类型统计):
  内嵌 Servlet 容器 → 1 个
  DispatcherServlet(前端控制器) → 1 个
  HandlerMapping(请求映射) → 8 个
  ObjectMapper(JSON 序列化) → 1 个
  Validator(参数校验) → 1 个
当前生效的 profile:[]
==================

这份输出就是本节的全部论证:

数字含义
160自动配置注册的 Bean 总数(《Spring:IOC 与依赖注入》 手写时只有 9 个)
4.608 秒创建这 160 个 Bean 的代价(启动慢、运行快)
Tomcat started on port 18080内嵌容器确实起来了——不需要单独装 Tomcat
HandlerMapping → 8 个Spring MVC 的复杂度被自动配置封装了

8. Debug 观察点 ​

  1. 在 SpringApplication.run(...) 处按 F7 进入:观察 refreshContext → invokeBeanFactoryPostProcessors 的流程——自动配置就是在这里被评估的;
  2. 在 StartupReporter.run 处断点,然后求值 context.getBeanDefinitionNames().length——看那 160 个名字里有多少是你认识的;
  3. 在 getBeanNamesForType(ObjectMapper.class) 处断点:观察返回的 Bean 名(jacksonObjectMapper),它是自动配置注册的;
  4. 搜索 spring-boot-autoconfigure jar 里的 AutoConfiguration.imports:那是一个文本文件,列着所有候选自动配置类的全限定名——这就是"清单"的真身;
  5. 加 --debug 启动,在输出里搜 TomcatWebServerFactoryAutoConfiguration 与 RedisAutoConfiguration——对比 Positive 与 Negative 两部分的措辞。

9. 常见错误 ​

错误 1:启动类放错包,组件扫不到 ​

text
cn.kb.learn.web.app.WebApplication        ← 启动类在子包里
cn.kb.learn.web.controller.OrderController ← 扫不到 ✗

症状:接口 404,或者启动时报"找不到 Bean"。

修复:启动类放在最外层的包(cn.kb.learn.web),或用 @ComponentScan("cn.kb.learn.web") 显式指定。

错误 2:不继承 spring-boot-starter-parent 时漏掉 -parameters ​

这是本项目实际踩到的坑(在 《Spring MVC 请求映射与参数绑定》 会详细展开):

text
java.lang.IllegalArgumentException: Name for argument of type [java.lang.String] not specified,
and parameter name information not available via reflection.
Ensure the compiler uses the '-parameters' flag.

原因:spring-boot-starter-parent 会自动为你开启 -parameters 编译标志;不继承它就得手动配:

xml
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <parameters>true</parameters>
  </configuration>
</plugin>

修复方式二:给每个 @PathVariable / @RequestParam 显式写名字(@PathVariable("orderNo"))——但加编译标志更彻底。

错误 3:以为"引了依赖就一定生效" ​

症状:"我加了 Redis starter,为什么 RedisTemplate 注入不进去?"

排查:

  1. 加 --debug 看 Negative matches——里面会写明原因(如"缺少某个类");
  2. 检查是否排除了自动配置(@SpringBootApplication(exclude = ...));
  3. 检查是否被条件注解挡住(如 @ConditionalOnProperty 的配置项没开)。

错误 4:把业务逻辑写在 profile 分支里 ​

java
if (environment.acceptsProfiles(Profiles.of("prod"))) {
    doProductionLogic();             // ✗ 反模式
} else {
    doTestLogic();
}

后果:"测试环境通过、生产环境失败"——因为分支让两套环境的代码路径不同。

修复:业务逻辑只应该有一份;环境差异只放在配置(连接串、地址、开关)里 ✓

错误 5:把密码写进 application.yml ​

yaml
spring:
  datasource:
    password: 123456               # ✗ 提交进 Git 了

修复:用环境变量或密钥管理服务:

yaml
spring:
  datasource:
    password: ${DB_PASSWORD}       # 从环境变量读

这一条在 《配置管理》 会展开。

10. 动手实验 ​

目标:亲手观察自动配置的机制。

步骤:

  1. 运行第 7 节的命令,核对"160 个 Bean"与五项能力;
  2. 做个减法实验:把 spring-boot-starter-web 从 pom 里注释掉,重新打包启动——观察报错(没有内嵌容器,应用直接退出或找不到 Web 相关类);
  3. 恢复后做个加法实验:加上 spring-boot-starter-validation(本项目已有),观察 Validator 从"0 个"变成"1 个"——验证"引 starter → 自动配置生效";
  4. 覆盖实验:自己定义一个 @Bean ObjectMapper(定制日期格式),然后用 context.getBeanNamesForType(ObjectMapper.class) 观察——默认的那个被替换了(@ConditionalOnMissingBean 生效);
  5. 打开自动配置报告:加 --debug 启动,把输出重定向到文件,统计 Positive matches 与 Negative matches 各有多少条;
  6. 在报告里搜 Redis:找到 RedisAutoConfiguration did not match,读一下它给出的原因。

验收方式:能说出第 2 步的报错、第 4 步"覆盖"的机制。

扩展任务:把 application.yml 拆成 application.yml(公共)+ application-dev.yml + application-prod.yml,用 --spring.profiles.active=dev 启动,验证不同 profile 下日志级别与端口不同。

11. 练习题 ​

基础题

  1. 判断:@SpringBootApplication 等价于 @Configuration + @EnableAutoConfiguration + @ComponentScan。(对 / 错)
  2. 填空:starter 的本质是 ______;自动配置可被用户覆盖的关键注解是 ______。
  3. 简答:为什么"自己定义一个同类型的 Bean"就能覆盖自动配置的默认行为?

代码阅读题

java
package com.example.demo.controller;

import org.springframework.web.bind.annotation.*;

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}
java
package com.example.demo.app;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}
  1. 这段代码能正常工作吗?GET /hello 会返回什么?
  2. 如果不能,如何修复?请给出两种方式。

编程题

  1. 写一个"自动配置观察工具":
    • 用 ApplicationRunner 在启动后打印:
      • BeanDefinition 总数;
      • 按类型统计:内嵌容器、DispatcherServlet、HandlerMapping、ObjectMapper、Validator 各几个;
      • 当前激活的 profile;
      • server.port 的值(从 Environment 读,证明"配置外置生效");
    • 分别在"默认"与"--spring.profiles.active=prod"下启动,对比输出;
    • 说明:为什么用 Environment 读配置比直接读 application.yml 更正确?

综合任务

  1. 为一个"待办事项(Todo)"应用设计 Spring Boot 项目结构:
    • 需求:增删改查待办事项(含标题、截止日期、完成状态);
    • 要求:
      • 给出完整的包结构与每个包里的类名(不用写完整实现);
      • 说明每个类属于哪一层、承担什么职责;
      • 说明为什么 CreateTodoRequest 与 TodoResponse 要分开(而不是共用一个 Todo 类);
      • 说明哪些配置该放 application.yml、哪些该放环境变量、哪些该按 profile 区分;
    • 额外要求:列出这个项目里"最可能被自动配置覆盖"的三个 Bean,以及覆盖它们的方式。
分级提示

提示 1(方向):第 4 题的关键是启动类所在包与 Controller 所在包的关系。

提示 2(关键点):第 6 题的"Environment vs 直接读 yml"——前者包含所有来源(配置文件、命令行参数、环境变量)合并后的结果。

提示 3(接近答案):第 7 题的"请求 DTO 与响应 DTO 分开"的理由:入参与出参的字段集不同(入参有密码、出参有 id 与创建时间)、校验规则只该加在入参上、以及"改一个不影响另一个"。

12. 参考答案 ​

基础题
  1. 对(近似对)。严格来说 @SpringBootApplication 包含的是 @SpringBootConfiguration(@Configuration 的特化)+ @EnableAutoConfiguration + @ComponentScan,还带了若干 @AliasFor 方便你直接配置 exclude 等属性。

同时注意:它还包含 @EnableAutoConfiguration 的 excludeFilters——排除了 TypeExcludeFilter 与 AutoConfigurationExcludeFilter(后者保证"自动配置类不会被组件扫描重复注册")。

  1. 一组协调过的依赖集合(解决版本兼容);@ConditionalOnMissingBean。
  2. 因为自动配置里的关键 Bean 都带了 @ConditionalOnMissingBean:
java
@Bean
@ConditionalOnMissingBean
public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) { ... }

它的判定时机:在你的 @Bean 被注册之后(用户配置优先级更高)✓ 所以:

text
容器里已有你的 ObjectMapper → 条件不成立 → 默认的不注册
容器里没有 → 条件成立 → 注册默认的

这就是"约定优于配置,但配置可覆盖"的机制实现——也是为什么"改一个 Bean"比"关掉整个自动配置"更精确。

代码阅读题(第 4、5 题)

第 4 题:不能正常工作,GET /hello 会返回 404。

原因:DemoApplication 在 com.example.demo.app 包下,而 @ComponentScan 只扫描这个包及其子包——com.example.demo.controller 不在其中 ✗

结果:HelloController 根本没被注册成 Bean,请求自然也匹配不到 ✓

第 5 题:两种修复方式。

方式一:把启动类移到最外层包(推荐)

java
package com.example.demo;              // ← 提到 com.example.demo

@SpringBootApplication
public class DemoApplication { ... }

此时 com.example.demo.controller 在它的子包下 ✓ 会被扫到。

方式二:显式指定扫描范围

java
package com.example.demo.app;

@SpringBootApplication(scanBasePackages = "com.example.demo")
public class DemoApplication { ... }

哪种更好:方式一——它是 Spring Boot 的约定("启动类放在根包"),约定比配置更不容易出错(新同事加了一个 com.example.demo.service 包,方式一自动生效,方式二需要有人记得改配置)。

顺带一个排查技巧:遇到"接口 404 但代码看着没问题",第一件事是确认 Controller 的包在不在启动类的扫描范围内。

编程题(第 6 题)
java
package cn.kb.learn.web;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validator;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.HandlerMapping;

import java.util.Arrays;

@Component
public class AutoConfigInspector implements ApplicationRunner {
    private final ApplicationContext context;
    private final Environment environment;

    public AutoConfigInspector(ApplicationContext context, Environment environment) {
        this.context = context;
        this.environment = environment;
    }

    @Override
    public void run(ApplicationArguments args) {
        System.out.println();
        System.out.println("===== 自动配置观察 =====");
        System.out.println("BeanDefinition 总数:" + context.getBeanDefinitionCount());
        System.out.println("激活的 profile:" + Arrays.toString(environment.getActiveProfiles()));

        report("内嵌 Servlet 容器", ServletWebServerFactory.class);
        report("DispatcherServlet", DispatcherServlet.class);
        report("HandlerMapping", HandlerMapping.class);
        report("ObjectMapper", ObjectMapper.class);
        report("Validator", Validator.class);

        // 关键:端口的值可能来自配置文件、命令行参数或环境变量,Environment 会把它们合并
        System.out.println("server.port = " + environment.getProperty("server.port"));
        System.out.println("配置文件来源:" + Arrays.toString(
                context.getEnvironment().getPropertySources().stream()
                        .map(source -> source.getName())
                        .filter(name -> name.contains("application"))
                        .toArray()));
        System.out.println("=========================");
    }

    private void report(String label, Class<?> type) {
        String[] names = context.getBeanNamesForType(type);
        System.out.println("  " + label + " → " + names.length + " 个"
                + (names.length > 0 && names.length <= 3 ? ":" + Arrays.toString(names) : ""));
    }
}

两种启动方式的对比:

powershell
java -jar target/spring-boot-web.jar
java -jar target/spring-boot-web.jar --spring.profiles.active=prod --server.port=9090

预期差异:

输出项默认加参数后
profile[][prod]
server.port180809090(命令行覆盖了配置文件)
Bean 数量160可能不同(profile 专属配置生效)

为什么用 Environment 而不是直接读 application.yml:

直接读 yml用 Environment
只拿到文件里写的值拿到所有来源合并后的最终值
不知道命令行覆盖反映命令行、环境变量、profile 的全部影响
在测试里难以替换可以在测试里用 @TestPropertySource 注入

一句话:Environment 是 Spring 的"配置视图",它已经帮你做完了优先级合并——你读到的就是"应用真正在用的值" ✓

这个原则可以推广:不要绕过框架去读原始数据源,用它提供的抽象(就像不该绕过 ORM 直接拼 SQL 一样)。

综合任务(第 7 题)

项目结构:

text
src/main/java/com/example/todo/
├── TodoApplication.java                    【启动类】最外层包
├── controller/
│   └── TodoController.java                 【Web 层】参数绑定 → 调 Service → 返回响应
├── service/
│   ├── TodoService.java                    【业务层】业务规则、事务边界
│   └── impl/TodoServiceImpl.java           (接口与实现分离,便于测试)
├── repository/
│   └── TodoRepository.java                 【数据层】数据访问
├── dto/
│   ├── CreateTodoRequest.java              【入参】含校验注解
│   ├── UpdateTodoRequest.java              【入参】
│   └── TodoResponse.java                   【出参】只含该暴露的字段
├── exception/
│   ├── BusinessException.java
│   ├── ResourceNotFoundException.java
│   ├── ErrorResponse.java
│   └── GlobalExceptionHandler.java         【边界】统一异常处理
└── config/
    └── (自定义配置类)

src/main/resources/
├── application.yml                         公共配置
├── application-dev.yml                     开发环境(日志详细、内存库)
└── application-prod.yml                    生产环境(日志收敛、连接池)

各层职责:

类层职责
TodoControllerWeb绑定参数、触发校验、调 Service、组装 ResponseEntity(不写业务逻辑)
TodoServiceService业务规则(如"截止日期不能早于今天")、事务边界
TodoRepository数据持久化
CreateTodoRequestDTO入参契约 + 校验规则
TodoResponseDTO出参契约
GlobalExceptionHandler边界异常 → 状态码 + 统一响应体

为什么 CreateTodoRequest 与 TodoResponse 要分开:

理由说明
字段集不同入参没有 id、createdAt、完成状态(由服务端决定);出参不该有"内部字段"
校验规则只该在入参上给 TodoResponse 加 @NotBlank 毫无意义,反而会让人困惑
演进方向不同前端表单加一个字段 → 改 Request;接口加一个返回字段 → 改 Response——互不影响
安全用同一个类时,很容易"顺手"把敏感字段返回出去(S3-REST 的 8.5 节)

配置的归类:

配置项放哪里理由
server.portapplication.yml(默认可覆盖)与环境无关的默认值
日志级别application-dev.yml / -prod.yml按环境区分
连接串(host/port)环境变量 / 外部配置每套环境不同,且不想打包进 jar
数据库密码环境变量 / 密钥管理绝不能进 Git
业务开关(如"是否启用邮件通知")application.yml是配置,不是环境差异

最可能被自动配置覆盖的三个 Bean:

Bean自动配置给的默认覆盖方式什么情况下要覆盖
ObjectMapperJackson 默认(日期为时间戳、包含 null)定义自己的 @Bean 或用 application.yml 的 spring.jackson.*要统一日期格式、忽略 null
TaskExecutor简单的线程池定义自己的 ThreadPoolTaskExecutor要控制线程数、队列容量、拒绝策略
Validator默认的 Hibernate Validator定义 LocalValidatorFactoryBean(如自定义 MessageInterpolator)要自定义错误消息来源

覆盖的一般方式(三选一):

  1. 定义同类型 Bean(最常用,@ConditionalOnMissingBean 让它自动失效);
  2. 改配置项(如 spring.jackson.date-format);
  3. 排除自动配置(@SpringBootApplication(exclude = XxxAutoConfiguration.class))——最后手段,因为它会让整个自动配置都不生效。

推荐顺序:① 改配置 → ② 覆盖 Bean → ③ 排除自动配置。

13. 掌握标准 ​

  • [ ] 能说出 @SpringBootApplication 包含的三个注解及分工;
  • [ ] 能说出 starter 的本质(依赖集合)与版本管理的价值;
  • [ ] 能说出自动配置的三步机制(候选清单 → 条件评估 → 注册 Bean);
  • [ ] 知道 @ConditionalOnMissingBean 是"可覆盖"的关键;
  • [ ] 会用 --debug 看自动配置报告,并能从 Negative matches 读出"为什么没生效";
  • [ ] 知道启动类必须放在最外层包;
  • [ ] 知道不继承 spring-boot-starter-parent 时要手动开启 -parameters;
  • [ ] 能说出配置的四种来源与优先级。

14. 扩展资源 ​

15. 下一步学习 ​

《Spring MVC 请求映射与参数绑定》:一个 HTTP 请求是怎么变成 Java 方法调用的。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引