Spring Boot 4 六步入门实录:从 Hello World 到一个能交付的 Todo API

环境:Java 17、Maven 3.9、Spring Boot 4.1.1、H2。

项目地址:https://gitee.com/Roadinforest/spring-learn

这篇记录的是我用一个小 Todo 项目走完 Spring Boot 核心路径的全过程:5 个接口、12 个自动化测试、可打包成 fat jar、支持多环境配置。文末有一份 Spring Boot 4 的踩坑清单,以及 8 道自测题。

为什么我按"六步"的顺序学

我之前的语言背景是JS和 Go。学 Spring Boot 时最难受的地方,是知识点全都散着:@Autowired 会写,@RestController 会写,@Entity 也会写,但说不清它们为什么存在、彼此什么关系、什么时候该用哪个。

所以这次我没按知识点学,改成按需求演进学。每一步只解决上一步留下的那个具体麻烦:

复制代码
第1步  骨架 + Hello     → 先让一个接口跑起来
第2步  CRUD 三层架构     → 让接口有结构、有分层
第3步  校验 + 异常体系   → 处理"不正常流程"(此时才暴露痛点)
第4步  JPA + 数据库      → 数据活过进程重启
第5步  自动化测试        → 把行为锁进 mvn test
第6步  打包 + 多环境     → 变成能交付的产物

顺序本身有讲究。内存版 Service 不是白写的,它让"重启数据就没了"变成我亲身撞过的一堵墙,而不是教程硬塞给我的需求。第 5 步的测试还回头抓出了第 3 步留下的一个 bug,这种后面修前面的过程,跟我平时改业务代码的节奏是一样的。

版本上多说一句:Spring Boot 4.0 在 2025 年 11 月 20 日 GA,我用的是 4.1.1。网上大部分教程还停在 Boot 2/3,代码直接抄会编译不过,第四节列了我踩到的四个地方。

做出来的是什么

接口

方法 路径 语义 成功状态码 失败
GET /hello 冒烟测试 200 无
GET /todos 列表 200 无
GET /todos/{id} 单个详情 200 404
POST /todos 创建 201,带 Location 头 400
PATCH /todos/{id} 部分更新 200 400 / 404
DELETE /todos/{id} 删除 204,无响应体 404

这几条 REST 惯例我一开始总写错,后来就当成硬性规定记了:创建成功返回 201 而不是 200,并且带一个 Location: /todos/3 指向新资源;删除成功返回 204,别返回 "删除成功" 这种字符串;部分更新用 PATCH 而不是 PUT,因为 PATCH 只改传过来的字段。错误响应体统一成 { "code", "message", "timestamp" }。

目录

复制代码
src/main/java/com/example/todo/
├── TodoApplication.java              # 启动入口
├── controller/
│   ├── HelloController.java          # 第 1 步:第一个接口
│   └── TodoController.java           # 只管 HTTP:解析请求、返回响应
├── service/TodoService.java          # 业务逻辑 + 事务边界
├── repository/TodoRepository.java    # 只声明、不实现的接口
├── model/Todo.java                   # JPA 实体(可变 class)
├── dto/                              # 进出 API 的数据载体(record)
│   ├── CreateTodoRequest.java
│   └── UpdateTodoRequest.java
└── common/
    ├── NotFoundException.java        # 业务异常
    └── GlobalExceptionHandler.java   # 全局统一错误出口

src/main/resources/     application.yml · application-dev.yml · application-prod.yml
src/test/resources/     application.yml(测试库隔离,覆盖主配置)

12 个测试

测试类 层级 数量 启动了什么东西 量级
TodoServiceTest 单元 4 什么都不启动,Repository 是 Mockito 假对象 ~0.01s
TodoControllerTest Web 层 5 只装 Web 切片(Controller + 异常处理器 + Jackson + 校验) 快
TodoIntegrationTest 集成 3 完整容器 + 真实 JPA + 真实 H2 ~3s

怎么选测试层级,我记了一句话:逻辑规则用单元测试,接口行为用 Web 层,关键业务流用集成。

第 1 步:项目骨架和第一个接口

这一步的目标很朴素,就是让一个项目跑起来,同时把"一个 Spring Boot 应用由哪些东西组成"这件事看清楚。

pom.xml 我按三层读,这是当时试出来最快的方法:

xml 复制代码
<!-- ① 身份坐标:三者唯一确定一个项目 -->
<groupId>com.example</groupId>
<artifactId>todo-app</artifactId>
<version>0.0.1-SNAPSHOT</version>

<!-- ② 父工程:Spring Boot 的灵魂,锁定了几百个依赖的推荐版本 -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
</parent>

<!-- ③ 依赖:starter 是"打包套餐",一个 starter-web = Spring MVC + Jackson + 内嵌 Tomcat -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

启动类和一个最小的 Controller:

java 复制代码
@SpringBootApplication  // = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan
public class TodoApplication {
    public static void main(String[] args) {
        SpringApplication.run(TodoApplication.class, args);
    }
}

@RestController  // = @Controller + @ResponseBody,返回值直接序列化成 JSON
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "Hello, Spring!";
    }
}

mvn spring-boot:run,然后 curl http://localhost:8080/hello。

一个请求进来之后走的路:

复制代码
curl /hello → Tomcat(网络层:解析 HTTP、开线程)
           → DispatcherServlet(Spring 的总调度)
           → 按 @GetMapping 找到方法
           → 返回值写入响应体

关于 Tomcat 在哪

我原以为 Tomcat 是装在电脑上的服务器软件,学 Java Web 得先把它装好。后来才发现它就是一个 jar,藏在 starter-web 依赖里。JVM 运行时不带 HTTP 能力,不像 Node 自带 http 模块、Go 自带 net/http,所以 Java 这边把 HTTP 服务器做成了库,Spring Boot 再把它内嵌进应用。

这个认知改过来之后,后面的事就顺了。java -jar 为什么能起一个 Web 服务?因为整个服务器就在那个 jar 里。

另一个我当时搞混的点:spring-boot-starter-parent 并不负责把常用依赖引进来,它只管版本。每个 starter 都得自己显式声明,声明时不用写 version,版本号由 parent 决定。这个区别在第 5 步让我栽了一次,就是下面 Spring Boot 4 那节里的最后一个坑。

怎么验证

bash 复制代码
curl http://localhost:8080/hello
# Hello, Spring!

能返回就说明三件事都成立了:内嵌 Tomcat 起来了,@ComponentScan 扫到了 Controller,Jackson 在工作。

第 2 步:Todo CRUD 和三层架构

一个接口能跑之后,接下来要让它变成一组有结构、也符合 REST 惯例的接口。

分层我按职责切:

复制代码
controller/  → 只管 HTTP:解析请求、返回响应,不写业务规则
service/     → 业务逻辑 + 事务边界
model/       → 领域对象(有身份、有生命周期)
dto/         → 进出 API 的数据载体

依赖注入是这一步最需要建立起来的概念。Controller 从头到尾没 new 过 Service:

java 复制代码
@RestController
@RequestMapping("/todos")
public class TodoController {

    private final TodoService service;

    // Controller 从不 new TodoService(),只声明"我需要一个",
    // 容器启动时自动把 TodoService 单例塞进来。
    public TodoController(TodoService service) {
        this.service = service;
    }
}

官方推荐构造器注入,也就是一个 final 字段加一个构造器。它给我两个好处:换实现时 Controller 不用动,测试时可以塞假对象进去。第二个好处在第 5 步兑现了。

常用的几个注解:

注解 作用
@PathVariable 取 URL 路径参数:/todos/{id} → 方法参数
@RequestBody 请求体 JSON → Java 对象(Jackson 反序列化)
@GetMapping / @PostMapping / @PatchMapping / @DeleteMapping HTTP 方法 + 路径映射

DTO 和 model 为什么要分开?因为 Todo 上有 id、createdAt 这类由服务端生成的字段,不该让客户端传。DTO 把外部输入输出和内部模型隔开,两边可以各改各的。

java 复制代码
// Java 17 record:一行声明不可变数据类,自动生成构造器/getter/equals
public record CreateTodoRequest(String title) {}

这一步的存储还是内存 Map:

java 复制代码
@Service
public class TodoService {
    private final Map<Long, Todo> store = new ConcurrentHashMap<>();
    private final AtomicLong idGen = new AtomicLong();  // 线程安全的自增 id
    // ...
}

用 ConcurrentHashMap 而不是 HashMap,是因为 Tomcat 多线程处理请求,而 Spring 的 Bean 默认是单例。单例 Bean 加上多线程请求,意味着共享的可变状态必须自己保证线程安全。

我一开始觉得 Service 层有形式主义的嫌疑,逻辑写在 Controller 里也能跑。第 4 步把 Service 内部的 Map 换成数据库 Repository 时,Controller 一行都没改,我才算明白分层的意义在哪:变化被关在一层里。要是当初逻辑写在 Controller 里,这次改的就是所有接口。

怎么验证

bash 复制代码
BASE=http://localhost:8080/todos

curl -i -X POST $BASE -H 'Content-Type: application/json' -d '{"title":"买牛奶"}'
# 期待 201 Created,响应头里有 Location: /todos/1

curl $BASE          # 200,JSON 数组
curl -i $BASE/1     # 200
curl -i -X DELETE $BASE/1   # 204,无响应体

顺便记一下重启之后数据全没了的那种感觉,第 4 步就是冲着它去的。

第 3 步:参数校验和全局异常处理

到这一步,POST {"title":" "} 是能创建成功的,一个空白标题的 Todo 就这样进了列表。

真实系统里很大一部分代码量花在处理不正常流程上,所以这一步把它们系统化,办法是建两道防线。

第一道防线是声明式校验。加依赖 spring-boot-starter-validation,然后在 DTO 上声明规则:

java 复制代码
public record CreateTodoRequest(
        @NotBlank(message = "标题不能为空")
        @Size(max = 100, message = "标题最长 100 字符")
        String title) {}
java 复制代码
@PostMapping
public ResponseEntity<Todo> create(@Valid @RequestBody CreateTodoRequest request) {
    // 走到这里时 title 一定合法。校验失败会抛异常,方法体根本不执行
}

第二道防线针对那些入口校验管不了的错误。GET /todos/999 的 id 格式完全合法,查了才知道不存在。这类业务错误我用自定义异常加全局处理器解决:

java 复制代码
// 业务层只管"喊一声",完全不知道 HTTP 的存在
public class NotFoundException extends RuntimeException {
    public NotFoundException(String message) { super(message); }
}

@Service
public class TodoService {
    @Transactional(readOnly = true)
    public Todo findById(long id) {
        return repository.findById(id)
                .orElseThrow(() -> new NotFoundException("Todo 不存在: id=" + id));
    }
}
java 复制代码
// 全局的"错误翻译官":业务异常 → HTTP 状态码的映射只写一次
@RestControllerAdvice  // = @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {

    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("NOT_FOUND", ex.getMessage(), Instant.now()));
    }

    @ExceptionHandler(Exception.class)  // 兜底,防止堆栈外泄
    public ResponseEntity<ErrorResponse> handleAll(Exception ex) { /* 500 */ }
}

Controller 因此干净了不少:

java 复制代码
// 之前:404 判断散落在每个接口
return service.findById(id).map(ResponseEntity::ok)
        .orElse(ResponseEntity.notFound().build());

// 之后:只表达正常流程
return service.findById(id);

校验注解不能照抄

创建接口的 title 必填,更新接口的 title 是可选字段,不传就表示不改。我图省事给两边都加了 @NotBlank,结果"只想改 completed 也必须传 title",这明显是 bug。UpdateTodoRequest 只能加 @Size,不能加 @NotBlank。校验注解得跟着业务语义走。

java 复制代码
// 用 Boolean 包装类型而不是 boolean:null = "客户端没传这个字段",这是 PATCH 语义的基础
public record UpdateTodoRequest(
        Boolean completed,
        @Size(max = 100, message = "标题最长 100 字符")
        String title) {}

兜底 handler 不是保险箱

我当时以为加上 @ExceptionHandler(Exception.class) 就稳了,什么异常都能接住。第 5 步的测试打了我的脸:它会把"客户端发坏了请求"也吞成"服务器错误"。兜底是最后一张网,不是第一道防线。

怎么验证

bash 复制代码
curl -i -X POST $BASE -H 'Content-Type: application/json' -d '{"title":"   "}'
# 400 + {"code":"VALIDATION_FAILED","message":"参数校验失败","errors":{"title":"标题不能为空"}}

curl -i -X POST $BASE -H 'Content-Type: application/json' -d '{bad json'
# 400 + {"code":"MALFORMED_JSON",...}   ← 注意这行第 5 步之前是 500

curl -i $BASE/999
# 404 + {"code":"NOT_FOUND","message":"Todo 不存在: id=999","timestamp":"..."}

第 4 步:用 Spring Data JPA 和 H2 做持久化

内存里的 ConcurrentHashMap 撑不住重启。真实数据得活过进程的生命周期。

架构上的变化是这一处:

复制代码
之前:  Controller → Service → ConcurrentHashMap(内存 Map)
之后:  Controller → Service → TodoRepository(接口)→ Hibernate → JDBC → H2
                                    ↑
                       我们只写一个接口,没有任何实现类!
java 复制代码
public interface TodoRepository extends JpaRepository<Todo, Long> {
    // 空接口。findAll/save/deleteById/count/existsById 全部自动拥有
    // 还支持"方法名生成查询":加一行 existsByTitle(String title) 就自动变成对应 SQL
}

空接口为什么不会返回 null

我一开始担心接口没有实现类,注入进来会是 NullPointerException。实际上 Spring Data 在启动时为每个 Repository 接口创建动态代理对象,注册进容器。调 findAll() 的时候,代理按方法名和签名决定怎么执行,可能解析成 JPQL,也可能走 @Query。注入进来的是一个能用的代理。

实体为什么不能用 record

这条我认为是整个 JPA 学习里最容易搞错的。DTO 用 record 很舒服,我第一反应是实体也照办。但 JPA 实体必须是可变的普通类,这是机制约束,不是风格偏好:

  1. Hibernate 创建对象时先调无参构造器,再逐个 set 字段。record 没有无参构造器,字段还是 final,没法 set。
  2. 事务内的修改靠脏检查同步回数据库,靠的就是 setter。record 的"改"是造一个新对象,Hibernate 追踪不到。
java 复制代码
@Entity
@Table(name = "todo")
public class Todo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)  // 数据库自增主键
    private Long id;

    @Column(nullable = false, length = 100)
    private String title;

    @Column(nullable = false)
    private boolean completed = false;

    @Column(nullable = false, updatable = false)
    private Instant createdAt;

    protected Todo() {}          // 给 Hibernate 用(protected:不让业务代码调)
    public Todo(String title) { this.title = title; this.completed = false; }

    @PrePersist                  // INSERT 前自动回调
    void onCreate() { this.createdAt = Instant.now(); }

    // getter/setter 略
}

改了实体不用 save()

java 复制代码
@Transactional
public Todo update(long id, Boolean completed, String title) {
    Todo existing = findById(id);
    if (completed != null) existing.setCompleted(completed);
    if (title != null) existing.setTitle(title);
    return existing;   // ⭐ 没有调 save()!
}

我原来以为改了对象就必须 save() 才会写库。实际是 @Transactional 方法内的实体处于"受管理"状态,事务提交时 Hibernate 拿它跟快照比一遍,发现字段变了就自动发 UPDATE,这个机制叫脏检查。

把 show-sql: true 打开,能亲眼看到这条 SQL 在方法返回之后、事务提交时才打出来。

配置文件

yaml 复制代码
# application.yml ------ 自动配置的"输入面板"
spring:
  datasource:
    url: jdbc:h2:file:~/todo-db   # 文件库,重启数据不丢
    username: sa
  jpa:
    hibernate:
      ddl-auto: update            # 启动时按实体自动建表(学习期用)
    show-sql: true                # 打印执行的 SQL
  h2:
    console:
      enabled: true               # 浏览器访问 /h2-console 直接查库

怎么验证

bash 复制代码
curl -X POST $BASE -H 'Content-Type: application/json' -d '{"title":"持久化验证"}'
# 然后 Ctrl+C 停掉应用,重新 mvn spring-boot:run
curl $BASE
# 数据还在 → 持久化成功

再打开 http://localhost:8080/h2-console(JDBC URL 填 jdbc:h2:file:~/todo-db),能看到 TODO 表里的数据。

第 5 步:三层自动化测试

手动 curl 一遍遍试太累了,这一步把行为锁进 mvn test。

复制代码
        /集成 3\        @SpringBootTest --- 全链路真实容器 + 数据库,~3s
       /---Web 5------\       @WebMvcTest --- HTTP 契约,mock Service,快
      /---单元 4------------\      Mockito --- 纯逻辑,不启动 Spring,~0.01s

单元测试用来验证业务规则,完全不碰 Spring:

java 复制代码
@ExtendWith(MockitoExtension.class)   // 让 Mockito 接管 @Mock/@InjectMocks 的初始化
class TodoServiceTest {

    @Mock TodoRepository repository;   // 假对象:所有方法默认返回 null/空
    @InjectMocks TodoService service;  // 把假对象注入进去

    @Test
    @DisplayName("findById: 不存在时抛 NotFoundException")
    void findById_throwsWhenMissing() {
        when(repository.findById(99L)).thenReturn(Optional.empty());  // 编排假行为

        assertThatThrownBy(() -> service.findById(99L))
                .isInstanceOf(NotFoundException.class);
    }
}

这里三个库各管一摊:JUnit 5 管运行器和生命周期,Mockito 造假对象,AssertJ 提供链式断言。第 2 步埋的"构造器注入让测试能塞假对象"在这里用上了。

Web 层测试验证 HTTP 契约,只装 Web 那一片:

java 复制代码
@WebMvcTest(TodoController.class)   // 只装配 Controller + 异常处理器 + Jackson + 校验
class TodoControllerTest {

    @Autowired MockMvc mockMvc;     // Spring 提供的"假 HTTP 客户端",进程内模拟全流程
    @MockitoBean TodoService service;   // 替身 Service(Boot 4 中 @MockBean 已改名)

    @Test
    void create_validationFails() throws Exception {
        mockMvc.perform(post("/todos")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\": \"   \"}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors.title").value("标题不能为空"));
    }
}

MockMvc 不发真实网络请求,在进程内把 request → DispatcherServlet → Controller 走一遍,跟 JS 生态里的 supertest 是一个思路。集成测试则用 @SpringBootTest 起完整容器,配上真实 JPA 和真实 H2,验证关键业务流能真正落库。

测试抓到的那个 bug

我给"坏 JSON 应该返回 400"写了测试,跑起来是红的,实际返回 500。

查下来是第 3 步的兜底 handler 把"客户端发坏了请求"误判成了"服务器出错"。服务器没错,错在请求,语义上必须是 400。补一个更精确的 handler:

java 复制代码
@ExceptionHandler(HttpMessageNotReadableException.class)  // 比兜底更精确,优先匹配
public ResponseEntity<ErrorResponse> handleUnreadable(HttpMessageNotReadableException ex) {
    return ResponseEntity.badRequest()
            .body(new ErrorResponse("MALFORMED_JSON", "请求体不是合法的 JSON", Instant.now()));
}

改完就绿了。这件事让我对测试的看法变了:它不是形式主义,而是下次有人改坏这里时,mvn test 会立刻变红。顺带记住一条规则,同一个 Advice 内精确异常类型优先于父类型;如果注册了多个 @RestControllerAdvice,则按 @Order 排序逐个询问,第一个有匹配 handler 的生效。

H2 文件锁的坑

集成测试第一次跑直接报错:

复制代码
org.h2.mvstore.MVStoreException: The file is locked: ~/todo-db.mv.db

原因是开发用的 H2 文件库同时只允许一个进程连接,我的应用还在跑,测试抢不到文件锁。

这个报错暴露了测试用库的三条要求:隔离、可重复、不依赖外部状态。解决办法是新建 src/test/resources/application.yml:

yaml 复制代码
spring:
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1   # 内存库,随 JVM 生灭
  jpa:
    hibernate:
      ddl-auto: create-drop   # 测试开始建表、结束删表,最干净
    show-sql: false           # 测试输出别刷屏

它能生效是因为 Maven 的 classpath 规则里 src/test/resources 优先于 src/main/resources,测试时这个文件自动覆盖主配置。一行 Java 代码都不用改,测试和开发就用上了两个不同的数据库。

怎么验证

bash 复制代码
mvn test
# Tests run: 12, Failures: 0, Errors: 0, Skipped: 0

想更有把握的话,故意改坏一处业务代码,比如把 findById 的 orElseThrow 改成返回 null,再跑 mvn test 看它变红。

第 6 步:打包交付和多环境 profiles

最后一步是把项目变成"有 Java 环境就能跑"的东西,顺便让它能部署到不同环境。

mvn clean package 之后解压产物看看:

复制代码
todo-app.jar
├── META-INF/MANIFEST.MF               ← Main-Class: JarLauncher, Start-Class: 你的启动类
├── org/springframework/boot/loader/   ← Spring 自己的类加载器
├── BOOT-INF/classes/                  ← 你自己的 class + application.yml
└── BOOT-INF/lib/                      ← 全部依赖:Tomcat、Hibernate、H2......一个不落

java -jar 的启动链是这样的:JVM 读 manifest 里的 Main-Class: JarLauncher,这个自定义类加载器从 BOOT-INF/lib 加载嵌套 jar,再反射调用 Start-Class 的 main。体验上类似一个自带 node_modules 的单文件 Node 应用,有 Java 环境就能跑,不需要 Maven,也不需要装 Tomcat。

有个容易忽略的细节:不跳过测试时,mvn package 会先把那 12 个测试跑完,任何一个红就打包失败。这就是 CI/CD 的质量门禁。-DskipTests 只在开发期图快,交付物不能跳。

多环境用 profiles 拆:

yaml 复制代码
# application.yml       ------ 只写所有环境共通的默认值
# application-dev.yml   ------ 开发差异:show-sql 开、h2-console 开
# application-prod.yml  ------ 生产差异:show-sql 关、console 关(安全)
bash 复制代码
java -jar todo-app.jar --spring.profiles.active=dev
java -jar todo-app.jar --spring.profiles.active=prod

同一个 jar,换一个启动参数,行为就变了,这也是一次构建、多环境部署的意思。我原以为多环境就是复制三份完整配置,改改数据库地址。后来发现应该只写差异、叠加覆盖:主 application.yml 放共通默认值,profile 文件只放该环境不同的那几行。复制三份完整配置的下场是,加一个公共配置要改三处,早晚漏一处。

优先级是命令行参数高于 profile 文件,profile 文件高于主配置。所以部署期的端口和密码都能从外面注入:

bash 复制代码
java -jar todo-app.jar --server.port=9090
# 等价于环境变量 SERVER_PORT=9090(Spring 的宽松绑定)
# 真实项目里数据库密码都是 CI 环境变量注入,敏感信息永不进 git

怎么验证

bash 复制代码
mvn clean package
java -jar target/todo-app-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev
# 日志里能看到 Tomcat started on port 8080,且 SQL 被打印出来(dev 开了 show-sql)

java -jar target/todo-app-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod --server.port=9090
# 端口变 9090,SQL 不再打印,/h2-console 访问被拒

Spring Boot 4 的四个坑

Spring Boot 4.0 在 2025 年 11 月 20 日 GA,升级幅度不小。我踩到的是这四个,都不难修,但不知道就会卡很久。

第一个,@WebMvcTest 换包了:

java 复制代码
// Boot 3.x(老教程写法,Boot 4 编译不过)
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Boot 4.x
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;

第二个,@MockBean 被删了,改用 Spring Framework 原生的注解:

java 复制代码
// Boot 4 中 @MockBean / @SpyBean 已从 Spring Boot 移除,改用 Spring Framework 的原生注解:
import org.springframework.test.context.bean.override.mockito.MockitoBean;

第三个,测试切片被拆成独立 starter,MVC 切片要单独引:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<!-- Boot 4 新增:@WebMvcTest 所需,Boot 3 时代它藏在 starter-test 里 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>

第四个,spring-boot-starter-test 不会自动引入。继承 parent 只是拿到了版本管理,依赖本身还得自己声明。

排查这类问题的方法比记住答案有用。遇到 找不到符号 WebMvcTest,先确认依赖在不在,再确认类在哪个 jar 里:

bash 复制代码
mvn dependency:tree                      # ① 确认依赖在不在
unzip -l ~/.m2/repository/.../xxx.jar | grep 类名   # ② 确认类在哪个 jar 里

版本升级导致的"找不到符号",九成是包路径变了或者模块被拆了,不是自己写错了。

复盘:能带走的东西

六步走完,我认为真正能迁移到下一个项目的是这六条,而不是某个注解怎么用:

# 心智模型 一句话解释 其他语言的对应物
1 依赖注入 不自己 new,只声明"我需要什么",容器负责给 前端 import 单例、Go 的 wire/fx
2 分层是为了隔离变化 每层只改自己那层的事,第 4 步换存储 Controller 零改动 前端的 api / store / view 分层
3 声明式优于命令式 @NotBlank 声明规则,框架负责执行 HTML 表单校验、TS 类型标注
4 接口 + 动态代理 Repository 只写接口,实现运行时生成 JS Proxy、Go 的 interface + 代码生成
5 事务内实体受管理 改了字段,提交时自动 UPDATE,不用 save ORM 的 Unit of Work 模式
6 配置外部化 同一份产物,用参数适配不同环境 12-Factor App 的 Config

另外还有个习惯值得留下:测试先红后绿。第 5 步那个 500 改 400 的修复,如果没有测试,这个 bug 会一直躺着,直到某天前端同学来问"为什么我传错 JSON 是服务器错误"。

自测题

能顺畅答出这 8 题,说明这六步走扎实了:

  1. @SpringBootApplication 是哪三个注解的组合?各自干什么?
  2. Tomcat 是从哪来的?为什么 java -jar 就能起一个 Web 服务?
  3. spring-boot-starter-parent 和 spring-boot-starter-web 的职责有什么本质区别?
  4. TodoRepository 没有实现类,为什么注入进来不是 null?
  5. 为什么 DTO 用 record,而 JPA 实体必须用普通 class?
  6. @Transactional 方法里改了实体却没调 save(),UPDATE 语句是什么时候发出来的?
  7. UpdateTodoRequest.title 为什么只能加 @Size 不能加 @NotBlank?
  8. 测试为什么不能用开发用的 ~/todo-db 文件库?src/test/resources/application.yml 凭什么能覆盖主配置?

答案要点

  1. @SpringBootConfiguration(本质是 @Configuration)+ @EnableAutoConfiguration(按依赖自动配置,检测到 starter-web 就起 Tomcat)+ @ComponentScan(扫描本包及子包)。
  2. Tomcat 是 starter-web 带进来的 jar,被 Spring Boot 内嵌进应用。JVM 运行时不带 HTTP 能力,所以服务器在 Java 里是一个库,而不是外部软件。
  3. parent 只做版本管理,不引入依赖;starter 才是真正被引入的依赖套餐,因为 parent 管着版本所以不用写 version。
  4. Spring Data 启动时为 Repository 接口创建动态代理对象注册进容器,方法调用时按方法名或签名决定执行方式。
  5. Hibernate 先调无参构造器再 set 字段,record 无无参构造器且字段 final;事务内靠 setter 触发脏检查,record 的"改"是造新对象,追踪不到。
  6. 方法返回后、事务提交时。Hibernate 对比快照发现字段变化,自动发出 UPDATE。
  7. 更新是 PATCH 语义,title 可选(null 表示不改),加 @NotBlank 会强制每次都传 title。
  8. H2 文件库同时只允许一个进程连接(会报 file is locked),而且会污染开发数据、不可重复;Maven classpath 中 src/test/resources 优先于 src/main/resources,所以测试配置自动覆盖主配置。

接下来打算学的

按工作实用度排:

  1. AOP 与事务传播。@Transactional 失效的各种情况,比如同类内部调用、异常被吞、非 public 方法,面试也常问。
  2. Flyway 数据库迁移。把 ddl-auto: update 换成版本化建表脚本,生产环境需要。
  3. Spring Security。登录鉴权是真实项目的标配。
  4. MySQL 和连接池调优。把 H2 换成生产级数据库。
  5. Testcontainers。用 Docker 起一次性真实数据库做集成测试,替掉内存库。

常用命令

bash 复制代码
# 启动(开发)
mvn spring-boot:run
mvn spring-boot:run -Dspring-boot.run.profiles=dev

# 测试与打包
mvn test                          # 跑 12 个测试
mvn clean package                 # 打包(会先跑测试)
mvn clean package -DskipTests     # 开发期图快

# 运行产物
java -jar target/todo-app-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

# 排查依赖
mvn dependency:tree               # 依赖树,排查"找不到符号"第一步

# 接口自测
curl -i -X POST $BASE -H 'Content-Type: application/json' -d '{"title":"买牛奶"}'

最后

六步走下来,我最大的感受是 Spring Boot 本身不难,难的是把散落的知识点串成一条因果链。回头看每一步为什么存在,其实可以说得很短:先让程序跑起来,再让它有结构,然后让它扛得住异常,让数据活下来,让改动不失控,最后让它能交付。

项目完整代码在 https://gitee.com/Roadinforest/spring-learn,每个 commit 对应一步,可以按 commit 顺序对照着看。

环境:Java 17 + Maven 3.9 + Spring Boot 4.1.1 + H2。代码都来自这个可运行的项目,发现错漏欢迎指正。

相关推荐
半仙白桑1 小时前
内核篇第十三讲:system-V消息队列
java·linux·网络
今天的砖头有点烫手啊1 小时前
GC 算法与垃圾收集器演进:从 Serial 到 ZGC
java·jvm
88号技师1 小时前
2026年9月SCI-天气状态蚂蚁优化算法Weather State Ants Optimizer-附Matlab免费代码
开发语言·算法·matlab·优化算法
孙启超1 小时前
【AI开发之Rust】第 20 课:壳侧 API 封装 —— 把 Rust 能力接进真实 UI
开发语言·后端·rust
paopaokaka_luck1 小时前
个人综合管理小程序(echarts数据看板、健康数据记录与趋势查看、目标拆解和进度管理、日程待办与番茄钟、习惯打卡、收支统计、个人生活记录、跨模块智能提醒)
前端·javascript·spring·小程序·数据分析·echarts·生活
牧瀬クリスだ1 小时前
Mybatis-Plus:让CRUD快如闪电
java·spring boot·spring·tomcat·mybatis·yml
盐焗鹌鹑蛋1 小时前
【Linux】线程概念和控制
java·linux·服务器
CarIise1 小时前
MyBatis缓存与分页插件:从一级二级缓存到底层原理与PageHelper实战
java·开发语言·spring