环境: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 实体必须是可变的普通类,这是机制约束,不是风格偏好:
- Hibernate 创建对象时先调无参构造器,再逐个
set字段。record 没有无参构造器,字段还是final,没法 set。 - 事务内的修改靠脏检查同步回数据库,靠的就是 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 题,说明这六步走扎实了:
@SpringBootApplication是哪三个注解的组合?各自干什么?- Tomcat 是从哪来的?为什么
java -jar就能起一个 Web 服务? spring-boot-starter-parent和spring-boot-starter-web的职责有什么本质区别?TodoRepository没有实现类,为什么注入进来不是 null?- 为什么 DTO 用
record,而 JPA 实体必须用普通class? @Transactional方法里改了实体却没调save(),UPDATE 语句是什么时候发出来的?UpdateTodoRequest.title为什么只能加@Size不能加@NotBlank?- 测试为什么不能用开发用的
~/todo-db文件库?src/test/resources/application.yml凭什么能覆盖主配置?
答案要点
@SpringBootConfiguration(本质是@Configuration)+@EnableAutoConfiguration(按依赖自动配置,检测到 starter-web 就起 Tomcat)+@ComponentScan(扫描本包及子包)。- Tomcat 是
starter-web带进来的 jar,被 Spring Boot 内嵌进应用。JVM 运行时不带 HTTP 能力,所以服务器在 Java 里是一个库,而不是外部软件。 - parent 只做版本管理,不引入依赖;starter 才是真正被引入的依赖套餐,因为 parent 管着版本所以不用写 version。
- Spring Data 启动时为 Repository 接口创建动态代理对象注册进容器,方法调用时按方法名或签名决定执行方式。
- Hibernate 先调无参构造器再 set 字段,record 无无参构造器且字段 final;事务内靠 setter 触发脏检查,record 的"改"是造新对象,追踪不到。
- 方法返回后、事务提交时。Hibernate 对比快照发现字段变化,自动发出 UPDATE。
- 更新是 PATCH 语义,
title可选(null 表示不改),加@NotBlank会强制每次都传 title。 - H2 文件库同时只允许一个进程连接(会报 file is locked),而且会污染开发数据、不可重复;Maven classpath 中
src/test/resources优先于src/main/resources,所以测试配置自动覆盖主配置。
接下来打算学的
按工作实用度排:
- AOP 与事务传播。
@Transactional失效的各种情况,比如同类内部调用、异常被吞、非 public 方法,面试也常问。 - Flyway 数据库迁移。把
ddl-auto: update换成版本化建表脚本,生产环境需要。 - Spring Security。登录鉴权是真实项目的标配。
- MySQL 和连接池调优。把 H2 换成生产级数据库。
- 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。代码都来自这个可运行的项目,发现错漏欢迎指正。