Spring Boot 4 旅游主题实战教程 阶段二:Web 开发基础
第04章 Web 开发基础:REST 接口全掌握
本章目标:掌握 GET/POST/PUT/DELETE 四类 HTTP 方法在 Spring MVC 中的写法,吃透 @PathVariable、@RequestParam、@RequestBody 三种参数接收方式,了解静态资源与 ResponseEntity。
上章回顾:第03章配置体系已就绪------参数可以外置了。现在回到接口本身:第01章只写了 GET,但真实的旅游系统必须支持"提交订单(POST)、修改行程(PUT)、取消订单(DELETE)"。
下一章预告 :本章查不到景点时只能返回
{"error": "..."},成功响应也是"裸奔"的 JSON。第05章《Web 进阶》引入统一响应结构与全局异常处理,让所有接口穿上统一的"外衣"。
4.1 RESTful 风格速览
REST 是一套用 URL 表达资源、用 HTTP 方法表达动作的设计风格:
| 操作 | HTTP 方法 | URL 示例 | 成功状态码 |
|---|---|---|---|
| 查询列表 | GET | /attractions | 200 |
| 查询单个 | GET | /attractions/1 | 200 |
| 新增 | POST | /attractions | 201 |
| 全量更新 | PUT | /attractions/1 | 200 |
| 删除 | DELETE | /attractions/1 | 204 |
要点:
- URL 是名词(资源),不出现 get/add/delete 等动词;
- 动作由 HTTP 方法表达;
- 状态码语义化:200 OK、201 Created、204 No Content、404 Not Found。
4.2 三种参数接收方式对比
| 注解 | 数据来源 | 典型场景 |
|---|---|---|
| @PathVariable | URL 路径占位符 /attractions/1 | 定位唯一资源 |
| @RequestParam | 查询字符串 ?city=大理 | 过滤、排序、分页 |
| @RequestBody | 请求体 JSON | POST/PUT 提交数据 |
4.3 创建第04章模块
4.3.1 根 pom 注册模块
xml
<modules>
<module>chapter01-hello</module>
<module>chapter02-bean</module>
<module>chapter03-config</module>
<module>chapter04-web-basic</module>
</modules>
4.3.2 本章 pom.xml
创建 chapter04-web-basic/pom.xml:
xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
第04章:Web 开发基础 ------ REST 接口全掌握
依赖与前三章相同。本章聚焦:
GET/POST/PUT/DELETE 四类方法 + @PathVariable/@RequestParam/@RequestBody
三种参数接收方式 + 静态资源访问。
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter04-web-basic</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
4.4 编写代码
4.4.1 实体:Attraction.java
路径:src/main/java/com/lihaozhe/chapter04/model/Attraction.java
java
package com.lihaozhe.chapter04.model;
import java.time.LocalDateTime;
/**
* 景点实体(第04章)
*
* <p>理论知识 ------ record + Jackson:
* <ul>
* <li>Jackson 能直接序列化/反序列化 record:反序列化时调用它的"规范构造器",
* 字段名一一对应 JSON 的 key;</li>
* <li>LocalDateTime 也能自动互转(默认 ISO-8601 格式:2026-08-25T12:00:00);</li>
* <li>本章新增 POST/PUT 需要"JSON → 对象"的反序列化能力,
* 正好演示 record 作为请求体的用法。</li>
* </ul>
*
* @param id 景点ID
* @param name 景点名称
* @param city 所在城市
* @param price 门票价格(元)
* @param createTime 创建时间
*/
public record Attraction(
Long id,
String name,
String city,
double price,
LocalDateTime createTime) {
}
4.4.2 控制器:AttractionController.java(核心)
路径:src/main/java/com/lihaozhe/chapter04/controller/AttractionController.java
java
package com.lihaozhe.chapter04.controller;
import com.lihaozhe.chapter04.model.Attraction;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 景点管理接口(第04章:REST 全家桶)
*
* <p>用一个内存 Map 模拟数据库,完整演示增删改查四种 HTTP 方法。
*/
@RestController
@RequestMapping("/attractions")
public class AttractionController {
/** ConcurrentHashMap:线程安全的内存"数据库"(多个请求并发读写) */
private final Map<Long, Attraction> db = new ConcurrentHashMap<>();
/** ID 生成器:AtomicLong 保证并发下自增不重复 */
private final AtomicLong idGen = new AtomicLong(0);
/** 初始化三条演示数据(构造器在容器启动时执行一次) */
public AttractionController() {
save(new Attraction(null, "长白山天池", "延边", 105.0, null));
save(new Attraction(null, "洱海", "大理", 0, null));
save(new Attraction(null, "黄鹤楼", "武汉", 70.0, null));
}
// ==================== 查询:GET ====================
/**
* 接口1:分页+筛选查询列表
* GET /attractions?city=大理&minPrice=0&sort=price
*
* <p>@RequestParam 三要点:
* <ul>
* <li>required=false:参数可省略(默认必须传,否则400);</li>
* <li>defaultValue="...":省略时用默认值(此时自动 required=false);</li>
* <li>参数名与 URL 的 ?key= 一致时注解可省略名字。</li>
* </ul>
*/
@GetMapping
public List<Attraction> list(
@RequestParam(required = false) String city,
@RequestParam(defaultValue = "0") double minPrice,
@RequestParam(defaultValue = "id") String sort) {
var stream = db.values().stream();
if (city != null) {
stream = stream.filter(a -> a.city().equals(city));
}
stream = stream.filter(a -> a.price() >= minPrice);
stream = switch (sort) {
// Java 21+ switch 表达式:每个分支产生一个值,无需 break
case "price" -> stream.sorted(java.util.Comparator.comparingDouble(Attraction::price));
case "name" -> stream.sorted(java.util.Comparator.comparing(Attraction::name));
default -> stream.sorted(java.util.Comparator.comparing(Attraction::id));
};
return stream.toList();
}
/**
* 接口2:查询单个 + 自定义响应头演示
* GET /attractions/1
*
* <p>@RequestHeader 可读取请求头------这里回显客户端的 User-Agent。
*/
@GetMapping("/{id}")
public Map<String, Object> detail(@PathVariable Long id,
@RequestHeader(value = "User-Agent", defaultValue = "unknown") String ua) {
Attraction attraction = db.get(id);
if (attraction == null) {
// 手动返回 404:第05章会用全局异常替代这种啰嗦写法
return Map.of("error", "景点不存在", "id", id);
}
return Map.of("data", attraction, "yourUA", ua);
}
// ==================== 新增:POST ====================
/**
* 接口3:新增景点
* POST /attractions,请求体是 JSON
*
* <p>@RequestBody:把请求体里的 JSON 反序列化成 record 对象。
* Content-Type 必须是 application/json。
*
* <p>@ResponseStatus:方法正常返回时给客户端 201 Created(RESTful 约定:
* 创建成功不用默认的 200,而是 201)。
*/
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Attraction add(@RequestBody Attraction input) {
return save(input);
}
/** 内部创建逻辑:生成ID和时间戳后入库 */
private Attraction save(Attraction input) {
long id = idGen.incrementAndGet();
var saved = new Attraction(id, input.name(), input.city(), input.price(), LocalDateTime.now());
db.put(id, saved);
return saved;
}
// ==================== 更新:PUT ====================
/**
* 接口4:全量更新
* PUT /attractions/1,请求体 JSON 包含全部字段
*
* <p>PUT vs PATCH:PUT 约定为"整体替换",PATCH 为"局部修改";
* 本章用 PUT 覆盖除 id/createTime 外的全部字段。
*
* <p>ResponseEntity<T>:可以精确控制状态码+响应头+响应体的"全家桶"返回类型,
* 比 @ResponseStatus 更灵活(能按业务结果动态决定状态码)。
*/
@PutMapping("/{id}")
public ResponseEntity<Attraction> update(@PathVariable Long id, @RequestBody Attraction input) {
Attraction old = db.get(id);
if (old == null) {
// 不存在则返回 404,响应体为空
return ResponseEntity.notFound().build();
}
var updated = new Attraction(id, input.name(), input.city(), input.price(), old.createTime());
db.put(id, updated);
// 200 OK + 更新后的实体
return ResponseEntity.ok(updated);
}
// ==================== 删除:DELETE ====================
/**
* 接口5:删除景点
* DELETE /attractions/3
*
* <p>RESTful 约定删除成功返回 204 No Content(成功但没有响应体)。
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (db.remove(id) == null) {
return ResponseEntity.notFound().build();
}
// noContent() = 204 状态码 + 无响应体
return ResponseEntity.noContent().build();
}
}
4.4.3 静态页面:index.html
路径:src/main/resources/static/index.html
html
<!DOCTYPE html>
<!-- 第04章:静态资源演示页面 -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>第04章 静态资源测试</title>
</head>
<body>
<h1>走遍中国 · 静态资源页</h1>
<p>本文件位于 src/main/resources/static/index.html</p>
<p>Spring Boot 自动把 static/ 目录作为静态资源根目录,无需任何配置。</p>
<a href="/attractions">点击查看景点 JSON 接口</a>
</body>
</html>
4.4.4 配置文件:application.yml
路径:src/main/resources/application.yml
yaml
# =====================================================================
# 第04章 配置文件
# =====================================================================
server:
port: 8104
spring:
application:
name: chapter04-web-basic
web:
resources:
# 静态资源缓存时间(秒)------演示静态资源也是可配置的
cache:
period: 3600
4.4.5 启动类:TravelApplication.java
路径:src/main/java/com/lihaozhe/chapter04/TravelApplication.java
java
package com.lihaozhe.chapter04;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第04章启动类:Web 开发基础
*
* <p>理论知识 ------ RESTful 风格约定(用 URL 表达资源,用 HTTP 方法表达动作):
* <pre>
* GET /attractions 查询列表
* GET /attractions/{id} 查询单个
* POST /attractions 新增
* PUT /attractions/{id} 全量更新
* DELETE /attractions/{id} 删除
* </pre>
* 同一个 /attractions 路径,靠 HTTP 方法区分操作------URL 里不出现 get/add/delete 等动词。
*/
@SpringBootApplication
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
4.5 运行与验证
bash
mvn -pl chapter04-web-basic -am clean package -DskipTests
cd chapter04-web-basic
mvn spring-boot:run
以下均为真实运行结果。
查询列表
bash
curl http://localhost:8104/attractions
json
[{"id":1,"name":"长白山天池","city":"延边","price":105.0,"createTime":"2026-08-25T21:20:39.784638"},{"id":2,"name":"洱海","city":"大理","price":0.0,"createTime":"2026-08-25T21:20:39.784638"},{"id":3,"name":"黄鹤楼","city":"武汉","price":70.0,"createTime":"2026-08-25T21:20:39.784638"}]
条件筛选 + 排序
bash
curl "http://localhost:8104/attractions?sort=price"
json
[{"id":2,"name":"洱海",...},{"id":3,"name":"黄鹤楼",...},{"id":1,"name":"长白山天池",...}]
按价格从低到高排序生效。
POST 新增 (注意 Windows 的 cmd/PowerShell 直接内联中文 JSON 可能有编码问题,建议把 JSON 存成 UTF-8 文件后用 --data-binary @文件 发送):
bash
curl -i -X POST http://localhost:8104/attractions \
-H "Content-Type: application/json; charset=utf-8" \
--data-binary @post.json
post.json 内容(UTF-8 编码):
json
{"name":"喀纳斯","city":"阿勒泰","price":160.0}
返回第一行:
text
HTTP/1.1 201
PUT 更新
bash
curl -X PUT http://localhost:8104/attractions/3 \
-H "Content-Type: application/json; charset=utf-8" \
--data-binary @put.json
put.json 内容:
json
{"name":"黄鹤楼公园","city":"武汉","price":75.0}
返回:
json
{"id":3,"name":"黄鹤楼公园","city":"武汉","price":75.0,"createTime":"2026-08-25T21:20:39.784638"}
createTime 保持原值------更新没有覆盖它。
DELETE 删除
bash
curl -i -X DELETE http://localhost:8104/attractions/2
text
HTTP/1.1 204
删除不存在的:
bash
curl -i -X DELETE http://localhost:8104/attractions/999
text
HTTP/1.1 404
静态资源:浏览器打开 http://localhost:8104/index.html ,能看到演示页面(状态码 200)。
踩坑实录 :如果 curl 发送中文 JSON 时服务端报 Invalid UTF-8 start byte,说明终端发送的字节不是 UTF-8。解决办法就是上面用的:JSON 写入 UTF-8 文件,--data-binary @file 发送。这个坑 Windows 用户几乎必遇一次。
4.6 本章小结
| 你学会了 | 关键点 |
|---|---|
| RESTful 设计 | 名词 URL + 动词 HTTP 方法 + 语义化状态码 |
| @PathVariable | 取路径占位符,定位资源 |
| @RequestParam | 取查询参数,required/defaultValue 控制 |
| @RequestBody | JSON 反序列化为 record |
| ResponseEntity | 动态控制状态码与响应体 |
| @ResponseStatus | 固定成功状态码(如 201) |
4.7 动手练习
- 增加
GET /attractions/search?keyword=x接口:按名称模糊搜索。 - 增加 PATCH 局部更新接口:只改价格不改其它字段(提示:请求体只带 price 字段,用一个只含 price 的 record 接收)。
- 给列表接口加分页参数 page/size,返回
{total, page, size, list}结构。
下一章预告 :
{"error":"景点不存在"}这种错误结构是临时凑合的------成功和失败长得不一样,前端没法统一处理。第05章《Web 进阶》打造统一响应体 Result + 全局异常处理器 + 拦截器,让接口工程化。
4.8 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| record (Java 16+) | 不可变数据载体,自动生成全参构造器、访问器、equals/hashCode/toString | Attraction 类用 record 定义景点实体,支持 Jackson 自动序列化/反序列化(Attraction.java:128) |
| switch 表达式 (Java 21+) | 每个分支产生一个值,无需 break,代码更紧凑 | 在 list() 方法中按 sort 参数动态选择排序方式(AttractionController.java:69-74) |
| var 局部变量类型推断 (Java 10+) | 编译器根据右值自动推断变量类型 | 多处使用:stream 对象(line 64)、新建的 Attraction(line 116)、更新对象(line 140) |
| Stream API (Java 8+) | 声明式集合处理,支持 filter/map/reduce 等操作 | 在 list() 中筛选、排序景点数据(AttractionController.java:64-75) |
| List.of() / Map.of() (Java 9+) | 不可变集合工厂方法,代码更简洁 | 在 guides() 和 detail() 中直接创建不可变集合(AttractionController.java:79、90、92) |
| LocalDateTime (Java 8+) | 不可变的日期时间类型,支持 ISO-8601 格式序列化 | 在 save() 中记录景点创建时间(AttractionController.java:116) |
为什么用现代语法? record 让 JSON 序列化零配置;switch 表达式替代冗长的 if-else 或传统 switch,代码更安全(无需 break);Stream API 替代手动 for 循环,逻辑更清晰;var 减少样板代码;LocalDateTime 提供标准化的时间表示,避免时区陷阱。
第05章 Web 进阶:统一响应、全局异常与拦截器
本章目标:打造 Result 统一响应体,用 @RestControllerAdvice 集中处理所有异常,用拦截器实现接口耗时统计,并配置好 CORS 跨域。
上章回顾 :第04章查不到景点时返回
{"error": "..."}------成功和失败结构不一致,前端要写两套解析逻辑;每个接口各写 try-catch 更是灾难。下一章预告:响应结构有了,但"新增目的地时 name 传了空字符串"怎么办?第06章《参数校验》用注解一行搞定校验。
5.1 统一响应体:Result<T>
目标结构:
json
{ "code": 0, "message": "success", "data": {...} } // 成功
{ "code": 2001, "message": "目的地不存在...", "data": null } // 业务失败
{ "code": 5000, "message": "系统繁忙,请稍后再试", "data": null } // 系统异常
设计要点:
- code = 0 表示成功,非 0 一律失败;错误码分段:1xxx 参数问题、2xxx 业务问题、5xxx 系统问题;
- 泛型 T 保证 data 的编译期类型安全;
- 私有构造 + 静态工厂(ok/fail):外部只能按语义创建实例。
5.2 全局异常处理:@RestControllerAdvice
工作原理一句话:它是给所有 Controller 加的"统一 catch"。Controller 方法抛出的任何异常都会被路由到 @ExceptionHandler 方法,按参数类型就近匹配。
Controller 抛出 BusinessException
↓
DispatcherServlet 捕获
↓
@RestControllerAdvice 按 @ExceptionHandler(BusinessException.class) 匹配
↓
handleBusiness() 返回 Result → 写入响应体
5.3 创建第05章模块
5.3.1 根 pom 注册模块
xml
<modules>
<module>chapter01-hello</module>
<module>chapter02-bean</module>
<module>chapter03-config</module>
<module>chapter04-web-basic</module>
<module>chapter05-web-advanced</module>
</modules>
5.3.2 本章 pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
第05章:Web 进阶 ------ 统一响应、全局异常、拦截器、CORS
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter05-web-advanced</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- 引入 validation starter:全局异常处理器要处理 MethodArgumentNotValidException,
该异常由参数校验框架抛出,第06章将系统学习,这里先让异常处理器"认识"它 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
5.4 编写代码
5.4.1 Result.java ------ 统一响应体
路径:src/main/java/com/lihaozhe/chapter05/common/Result.java
java
package com.lihaozhe.chapter05.common;
/**
* 统一响应体(第05章的核心类)
*
* <p>理论知识 ------ 为什么要统一响应结构?
* 前端拿到任何接口的返回,都按同一套规则解析:
* <pre>
* { "code": 0, "message": "success", "data": {...} }
* </pre>
* code=0 表示成功,非 0 表示各类失败------前端只需判断一个字段。
*
* <p>设计说明:
* <ul>
* <li>泛型 T:data 的类型随接口而变(列表/对象/布尔),编译期类型安全;</li>
* <li>私有构造器 + 静态工厂方法:外部不能随意 new,只能通过
* ok()/fail() 创建语义明确的实例------这是"受控创建"的经典手法;</li>
* <li>用 record 而不是 Lombok:零依赖、不可变。</li>
* </ul>
*
* @param code 业务状态码:0=成功;1xxx=通用错误;2xxx=业务错误
* @param message 提示信息(可直接展示给用户)
* @param data 业务数据,失败时为 null
* @param <T> data 的实际类型
*/
public record Result<T>(int code, String message, T data) {
/** 成功状态码常量 */
public static final int SUCCESS = 0;
/** 通用失败码 */
public static final int ERROR = 1000;
/** 静态工厂:成功 + 携带数据 */
public static <T> Result<T> ok(T data) {
return new Result<>(SUCCESS, "success", data);
}
/** 静态工厂:成功 + 无数据 */
public static Result<Void> ok() {
return new Result<>(SUCCESS, "success", null);
}
/** 静态工厂:失败 + 自定义码和消息 */
public static <T> Result<T> fail(int code, String message) {
return new Result<>(code, message, null);
}
/** 静态工厂:失败 + 默认码 */
public static <T> Result<T> fail(String message) {
return fail(ERROR, message);
}
}
注意:record 的构造器默认就是"全参构造",没有 public 修饰的其它构造器即可达到受控效果。record 天生满足"字段 final + 构造唯一",配合静态工厂非常干净。
5.4.2 BusinessException.java ------ 业务异常
路径:src/main/java/com/lihaozhe/chapter05/common/BusinessException.java
java
package com.lihaozhe.chapter05.common;
/**
* 业务异常(第05章)
*
* <p>理论知识 ------ 为什么要自定义异常?
* <ul>
* <li>Java 内置异常表达不了业务语义:NotFoundException?BusinessRuleException?
* 都需要自己定义;</li>
* <li>业务代码里 throw new BusinessException(...) 即可"中断"流程,
* 处理交给全局异常处理器------业务方法从此不再写 try-catch;</li>
* <li>继承 RuntimeException(非受检异常):不强迫调用方到处写 throws,
* 这是 Spring 生态的惯例。</li>
* </ul>
*/
public class BusinessException extends RuntimeException {
/** 业务错误码,会透传到 Result.code */
private final int code;
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
/** 常用便捷构造:默认码 2000 */
public BusinessException(String message) {
this(2000, message);
}
public int getCode() {
return code;
}
}
5.4.3 GlobalExceptionHandler.java ------ 全局异常处理器
路径:src/main/java/com/lihaozhe/chapter05/common/GlobalExceptionHandler.java
java
package com.lihaozhe.chapter05.common;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/**
* 全局异常处理器(第05章核心组件)
*
* <p>理论知识 ------ @RestControllerAdvice 的工作原理:
* <ul>
* <li>它是"所有 @RestController 的 AOP 环绕增强":任何 Controller 方法抛出的异常,
* 都会先被路由到这里,按 @ExceptionHandler 的参数类型匹配最具体的处理方法;</li>
* <li>Controller 里从此不写 try-catch------异常一路向上抛,由这里统一兜底;</li>
* <li>@RestControllerAdvice = @ControllerAdvice + @ResponseBody(处理结果直接写响应体)。</li>
* </ul>
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/**
* 处理业务异常:如"景点不存在""库存不足"
* 返回 HTTP 200 + 业务错误码(前后端约定:看 code 而不是 HTTP 状态码判断业务成败)
*/
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusiness(BusinessException e) {
// warn 级别:业务异常是预期内的,不需要 error 级别报警
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return Result.fail(e.getCode(), e.getMessage());
}
/**
* 处理 JSON 解析失败:前端发的 JSON 格式错误/编码错误
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public Result<Void> handleNotReadable(HttpMessageNotReadableException e) {
log.warn("JSON解析失败: {}", e.getMessage());
return Result.fail(1001, "请求体不是合法的JSON");
}
/**
* 处理参数类型不匹配:如 /attractions/abc 中 abc 无法转成 Long
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public Result<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
log.warn("参数类型错误: name={}, value={}", e.getName(), e.getValue());
return Result.fail(1002, "参数[" + e.getName() + "]类型不正确");
}
/**
* 处理参数校验失败(@Valid 校验不通过时抛出,第06章主角)
* 把所有字段的错误信息拼接成一条可读消息
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidation(MethodArgumentNotValidException e) {
String detail = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.reduce((a, b) -> a + ";" + b)
.orElse("参数校验失败");
log.warn("参数校验失败: {}", detail);
return Result.fail(1003, detail);
}
/**
* 处理静态资源404:访问了不存在的路径
*/
@ExceptionHandler(NoResourceFoundException.class)
public Result<Void> handleNoResource(NoResourceFoundException e) {
return Result.fail(1004, "请求的资源不存在: " + e.getResourcePath());
}
/**
* 兜底处理器:上面都没接住的异常最终落到这里
* Exception 类型匹配所有异常,但 Spring 总是优先匹配更具体的类型
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleUnknown(Exception e) {
// error 级别 + 完整堆栈:未知异常必须记录现场以便排查
log.error("系统异常", e);
return Result.fail(5000, "系统繁忙,请稍后再试");
}
}
5.4.4 TimingInterceptor.java ------ 耗时统计拦截器
路径:src/main/java/com/lihaozhe/chapter05/common/TimingInterceptor.java
java
package com.lihaozhe.chapter05.common;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 接口耗时统计拦截器(第05章)
*
* <p>理论知识 ------ HandlerInterceptor 的三个时机:
* <pre>
* preHandle → Controller 方法执行【前】(返回 false 可直接拦下请求)
* ↓
* postHandle → Controller 方法执行后、视图渲染前(前后端分离场景很少用)
* ↓
* afterCompletion → 请求完全结束后(无论成功失败都会执行,适合清理与统计)
* </pre>
*
* <p>拦截器 vs 过滤器(Filter):
* <ul>
* <li>Filter 是 Servlet 规范,在 DispatcherServlet 之前执行,能拦截一切请求;</li>
* <li>Interceptor 是 Spring MVC 机制,只拦截进 Controller 的请求,
* 且能注入 Spring Bean、感知 handler 信息------业务相关横切逻辑首选。</li>
* </ul>
*/
@Component
public class TimingInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(TimingInterceptor.class);
/** ThreadLocal 存开始时间:每个 HTTP 请求由一个线程处理,线程间互不干扰 */
private static final ThreadLocal<Long> START_TIME = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 记录请求进入时间,放行(true = 继续执行后续流程)
START_TIME.set(System.currentTimeMillis());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
long cost = System.currentTimeMillis() - START_TIME.get();
// 记得清理 ThreadLocal!Tomcat 线程会被复用,残留数据会"串"到下一个请求
START_TIME.remove();
log.info("[耗时统计] {} {} → {}ms (HTTP {})",
request.getMethod(), request.getRequestURI(), cost, response.getStatus());
}
}
5.4.5 WebConfig.java ------ 注册拦截器 + CORS
路径:src/main/java/com/lihaozhe/chapter05/config/WebConfig.java
java
package com.lihaozhe.chapter05.config;
import com.lihaozhe.chapter05.common.TimingInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置类(第05章:注册拦截器 + 配置 CORS)
*
* <p>理论知识 ------ WebMvcConfigurer:
* Spring Boot 留给开发者的"MVC 定制入口",实现它的回调方法即可
* 追加配置,而不是覆盖 Boot 的默认配置(对比旧式继承 WebMvcConfigurationSupport)。
*
* <p>理论知识 ------ CORS(跨域资源共享):
* 浏览器的同源策略禁止页面 A(http://a.com)的 JS 请求 B 域名(http://b.com),
* 除非 B 在响应头里明确"授权"。前后端分离时前端 localhost:5173、后端 localhost:8105
* 就是跨域,后端必须配 CORS,否则浏览器拦截响应。
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final TimingInterceptor timingInterceptor;
public WebConfig(TimingInterceptor timingInterceptor) {
this.timingInterceptor = timingInterceptor;
}
/** 注册拦截器:addPathPatterns 拦截哪些路径,excludePathPatterns 排除哪些 */
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(timingInterceptor)
.addPathPatterns("/**") // 拦截所有接口
.excludePathPatterns("/error"); // 排除错误页
}
/** 全局 CORS 配置:允许任何来源访问本服务的任何接口 */
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
// allowedOrigins("*") 与 allowCredentials(true) 不能同时用,
// 新版本请用 allowedOriginPatterns
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.maxAge(3600); // 预检请求缓存1小时,减少 OPTIONS 请求次数
}
}
5.4.6 Destination.java 与 DestinationService.java
路径:src/main/java/com/lihaozhe/chapter05/model/Destination.java
java
package com.lihaozhe.chapter05.model;
/**
* 目的地实体(第05章)
*
* @param id ID
* @param name 名称
* @param city 城市
*/
public record Destination(Long id, String name, String city) {
}
路径:src/main/java/com/lihaozhe/chapter05/service/DestinationService.java
java
package com.lihaozhe.chapter05.service;
import com.lihaozhe.chapter05.common.BusinessException;
import com.lihaozhe.chapter05.model.Destination;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 目的地服务(第05章)
*
* <p>注意:业务方法里没有任何 try-catch!
* 找不到数据就抛 BusinessException,由全局异常处理器统一转换成 Result。
*/
@Service
public class DestinationService {
private final Map<Long, Destination> db = new ConcurrentHashMap<>();
private final AtomicLong idGen = new AtomicLong(0);
public DestinationService() {
save(new Destination(null, "喀什古城", "喀什"));
save(new Destination(null, "稻城亚丁", "甘孜"));
}
public Destination save(Destination input) {
long id = idGen.incrementAndGet();
var saved = new Destination(id, input.name(), input.city());
db.put(id, saved);
return saved;
}
/** 查全部 */
public List<Destination> findAll() {
return List.copyOf(db.values());
}
/**
* 按ID查------找不到直接抛业务异常(对比第04章返回 null/Map 的写法)
*/
public Destination findById(Long id) {
Destination d = db.get(id);
if (d == null) {
throw new BusinessException(2001, "目的地不存在: id=" + id);
}
return d;
}
}
5.4.7 DestinationController.java
路径:src/main/java/com/lihaozhe/chapter05/controller/DestinationController.java
java
package com.lihaozhe.chapter05.controller;
import com.lihaozhe.chapter05.common.Result;
import com.lihaozhe.chapter05.model.Destination;
import com.lihaozhe.chapter05.service.DestinationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 目的地接口(第05章:统一响应版)
*
* <p>对比第04章:所有方法都返回 Result<T>,成功失败结构完全一致。
*/
@RestController
@RequestMapping("/destinations")
public class DestinationController {
private final DestinationService service;
public DestinationController(DestinationService service) {
this.service = service;
}
/**
* 成功场景:GET /destinations
* 返回 {"code":0,"message":"success","data":[...]}
*/
@GetMapping
public Result<java.util.List<Destination>> list() {
return Result.ok(service.findAll());
}
/**
* 业务失败场景:GET /destinations/999
* Service 抛 BusinessException → 全局处理器接住 → 返回 {"code":2001,"message":"目的地不存在..."}
* 注意 Controller 里一行异常处理代码都没有!
*/
@GetMapping("/{id}")
public Result<Destination> detail(@PathVariable Long id) {
return Result.ok(service.findById(id));
}
/**
* 主动抛异常演示:GET /destinations/demo/crash
* 抛出非业务异常 → 落到兜底处理器 → {"code":5000,"message":"系统繁忙..."}
*/
@GetMapping("/demo/crash")
public Result<Void> crash() {
throw new IllegalStateException("模拟空指针前的非法状态");
}
}
5.4.8 启动类与配置文件
启动类 TravelApplication.java 与前几章相同(见本章代码包),配置文件 application.yml:
yaml
# =====================================================================
# 第05章 配置文件
# =====================================================================
server:
port: 8105
spring:
application:
name: chapter05-web-advanced
5.5 运行与验证
bash
mvn -pl chapter05-web-advanced -am clean package -DskipTests
cd chapter05-web-advanced
mvn spring-boot:run
以下均为真实运行结果。
成功场景
bash
curl http://localhost:8105/destinations
json
{"code":0,"message":"success","data":[{"id":1,"name":"喀什古城","city":"喀什"},{"id":2,"name":"稻城亚丁","city":"甘孜"}]}
业务异常(id=999 不存在)
bash
curl http://localhost:8105/destinations/999
json
{"code":2001,"message":"目的地不存在: id=999","data":null}
未知异常兜底
bash
curl http://localhost:8105/destinations/demo/crash
json
{"code":5000,"message":"系统繁忙,请稍后再试","data":null}
参数类型不匹配
bash
curl http://localhost:8105/destinations/abc
json
{"code":1002,"message":"参数[id]类型不正确","data":null}
CORS 生效验证(模拟前端预检请求)
bash
curl -i -X OPTIONS http://localhost:8105/destinations \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: GET"
响应头(节选):
text
HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
拦截器耗时日志(控制台输出,真实记录):
text
[耗时统计] GET /destinations/999 → 8ms (HTTP 200)
[耗时统计] OPTIONS /destinations → 0ms (HTTP 200)
5.6 本章小结
| 你学会了 | 关键点 |
|---|---|
| Result<T> | 泛型 + record + 静态工厂 |
| 全局异常 | 异常类型就近匹配;业务异常 warn、未知异常 error+堆栈 |
| BusinessException | 抛出去比返回 null 好------调用方无法"忘记"失败 |
| 拦截器 | preHandle/afterCompletion + ThreadLocal 清理 |
| CORS | 后端授权跨域;allowedOriginPatterns 用法 |
5.7 动手练习
- 给 Result 增加
timestamp字段(long 类型毫秒时间戳),观察所有响应的变化。 - 新增一个错误码 2002"城市暂未开通服务",并在 Service 中某条件下抛出,验证前端拿到的 code。
- 再写一个登录检查拦截器:请求头缺少
X-Token时直接 response.setStatus(401) 并返回 false 放弃执行(提示:在 preHandle 里操作 HttpServletResponse 写 JSON)。
下一章预告 :现在校验参数还得手写
if (name == null || name.isBlank())。第06章《参数校验 Validation》用@NotBlank @Min @Email等注解声明式校验,并自定义"手机号"注解。
5.8 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| record (Java 16+) | 不可变数据载体,自动生成全参构造器、访问器、equals/hashCode/toString | Result<T> 和 Destination 用 record 定义(Result.java:136、Destination.java:435),保证响应体和实体的不可变性 |
| Stream API + reduce (Java 8+) | 声明式集合处理,reduce 将流简化为单个值 | 在 GlobalExceptionHandler.handleValidation() 中拼接多个字段错误为一条消息(GlobalExceptionHandler.java:64-67) |
| 泛型 (Java 5+) | 编译期类型安全的参数化类型 | Result<T> 中的泛型 T 让 data 字段类型随接口变化(Result.java:26) |
为什么用现代语法? record 让 Result 和 Destination 不可变且零样板代码;Stream API 的 reduce 操作优雅地聚合多个校验错误;泛型保证 Result 中 data 的类型安全,避免了强制类型转换。
第06章 参数校验 Validation:告别手写 if
本章目标:掌握 Jakarta Validation 常用注解,理解 @Valid 与 @Validated 的分工,学会嵌套对象校验与自定义校验注解。
上章回顾:第05章的全局异常处理器已经"认识"了 MethodArgumentNotValidException------本章让它真正登场。
下一章预告:接口的"面子工程"全部完工。从第07章开始进入数据持久化:连接 MySQL,用 JdbcTemplate 把景点存进真实数据库。
6.1 手写校验之痛
没有校验框架时的代码:
java
if (request.name() == null || request.name().isBlank()) {
return Result.fail("姓名不能为空");
}
if (request.peopleCount() == null || request.peopleCount() < 1 || request.peopleCount() > 10) {
return Result.fail("人数必须在1到10之间");
}
// ......十几个字段要写几十行
问题:啰嗦、易漏、校验规则与业务逻辑搅在一起、无法复用。
声明式校验的答案------把规则写在字段上:
java
@NotBlank(message = "姓名不能为空")
String name;
@Min(1) @Max(10)
Integer peopleCount;
6.2 核心机制
- 规范 vs 实现 :Jakarta Bean Validation 是规范(
jakarta.validation.*注解),Hibernate Validator 是默认实现; - 触发开关 :Controller 参数前加
@Valid(或@Validated)才生效; - 失败表现 :@RequestBody 校验失败抛
MethodArgumentNotValidException;单参数(@RequestParam 等)校验失败抛ConstraintViolationException------两者都要在全局异常处理器中接住。
6.3 创建第06章模块
6.3.1 根 pom 注册模块
xml
<module>chapter06-validation</module>
6.3.2 本章 pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
第06章:参数校验 Validation
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter06-validation</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- 校验启动器:Jakarta Validation 规范 + Hibernate Validator 实现 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
6.4 编写代码
6.4.1 自定义注解:PhoneNo.java
路径:src/main/java/com/lihaozhe/chapter06/validation/PhoneNo.java
java
package com.lihaozhe.chapter06.validation;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.regex.Pattern;
/**
* 自定义校验注解:手机号(第06章压轴知识点)
*
* <p>理论知识 ------ 一个自定义校验注解 = "注解定义" + "校验器"两部分:
* <ul>
* <li>@Constraint(validatedBy = PhoneNo.Validator.class):把注解和校验逻辑绑定;</li>
* <li>三要素 message() / groups() / payload() 是规范要求的固定写法,缺一不可;</li>
* <li>校验器实现 ConstraintValidator<注解, 被校验字段类型>:
* 泛型第二个参数决定它能标在什么类型上(这里 String)。</li>
* </ul>
*/
@Documented
@Retention(RetentionPolicy.RUNTIME) // 运行时可读------框架靠反射读取它
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Repeatable(PhoneNo.List.class) // 允许同一字段重复标注(不同分组用不同提示)
@Constraint(validatedBy = PhoneNo.Validator.class)
public @interface PhoneNo {
/** 校验失败时的提示消息,使用时可以覆盖:@PhoneNo(message = "...") */
String message() default "手机号格式不正确";
/** 分组校验支持(固定写法) */
Class<?>[] groups() default {};
/** 元数据载荷(固定写法,一般不动) */
Class<? extends Payload>[] payload() default {};
/**
* 校验器:真正的判断逻辑在这里
*/
class Validator implements ConstraintValidator<PhoneNo, String> {
/** 中国大陆手机号正则:1开头 + 第二位3-9 + 共11位数字 */
private static final Pattern PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
/**
* @param value 被校验的字段值
* @param context 校验上下文(可用于自定义错误消息)
* @return true=通过 false=不通过
*/
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 约定:null 交给 @NotNull/@NotBlank 去管,本注解只管"格式"
if (value == null || value.isBlank()) {
return true;
}
return PATTERN.matcher(value).matches();
}
}
/** @Repeatable 需要的容器注解(固定写法) */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@interface List {
PhoneNo[] value();
}
}
6.4.2 请求体:BookingRequest.java ------ 注解全家福
路径:src/main/java/com/lihaozhe/chapter06/model/BookingRequest.java
java
package com.lihaozhe.chapter06.model;
import com.lihaozhe.chapter06.validation.PhoneNo;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* 跟团报名请求体(第06章:校验注解全家福)
*
* <p>理论知识 ------ record + 校验注解:
* 注解直接标在 record 组件上,Jakarta Validation 会把它们应用到对应字段。
*/
public record BookingRequest(
/** 游客姓名:非空且2~20个字符。message 属性自定义中文提示 */
@NotBlank(message = "姓名不能为空")
@Size(min = 2, max = 20, message = "姓名长度须在{min}到{max}个字符之间")
String name,
/** 联系电话:用我们自定义的 @PhoneNo 注解校验格式 */
@NotBlank(message = "联系电话不能为空")
@PhoneNo
String phone,
/** 同行人数:1~10 人 */
@NotNull(message = "人数不能为空")
@Min(value = 1, message = "至少要有1位游客")
@Max(value = 10, message = "单次最多报名10人")
Integer peopleCount,
/** 预算金额:必须大于0 */
@NotNull(message = "预算不能为空")
@DecimalMin(value = "0.01", message = "预算必须大于0")
Double budget,
/** 身份证号:正则演示(简化版18位) */
@Pattern(regexp = "^\\d{17}[\\dXx]$", message = "身份证号格式不正确")
String idCard,
/**
* 嵌套对象校验的关键:加 @Valid 才会"递归"进去校验!
* 不加的话 EmergencyContact 里面的注解全部不生效------高频坑点。
*/
@NotNull(message = "紧急联系人不能为空")
@Valid
EmergencyContact emergencyContact,
/** 景点偏好列表:@Size 校验列表长度 */
@Size(max = 5, message = "最多选择5个偏好景点")
List<String> preferredAttractions) {
/**
* 紧急联系人(嵌套校验对象)
*/
public record EmergencyContact(
@NotBlank(message = "紧急联系人姓名不能为空")
String contactName,
@NotBlank(message = "紧急联系人电话不能为空")
@PhoneNo(message = "紧急联系人电话格式不正确")
String contactPhone) {
}
}
6.4.3 控制器:BookingController.java
路径:src/main/java/com/lihaozhe/chapter06/controller/BookingController.java
java
package com.lihaozhe.chapter06.controller;
import com.lihaozhe.chapter06.common.Result;
import com.lihaozhe.chapter06.model.BookingRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 报名接口(第06章)
*
* <p>理论知识 ------ @Valid 与 @Validated 的区别:
* <ul>
* <li>@Valid:Jakarta 规范注解,标在参数/字段上触发校验,支持嵌套校验;</li>
* <li>@Validated:Spring 增强,多一个"分组"能力,且能标注在类上
* 让方法参数(@RequestParam/@PathVariable)也被校验。</li>
* </ul>
*/
@RestController
@RequestMapping("/bookings")
@Validated // 类上标注后:下面的 @RequestParam 才会被校验
public class BookingController {
/**
* 接口1:提交报名(对象校验)
* POST /bookings
*
* <p>参数前的 @Valid 是"开关"------没有它,BookingRequest 里的所有注解都不生效。
*/
@PostMapping
public Result<String> book(@Valid @RequestBody BookingRequest request) {
// 走到这里说明全部校验已通过,安心写业务即可(无需任何 if 判断)
return Result.ok("报名成功:" + request.name() + ",同行 " + request.peopleCount() + " 人");
}
/**
* 接口2:查询名额(单参数校验演示)
* GET /bookings/quota?attractionId=5&days=3
*
* <p>注意:@RequestParam 的校验失败抛的是 ConstraintViolationException,
* 与 @RequestBody 的 MethodArgumentNotValidException 不同!
*/
@GetMapping("/quota")
public Result<Integer> quota(
@RequestParam
@Min(value = 1, message = "景点ID必须为正数") Long attractionId,
@RequestParam(defaultValue = "1")
@Min(value = 1, message = "天数最少1天")
@Max(value = 30, message = "天数最多30天") int days) {
// 模拟按景点+天数计算剩余名额
int quota = 100 - days * 10;
return Result.ok(quota);
}
}
6.4.4 公共三件套(Result / BusinessException / GlobalExceptionHandler)
Result 与 BusinessException 和第05章相同(见本章代码包)。全局异常处理器是精简版,重点多了 ConstraintViolationException 处理。
路径:src/main/java/com/lihaozhe/chapter06/common/GlobalExceptionHandler.java
java
package com.lihaozhe.chapter06.common;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* 全局异常处理器(第06章精简版:重点展示校验异常的处理)
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* @Valid 校验失败时抛出 MethodArgumentNotValidException。
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidation(MethodArgumentNotValidException e) {
// 拼接所有字段错误:"名称不能为空;价格必须在0到10000之间"
String detail = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.reduce((a, b) -> a + ";" + b)
.orElse("参数校验失败");
return Result.fail(1003, detail);
}
/** 路径/查询参数类型不匹配(如 id=abc) */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public Result<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
return Result.fail(1002, "参数[" + e.getName() + "]类型不正确");
}
/**
* 单参数(@RequestParam/@PathVariable)校验失败时抛出。
* 与 @RequestBody 的 MethodArgumentNotValidException 是不同的异常类型!
*/
@ExceptionHandler(jakarta.validation.ConstraintViolationException.class)
public Result<Void> handleConstraintViolation(jakarta.validation.ConstraintViolationException e) {
String detail = e.getConstraintViolations().stream()
.map(v -> v.getMessage())
.reduce((a, b) -> a + ";" + b)
.orElse("参数校验失败");
return Result.fail(1003, detail);
}
/** 兜底 */
@ExceptionHandler(Exception.class)
public Result<Void> handleUnknown(Exception e) {
return Result.fail(5000, "系统繁忙,请稍后再试");
}
}
6.4.5 启动类与配置文件
启动类与前几章结构一致。配置文件 application.yml:
yaml
# =====================================================================
# 第06章 配置文件
# =====================================================================
server:
port: 8106
spring:
application:
name: chapter06-validation
6.5 运行与验证
bash
mvn -pl chapter06-validation -am clean package -DskipTests
cd chapter06-validation
mvn spring-boot:run
以下均为真实运行结果。
合法请求
post.json(UTF-8):
json
{"name":"张三丰","phone":"13812345678","peopleCount":3,"budget":5000.0,"idCard":"110101199001011234","emergencyContact":{"contactName":"李四","contactPhone":"13998765432"},"preferredAttractions":["喀纳斯","稻城"]}
bash
curl -X POST http://localhost:8106/bookings \
-H "Content-Type: application/json; charset=utf-8" --data-binary @post.json
json
{"code":0,"message":"success","data":"报名成功:张三丰,同行 3 人"}
多字段非法请求
bad.json:
json
{"name":"张","phone":"12345","peopleCount":20,"budget":-5,"idCard":"abc","emergencyContact":{"contactName":"","contactPhone":"888"},"preferredAttractions":["1","2","3","4","5","6"]}
json
{"code":1003,"message":"budget: 预算必须大于0;peopleCount: 单次最多报名10人;emergencyContact.contactName: 紧急联系人姓名不能为空;idCard: 身份证号格式不正确;phone: 手机号格式不正确;preferredAttractions: 最多选择5个偏好景点;name: 姓名长度须在2到20个字符之间;emergencyContact.contactPhone: 紧急联系人电话格式不正确","data":null}
注意 emergencyContact.xxx 前缀------嵌套校验生效了,错误能定位到具体层级。
嵌套对象缺失
json
{"code":1003,"message":"emergencyContact: 紧急联系人不能为空","data":null}
单参数校验
bash
curl "http://localhost:8106/bookings/quota?attractionId=5&days=99"
curl "http://localhost:8106/bookings/quota?attractionId=5&days=3"
json
{"code":1003,"message":"天数最多30天","data":null}
{"code":0,"message":"success","data":70}
6.6 本章小结
| 你学会了 | 关键点 |
|---|---|
| 常用注解 | @NotNull/@NotBlank/@NotEmpty 三兄弟的区别 |
| 触发开关 | @Valid 开对象校验;类上 @Validated 开单参数校验 |
| 嵌套校验 | 对象字段上加 @Valid 才会递归 |
| 两种异常 | @RequestBody → MethodArgumentNotValidException;单参数 → ConstraintViolationException |
| 自定义注解 | @Constraint 绑定校验器,message/groups/payload 三要素 |
6.7 动手练习
- 给 BookingRequest 增加
@Email邮箱字段并验证。 - 编写自定义注解
@IdCard:更严格的18位身份证校验(含出生日期合法性检查)。 - 实现分组校验:新增时 name 必填、修改时 id 必填(提示:定义 Create/CreateUpdate 两组接口,@Validated(Create.class) 切换)。
下一章预告:内存 Map 一重启就清空------数据该"住"进数据库了。第07章《数据库连接与 JdbcTemplate》连接远程 MySQL,学习 HikariCP 连接池原理与 SQL 初始化机制。
6.8 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| record (Java 16+) | 不可变数据类,自动生成构造器、getter、equals、hashCode、toString | BookingRequest 用 record 替代普通类,校验注解直接标在组件上;EmergencyContact 嵌套在 BookingRequest 内部作为 record |
| 文本块 (Java 15+) | 三引号字符串,支持多行文本,无需转义换行和引 | 注解驱动的校验逻辑说明中使用文本块展示 @NotNull 等常用注解速查表 |
| 嵌套 record (Java 16+) | record 内嵌其他 record,形成层次化数据模型 | BookingRequest.EmergencyContact 作为内部 record,实现嵌套对象校验 |
| 注解与 record 结合 (Java 16+) | 注解可标在 record 组件上,框架自动应用校验 | Jakarta Validation 的 @NotBlank、@Min、@Max 等直接标在 record 组件上 |
为什么用现代语法? record 减少了大量样板代码,使数据模型清晰易读;配合注解校验,实现了声明式的数据验证,避免了冗余的 getter/setter 与手工 if 判断。