《SpringBoot 3:入门与应用实战》第 9 章 使用 WebMvc 开发应用 阅读笔记 20
9.6 常用注解的使用
经过前面两节的练习后,读者可能会对这个过程中的部分代码感到疑惑和不满:每次都要写拥有同样 URI 前缀的请求路径很麻烦,而且不知道怎么限定请求的方式等。为此,本节要对前面出现的两个注解进行解释,再讲解 9.5.3 节提到的 @DateTimeFormat 注解,最后补充讲解一下RESTful 的编码风格。
9.6.1 @RequestMapping
@RequestMapping 标注在 Controller 的方法后,对应的方法会变为一个被 WebMvc 利用的 Handler 方法,通过访问 @RequestMapping 注解上标注的请求路径可以触发方法的调用。除了可以标注在方法上,@RequestMapping 还可以直接标注在 Controller 类上,标注后 Controller 中所有的 Handler 方法请求路径都会被追加前缀,即 Controller 类上的 @RequestMapping 路径+方法上的 @RequestMapping 路径。代码清单展示了两种等价的标注方法。

HTTP 有 8 种请求方式,它们分别是 GET、POST、PUT、DELETE、HEAD、CONNECT、OPTIONS、TRACE,大部分读者相对熟悉的是GET 和 POST。通过设置 @RequestMapping 注解的 method 属性,可以限定 Handler 的请求方式。

直接用浏览器访问 /user/save 请求。由于使用浏览器直接输入地址访问时发送的是 GET 请求,与 @RequestMapping 限定的不符,因此WebMvc 会响应 405 状态码,提示不允许 GET 方式的请求。

自 Spring Framework 4.2 之后,@RequestMapping 注解多了几个派生注解,使用这些派生注解可以更方便地限定请求方式,编码也会更方便。
java
@RequestMapping(value = "/list", method = RequestMethod.GET)
@GetMapping("/list")
@PostMapping("/list")
@PutMapping("/list")
@DeleteMapping("/list")
其实它的底层封装简单得很,借助 IDE 查看源码就能了解到,派生注解仅仅是多了一个限定的 method=RequestMethod.GET,其余的都是借助@AliasFor 将属性值映射到内部的 @RequestMapping 中而已,也正是这样一些小小的优化,给我们开发者带来了便捷。

9.6.2 @DateTimeFormat
9.5.3 节中提到了字符串和日期的转换,其实 WebMvc 给提供了一个很方便的注解 @DateTimeFormat,只需要指定转换的日期时间格式它就可以自动实现字符串和日期的转换。譬如在 9.5 节的 User 类中给 birthday 属性标注 @DateTimeFormat 注解。
之后将 WebMvcConfiguration 配置类中的 String2DateConverter 注册暂时注释掉,之后重启应用,重新执行一次用户信息的编辑动作,可以发现效果与自定义类型转换完全一致。实际的项目开发中笔者更推荐使用 @DateTimeFormat 注解而不是自定义类型转换,原因是@DateTimeFormat 注解可以针对模型类中的每个属性单独设置转换格式,相对来讲更加灵活。



9.6.3 @RestController
在前面接触到了两个与 Controller 相关的注解:@Controller 和 @RestController,它们来自不同的 jar 包。@Controller 来自 spring-context 包,说明即便在没有 WebMvc 的环境中也有其他组件可以充当控制器的角色;@RestController 注解来自 spring-web 包(并且在 Spring Framework 4.0 版本后才出现),意味着这是进行 Web 开发时专门扩展的注解。简单地理解,@RestController表示 @Controller 标注的类中的所有 Handler 方法都被标注了 @ResponseBody,即标注后整个 Controller 中的所有 Handler 方法都不会跳转页面,而是将返回值作为响应体返回给客户端。
通常来讲,@RestController 注解更多出现在前后端分离的项目中,这种项目最大的特点是后端不负责页面视图的跳转,只负责请求响应和数据传递,页面视图的控制逻辑由前端独立负责;而 @Controller 注解都出现在前后端不分离的项目中,因为视图的跳转需要后端的 Controller 负责,所以不能直接声明 @RestController 注解。
9.6.4 RESTful 编码风格
1.RESTful 概述
表现层状态转换 (Representational State Transfer,REST) 这个术语听起来很抽象,换一种更容易理解的说法解释。HTTP 中有 8 种请求方式,其中包含 GET、POST、PUT 和 DELETE 这 4 种常用的请求方式,RESTful 的编码风格将这四种请求方式赋予真正的意义。
- GET:获取资源/数据。
- POST:新建资源/数据。
- PUT:更新资源/数据。
- DELETE:删除资源/数据。
注意,RESTful 只是一种编码风格,不是标准、不是协议、不是规范,仅仅是风格而已,如果用这种编码风格的话,设计的 API 请求路径看上去更简洁、更有层次性。对于RESTful的定义读者没有必要了解太多,主要掌握如何实现 RESTful 风格的编码即可。
2.URI 的 RESTful
通常说的 RESTful 都是基于 API 层面的 RESTful,也就是基于 URI 的 RESTful。这种编码风格与传统接口请求路径的对比如表所示。
| 操作 | 传统接口路径 (面向操作) | RESTful 风格路径 (面向资源) |
|---|---|---|
| 查询用户列表 | GET /api/getUserList |
GET /api/users |
| 查询单个用户 | GET /api/getUser?id=123 |
GET /api/users/123 |
| 新增用户 | POST /api/addUser |
POST /api/users |
| 修改用户 | POST /api/updateUser |
PUT /api/users/123 |
| 删除用户 | GET /api/deleteUser?id=123 |
DELETE /api/users/123 |
通过对比两种不同的接口请求路径风格,读者是否能看出其中的端倪?两者最大的区别是:传统的接口请求路径需要通过接口名来分辨接口的业务含义,而 RESTful 风格的请求路径则是通过 HTTP 的请求方式区分。除此之外参数的传递方式也有所不同,传统接口的参数传递通常使用URL 参数拼接,而 RESTful 风格的请求参数会直接嵌在 URL 中成为 URL 的一部分。
从实际项目开发角度出发,两种风格没有优劣之分,学习阶段读者可以根据自己的个人喜好练习,项目开发时则最好与团队风格保持一致。
3.RESTful 风格的 Controller
下面基于 RESTful 的编码风格制作一个 RestfulDepartmentController,简单演示 RESTful 风格的代码应该如何编写。整体上难度不大,唯一陌生的注解是 @PathVariable,它是支持 RESTful 风格编码的重要注解。

java
package com.yangjunbo.springboot.webmvc.examplef;
import com.yangjunbo.springboot.webmvc.examplec.Department;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.BeanUtils;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/department")
public class RestfulDepartmentController {
private List<Department> departmentList = new ArrayList<>();
@PostConstruct
public void init() {
Department dept1 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门1", "123321");
departmentList.add(dept1);
Department dept2 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门2", "1234567");
departmentList.add(dept2);
}
@GetMapping("/{id}")
public Department findById(@PathVariable("id") String id) {
return departmentList.stream().filter(i -> i.getId().equals(id)).findAny().orElse(null);
}
@PostMapping("/")
public void save(Department department) {
departmentList.add(department);
}
@PutMapping("/{id}")
public void update(Department department, @PathVariable("id") String id) {
departmentList.stream().filter(i -> i.getId().equals(id)).findAny().ifPresent(i -> {
// 将修改的department属性复制到原来的数据,即相当于修改
BeanUtils.copyProperties(department, i, "id");
});
}
@DeleteMapping("/{id}")
public void delete(@PathVariable("id") String id) {
departmentList.stream().filter(i -> i.getId().equals(id)).findAny().ifPresent(i -> departmentList.remove(i));
}
}
4.@PathVariable
下面解释 @PathVariable 的作用,它可以解析 @RequestMapping 及其派生注解中的URI参数。使用 @PathVariable 时只需要将其标注在Handler 方法的参数上,就可以解析 @RequestMapping 中 URI 的指定参数。例如 URI 是 /department/{id},那么实际发送请求的一个示例 URI就应该是 /department/6ded6d3bdc8f4fc70bcc4347822a5ca3。
项目开发中有可能会遇到 URI 中有多个请求参数,如果需要一次性传递多个参数,可以直接在 URI 中将它们全部拼接起来,如 /department/{id}/{name}/{tel},对应的 Handler 方法的参数列表中都能找到一一对应的参数,并且配置 @PathVariable 即可。默认情况下@PathVariable 标注的参数都是必填项,如果需要设置某个参数为非必填项,只需要修改 @PathVariable 的 required 为 false。
有一个小细节,每个 Handler 方法中的 @PathVariable 都设置了 value。通常属性名和 URI 中的参数占位符名一致时,是不需要显式声明 value的,但笔者使用 @PathVariable 时都会设置,一是出于个人编码喜好,二是考虑到参数名有改变的可能。
9.7 JSON 支持
前面的章节中多次看到了数据作为响应体传递给客户端浏览器的场景,使用 @ResponseBody 来实现这一效果。对于被标注了 @RestController的控制器而言,其内部的所有 Handler 方法也都是响应 JSON 数据。前后端分离的项目开发中,要求后端应用必须支持 JSON 数据的请求和响应,WebMvc 自然也对其进行了全方位的支持,下面简单介绍 WebMvc 中 JSON 作为数据传递方式的使用方法。
9.7.1 JSON 支持与配置
Spring Boot 整合 WebMvc 时底层已经附带了 JSON 的支持,前面使用 @ResponseBody 时没有出现问题,就是最好的证明。WebMvc 可以整合的 JSON 库有很多,包括 Jackson、Gson、Fastjson 等,Spring Boot 默认选择了最稳定、效率很高的 Jackson。
得益于 Spring Boot 的自动装配机制,默认拥有处理 JSON 的能力,可以通过干预 WebMvc 中整合的 JSON 组件完成一些个性化配置。以Jackson 为例,可以向 IOC 容器中注册一个自定义的 JsonMapperBuilderCustomizer 实现一些特殊的定制,比如修改默认的日期时间格式和时区。

java
package com.yangjunbo.springboot.webmvc.exampleg;
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import java.text.SimpleDateFormat;
import java.util.TimeZone;
@Configuration
public class JsonConfiguration {
@Bean
public JsonMapperBuilderCustomizer jsonMapperCustomizer() {
return builder -> builder
.defaultDateFormat(new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss"))
.defaultTimeZone(TimeZone.getTimeZone("CTT"));
}
}
对比一下注册与不注册的区别,当修改 JsonMapperBuilderCustomizer 之前,日期格式是 Date 的默认输出格式,而且时区是默认的格林尼治时;注册自定义的 JsonMapperBuilderCustomizer 进行替换后输出的日期格式是修改后的中文格式,并且从格式化的时间结果也能看得出来时区被正确调整。


9.7.2 @ResponseBody 和 @RequestBody
前面接触了 @ResponseBody 的使用,它可以将 Handler 方法的返回值作为响应体序列化为 JSON 返回给客户端。与 @ResponseBody 相对应的注解是 @RequestBody,它的作用是将请求体的 JSON 数据转换为模型对象,所以使用 @RequestBody 也是一种参数收集的方式。
下面简单演示 @RequestBody 的使用,在 RestfulDepartmentController 中再编写一个 saveJson 方法,它接收一个完整的 Department 对象参数,随后保存到内部的 departmentList 集合中。由于 RestfulDepartmentController 上标注了 @RestController,因此编写的方法自带@ResponseBody,那么当方法被触发时,最终能得到一个 "success" 字符串的响应。

java
package com.yangjunbo.springboot.webmvc.examplef;
import com.yangjunbo.springboot.webmvc.examplec.Department;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.BeanUtils;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/department")
public class RestfulDepartmentController {
private List<Department> departmentList = new ArrayList<>();
@PostConstruct
public void init() {
Department dept1 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门1", "123321");
departmentList.add(dept1);
Department dept2 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门2", "1234567");
departmentList.add(dept2);
}
@GetMapping("/{id}")
public Department findById(@PathVariable("id") String id) {
return departmentList.stream().filter(i -> i.getId().equals(id)).findAny().orElse(null);
}
@PostMapping("/")
public void save(Department department) {
departmentList.add(department);
}
@PutMapping("/{id}")
public void update(Department department, @PathVariable("id") String id) {
departmentList.stream().filter(i -> i.getId().equals(id)).findAny().ifPresent(i -> {
// 将修改的department属性复制到原来的数据,即相当于修改
BeanUtils.copyProperties(department, i, "id");
});
}
@DeleteMapping("/{id}")
public void delete(@PathVariable("id") String id) {
departmentList.stream().filter(i -> i.getId().equals(id)).findAny().ifPresent(i -> departmentList.remove(i));
}
@PostMapping("/saveJson")
public String saveJson(@RequestBody Department department) {
System.out.println(department);
departmentList.add(department);
return "success";
}
}
演示两种触发 @RequestBody 的方式。首先是借助 API 工具触发,诸如 Postman、Apifox 等。

编辑完毕后单击右侧的 "发送" 按钮,在下方的响应窗口中可以看到 success 的内容,说明发送的 JSON 数据已经成功被 WebMvc 利用 Jackson转换为 Department 对象,从后端的控制台中也能看得到对象的 toString 输出。

实际项目开发中更多的是使用 Axios 或者 jQuery 等前端 JS 库发送请求。代码中分别展示了使用 Axios 和 jQuery 发送 POST 请求的示例代码。需要区分的是,默认情况下 Axios 发送 POST 请求时传递的参数就是 JSON 格式,而 jQuery 需要手动设置 contentType 为 application/json。

html
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>部门列表</title>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<script>
$(function () {
axios.post('[[@{/department/saveJson}]]', {
id: 'aaa',
name: 'bbb',
tel: 'ccc'
}).then(function(res) {
alert(res.data);
});
$.ajax({
url: '[[@{/department/saveJson}]]',
type: 'post',
data: JSON.stringify({
id: 'aaa',
name: 'bbb',
tel: 'ccc'
}), // 这里也可以直接写JSON字符串,也可以借助ES5中的JSON进行字符串序列化
contentType: 'application/json;charset=utf-8',
success: function(data) {
alert(data);
}
});
});
</script>
<body>
<h3>部门列表</h3>
<div>
<form id="query-form" method="get" th:action="@{/department/list5}">
<label>部门名称:</label>
<input type="text" name="name" value="">
<input type="submit" value="查询">
</form>
</div>
<table id="dept-table" border="1">
<thead>
<tr>
<th width="320px">id</th>
<th width="150px">名称</th>
<th width="150px">电话</th>
<th width="100px">操作</th>
</tr>
</thead>
<tbody>
<tr th:each="dept : ${deptList}">
<td align="center">[[${dept.id}]]</td>
<td align="center">[[${dept.name}]]</td>
<td align="center">[[${dept.tel}]]</td>
<td align="center">
<a th:href="@{|/department/edit?id=${dept.id}|}">编辑</a>
<a th:href="|javascript:del('${dept.id}')|">删除</a>
</td>
</tr>
</tbody>
</table>
</body>
</html>
编写完毕后重新访问 /department/list 页面,发现浏览器可以正确弹出两次 alert 的提示,说明两次请求都正确发送并生效,WebMvc 都正确接收到了请求体的数据并转换为模型对象。

9.8 静态资源配置
实际的项目开发中一般不会直接引用 CDN 的静态资源,而是将这些静态资源下载到本地并放在项目的静态资源目录中,这样做的目的是避免CDN 源文件突然不可访问,以及 CDN 文件发生变化后影响程序的正常运行。接下来将 jQuery.min.js 和 axios.min.js 两个文件放到工程的src/main/resources/static/js 中,完成后续的测试。

9.8.1 默认的静态资源位置
JS 库下载到本地后,下一步需要把 deptList.html 中的静态资源引用改为本地路径,使用 @{} 表达式引用 /static/js 下的两个 JS 文件,注意引用路径中没有 /static 前缀)。修改完毕后直接重启应用,刷新页面后发现 Ajax 请求依然可以正常发送,说明基于 Spring Boot 的工程在引用静态资源时背后有支持的机制。

html
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>部门列表</title>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
<script th:src="@{/js/axios.min.js}"></script>
</head>
<script>
$(function () {
axios.post('[[@{/department/saveJson}]]', {
id: 'aaa',
name: 'bbb',
tel: 'ccc'
}).then(function(res) {
alert(res.data);
});
$.ajax({
url: '[[@{/department/saveJson}]]',
type: 'post',
data: JSON.stringify({
id: 'aaa',
name: 'bbb',
tel: 'ccc'
}), // 这里也可以直接写JSON字符串,也可以借助ES5中的JSON进行字符串序列化
contentType: 'application/json;charset=utf-8',
success: function(data) {
alert(data);
}
});
});
</script>
<body>
<h3>部门列表</h3>
<div>
<form id="query-form" method="get" th:action="@{/department/list5}">
<label>部门名称:</label>
<input type="text" name="name" value="">
<input type="submit" value="查询">
</form>
</div>
<table id="dept-table" border="1">
<thead>
<tr>
<th width="320px">id</th>
<th width="150px">名称</th>
<th width="150px">电话</th>
<th width="100px">操作</th>
</tr>
</thead>
<tbody>
<tr th:each="dept : ${deptList}">
<td align="center">[[${dept.id}]]</td>
<td align="center">[[${dept.name}]]</td>
<td align="center">[[${dept.tel}]]</td>
<td align="center">
<a th:href="@{|/department/edit?id=${dept.id}|}">编辑</a>
<a th:href="|javascript:del('${dept.id}')|">删除</a>
</td>
</tr>
</tbody>
</table>
</body>
</html>
之所以可以这样写,是因为默认情况下 Spring Boot 加载了几个静态资源目录的规则,只要静态资源位于 classpath 下的以下几个路径,在页面中引用时就可以直接声明。
- /META-INF/resources
- /resources
- /static(最常用)
- /public
通常创建的 Spring Boot 应用在整合 WebMvc 时,会在 resources 目录下创建一个 static 文件夹来放置所有的静态资源文件,并将这些静态资源都映射到 /** 请求路径上,这也体现了约定大于配置。
9.8.2 定制化静态资源配置
如果需要修改原有的静态资源配置,有如下几个扩展点和修改点。
- 替换默认的静态资源目录:通过修改 spring.web.resources.static-locations 指定,该配置项会接收一个字符串数组,设置该属性后原有的静态资源目录规则会失效。


- 覆盖静态访问路径的根路径:通过修改 spring.mvc.static-path-pattern 指定,默认值 /,例如设置 /mvc/ 后访问所有的静态资源时就要以 /mvc 开头。


- 配置自定义静态路径映射规则:通过重写 WebMvcConfigurer 的 addResourceHandlers 方法进行编程式配置,同时可以指定静态资源的缓存时间。



9.9 数据校验
回顾前面编写的示例代码中,有一个问题是无法避免和绕过的:数据的正确性、合理性。如果一个数据表单中填写的都是空数据或错误数据,那么提交到后端后收到的就是垃圾数据,如果这种数据越来越多,数据库中的数据就会被严重污染。为了解决这个问题,要引入数据校验。
9.9.1 页面的数据校验
通常在项目开发中,数据的校验逻辑大多以页面的前端校验为主,现在对于基本的 HTML + jQuery 页面也好,Vue/React/AngularJS 也好,它们都可以找到一些比较成熟的校验组件,甚至有的 UI 框架自带校验,所以对于一般的数据校验,在页面的 input 上控制即可。
然而仅限于前端校验远远不够,对于某些重要数据的请求来讲,请求携带的数据会涉及金钱、隐私信息等(比方说订单创建、充值等),别有用心的攻击者可以通过借助 API 工具等方式自行构建请求,给应用发送错误数据,如果此时没有后端校验作为第二道防线,后果将不堪设想。为此必须对重要的请求进行参数的后端校验。
9.9.2 后端的数据校验
后端的数据校验本质上是借助 Java EE 6 规范中的 JSR-303 规范以及后期升级的 JSR-380 规范,它制定了一套完整的数据校验接口,我们就是利用这套规范来实现。当然有规范就要有对应的落地实现,WebMvc 选择了 Hibernate Validator 作为 JSR-303 的默认落地实现。
1.引入依赖
在 Spring Boot 2.3.0.RELEASE 之前的版本中 spring-boot-starter-web 会传递依赖 spring-boot-starter-validation,而 2.3.0.RELEASE 之后的版本中移除了该依赖,Spring Boot 这样做的目的是让开发者 "按需引入",避免在不使用校验框架时浪费不必要的成本。

xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
2.简单使用校验注解
引入校验组件后,下面先简单使用两个校验注解快速体会。JSR-303 规范中提出的数据校验主要是基于对象的校验,所以可以修改 User 类的内容,在其中添加两个校验注解。从注解名上不难读懂,设置的校验规则是 username 和 name 属性不能为 null 或者空字符串,并且 username 的字符串长度必须为 6~20 位,可见 JSR-303 规范中校验注解的可读性都不错。

java
package com.yangjunbo.springboot.webmvc.exampled;
import com.yangjunbo.springboot.webmvc.examplec.Department;
import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.Length;
import org.springframework.format.annotation.DateTimeFormat;
import java.util.Arrays;
import java.util.Date;
public class User {
private String id;
@NotBlank(message = "用户名不能为空")
@Length(min = 6, max = 20, message = "用户名的长度必须为6-20位")
private String username;
@NotBlank(message = "用户姓名不能为空")
private String name;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date birthday;
private byte[] photo;
private Department department;
public User() {
}
public User(String id, String username, String name, Date birthday, byte[] photo, Department department) {
this.id = id;
this.username = username;
this.name = name;
this.birthday = birthday;
this.photo = photo;
this.department = department;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Date getBirthday() {
return birthday;
}
public void setBirthday(Date birthday) {
this.birthday = birthday;
}
public byte[] getPhoto() {
return photo;
}
public void setPhoto(byte[] photo) {
this.photo = photo;
}
public Department getDepartment() {
return department;
}
public void setDepartment(Department department) {
this.department = department;
}
@Override
public String toString() {
return "User{" +
"id='" + id + '\'' +
", username='" + username + '\'' +
", name='" + name + '\'' +
", birthday=" + birthday +
", photo=" + Arrays.toString(photo) +
", department=" + department +
'}';
}
}
需要读者注意的是,在标注注解时要分辨注解所在的包,如果出现两个同名的注解,应当导入包名以 jakarta.validation 开头的注解,这是J SR-303 规范的标准注解(不要忘记导包的基本原则:有规范导规范,没有规范导实现)。
3.声明校验与输出校验失败信息
只在类上声明校验注解还不够,还需要显式声明具体哪个接口需要数据校验,例如要在 save 方法上进行数据校验,就需要在 save 方法的 User参数上标注一个 @Validated 注解,这样 WebMvc 就会在接口被调用时依据校验规则对参数进行校验。

标注之后重启应用,在用户信息编辑页面删掉用户名的表单输入框,之后直接单击 "保存用户" 按钮,浏览器会收到 400 状态码和一段报错提示信息,从提示中可以分辨和提取出校验失败的信息,但是整体上看这种提示对用户而言非常不友好。

期望的比较友好的结果是:当校验不通过时,通过一些方式方法提示用户提交的哪些数据不合法即可,为此需要收集数据校验失败的信息。在Handler 方法的参数列表中可以声明一个 BindingResult 类型的参数,它就是可以接收校验失败信息的组件,有了它之后就可以构造错误信息,之后将其输出到页面上或者响应给客户端。为了方便演示,直接抛出了携带校验失败信息的异常 RuntimeException,实际的项目开发中通常会以响应体的形式返回给客户端,由客户端负责展示这些错误信息。重新启动应用后重复一次操作,可以发现这次浏览器中收到的状态码是 500,并且可以正确、直接地获取校验错误信息。

java
package com.yangjunbo.springboot.webmvc.exampled;
import com.yangjunbo.springboot.webmvc.examplec.Department;
import jakarta.annotation.PostConstruct;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Controller
public class UserController {
private List<Department> departmentList = new ArrayList<>();
private List<User> userList = new ArrayList<>();
@PostConstruct
public void init() throws Exception {
Department dept1 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门1", "123321");
departmentList.add(dept1);
Department dept2 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门2", "1234567");
departmentList.add(dept2);
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
User user1 = new User(UUID.randomUUID().toString().replaceAll("-", ""),"zhangsan", "张三", dateFormat.parse("2023-01-01"), null, dept1);
userList.add(user1);
User user2 = new User(UUID.randomUUID().toString().replaceAll("-", ""),"lisi", "李四", dateFormat.parse("2023-02-02"), null, dept1);
userList.add(user2);
User user3 = new User(UUID.randomUUID().toString().replaceAll("-", ""),"wangwu", "王五", dateFormat.parse("2023-03-03"), null, dept2);
userList.add(user3);
}
@RequestMapping("/user/list")
public String list(String username, Model model) {
Stream<User> stream = this.userList.stream();
if (StringUtils.hasText(username)) {
stream = stream.filter(i -> i.getUsername().contains(username));
}
model.addAttribute("userList", stream.collect(Collectors.toList()));
return "userList";
}
@RequestMapping("/user/edit")
public String edit(String id, Model model) {
model.addAttribute("user", this.userList.stream().filter(i -> i.getId().equals(id)). findAny().orElse(null));
model.addAttribute("deptList", this.departmentList);
return "userInfo";
}
@RequestMapping("/user/batchDelete")
@ResponseBody
public String batchDelete(String[] ids) {
System.out.println(Arrays.toString(ids));
return "success";
}
@RequestMapping("/user/batchUpdate")
public String batchUpdate(UsersVO vo) {
System.out.println(Arrays.toString(vo.getUsers()));
return "redirect:/user/list";
}
@RequestMapping(value = "/user/save", method = RequestMethod.POST)
public String save(@Validated User user, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
String errorMessage = bindingResult.getAllErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(";"));
throw new RuntimeException("数据格式不正确:" + errorMessage);
}
System.out.println(user);
return "redirect:/user/list";
}
@RequestMapping("/user/getUser")
@ResponseBody
public User getUser() throws ParseException {
Department dept1 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门1", "123321");
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
User user1 = new User(UUID.randomUUID().toString().replaceAll("-", ""),"zhangsan", "张三", dateFormat.parse("2023-01-01"), null, dept1);
return user1;
}
}

之所以上述两次试验请求响应的状态码不同,是由错误引发的位置不同导致的。第一次请求响应 400 状态码是WebMvc响应,WebMvc 认为一个不符合校验规则的请求是一次错误请求,而 400 状态码对应的是客户端请求的问题;第二次请求响应 500 状态码是服务端主动抛出异常导致的,WebMvc 认为这个异常是意料之外的,与客户端没有关系,于是抛出了含义为服务端错误的 500 状态码。
4.常用的校验注解
JSR-303(Bean Validation)规范为 Java 对象的数据校验提供了一套标准化的注解。在实际的 Spring Boot 开发中,通常会结合 Hibernate Validator 实现来使用。
以下是 JSR-303 规范及 Hibernate Validator 扩展中最常用的校验注解,按功能分类整理:
📋 空值与字符串校验
这类注解主要用于判断对象是否为空,以及字符串的长度和格式。
| 注解 | 适用类型 | 说明 |
|---|---|---|
@Null |
任何类型 | 被注释的元素必须为 null |
@NotNull |
任何类型 | 被注释的元素不能为 null,但不检查字符串长度 |
@NotEmpty |
字符串、集合、数组 | 不能为 null,且长度/大小必须大于 0 |
@NotBlank |
字符串 | 不能为 null,且去除首尾空格后长度必须大于 0 |
@Size(min, max) |
字符串、集合、数组 | 验证元素的大小/长度是否在指定范围内 |
@Length(min, max) |
字符串 | (Hibernate扩展) 验证字符串长度是否在指定范围内 |
@Email |
字符串 | 验证字符串是否为合法的电子邮箱地址 |
@Pattern(regexp) |
字符串 | 验证字符串是否符合指定的正则表达式 |
🔢 数值校验
用于限制数字的大小、范围及精度。
| 注解 | 适用类型 | 说明 |
|---|---|---|
@Min(value) |
数字类型 | 被注释的元素必须是一个数字,其值 >= 指定值 |
@Max(value) |
数字类型 | 被注释的元素必须是一个数字,其值 <= 指定值 |
@DecimalMin(value) |
数字类型 | 被注释的元素必须是一个数字,其值 >= 指定值(支持小数) |
@DecimalMax(value) |
数字类型 | 被注释的元素必须是一个数字,其值 <= 指定值(支持小数) |
@Digits(integer, fraction) |
数字类型 | 验证数字的整数部分位数和小数部分位数是否符合要求 |
@Range(min, max) |
数字类型 | (Hibernate扩展) 验证数字是否在指定的最小值和最大值之间 |
📅 日期与布尔校验
用于校验日期时间和布尔值。
| 注解 | 适用类型 | 说明 |
|---|---|---|
@Past |
日期/时间类型 | 被注释的元素必须是一个过去的时间或日期 |
@Future |
日期/时间类型 | 被注释的元素必须是一个将来的时间或日期 |
@AssertTrue |
Boolean | 被注释的元素必须为 true |
@AssertFalse |
Boolean | 被注释的元素必须为 false |
💡 核心使用技巧
- 触发校验 :在 Controller 的方法参数前添加
@Valid或 Spring 提供的@Validated注解,即可触发校验。 @Validvs@Validated:@Valid:JSR-303 标准注解,支持嵌套校验(在嵌套对象字段上也加@Valid),但不支持分组。@Validated:Spring 提供的注解,支持分组校验 (如@Validated(AddGroup.class)),但不能用在成员属性上。
- 嵌套校验 :如果一个对象内部还包含另一个需要校验的对象,需要在嵌套对象的字段上添加
@Valid注解,才能触发递归校验。 - 异常处理 :校验失败时,Spring 会抛出
MethodArgumentNotValidException。最佳实践是使用@RestControllerAdvice定义全局异常处理器,统一捕获并返回友好的错误信息,而不是在每个接口里手动处理BindingResult。
9.9.3 分组校验
在实体类上标注校验注解之后,有可能会面临下一个问题:不同的业务场景下使用的校验规则不同(例如用户信息的编辑和用户密码的修改,这两者都要有一定的校验,但用户密码修改的校验不涉及对其他信息进行校验)。为了能把多套校验规则同时标注在一个实体类上,且能互相区分开,就需要用到数据校验的一个非常重要的机制:分组校验。前面使用过的校验注解也好,表格中列举的注解也好,这些注解都有一个属性:group,通过给 group 声明不同的接口,就可以实现分组校验。
下面简单演示分组校验的使用方式,首先需要声明两个标记型接口(内部没有任何定义的接口)。

java
package com.yangjunbo.springboot.webmvc.exampled;
public interface NameGroup { }
java
package com.yangjunbo.springboot.webmvc.exampled;
public interface UserNameGroup { }
之后回到 User 类中,假定有两种校验场景,一种只校验 name,另一种 username 和 name 都要校验,则可以有代码所示的声明方式。哪个注解适用于哪个校验场景,只需要将其加入 groups 中即可。

java
package com.yangjunbo.springboot.webmvc.exampled;
import com.yangjunbo.springboot.webmvc.examplec.Department;
import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.Length;
import org.springframework.format.annotation.DateTimeFormat;
import java.util.Arrays;
import java.util.Date;
public class User {
private String id;
@NotBlank(message = "用户名不能为空", groups = UserNameGroup.class)
@Length(min = 6, max = 20, message = "用户名的长度必须为6-20位", groups = UserNameGroup.class)
private String username;
@NotBlank(message = "用户姓名不能为空", groups = {NameGroup.class, UserNameGroup.class})
private String name;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date birthday;
private byte[] photo;
private Department department;
public User() {
}
public User(String id, String username, String name, Date birthday, byte[] photo, Department department) {
this.id = id;
this.username = username;
this.name = name;
this.birthday = birthday;
this.photo = photo;
this.department = department;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Date getBirthday() {
return birthday;
}
public void setBirthday(Date birthday) {
this.birthday = birthday;
}
public byte[] getPhoto() {
return photo;
}
public void setPhoto(byte[] photo) {
this.photo = photo;
}
public Department getDepartment() {
return department;
}
public void setDepartment(Department department) {
this.department = department;
}
@Override
public String toString() {
return "User{" +
"id='" + id + '\'' +
", username='" + username + '\'' +
", name='" + name + '\'' +
", birthday=" + birthday +
", photo=" + Arrays.toString(photo) +
", department=" + department +
'}';
}
}
接下来,在 Controller 的 save 方法上,给 @Validated 注解添加校验组的声明,代码中声明的是只校验 name 属性的校验组。

代码编写完毕,重启应用后重新访问用户信息编辑页面,这次将所有的输入框都删掉,单击 "保存用户" 按钮后只提示了 "用户姓名不能为空",这就说明 username 属性的校验没有生效,分组校验成功。

9.9.4 校验错误信息外部化
如果仔细观察校验注解中的错误信息,会发现一个问题:错误信息被硬编码到 Java 代码中,这种做法是不被推荐的。为此需要将这些校验的错误信息提取出来,放在一个可以单独维护的地方。JSR-303 规范中提出了一个规则:可以将校验失败的信息放到一个特殊的 properties 文件中,内部使用 key-value 的形式将提示信息提取出来。于是按照这个要求就可以编写一个 validation-message.properties 文件,将其放到resources/messages 目录下。

user.username.notblank=用户名不能为空
user.username.length=用户名的长度必须为6-20位
user.name.notblank=用户姓名不能为空
接下来要想让这个文件生效,需要在 application.properties 文件中配置一个属性:spring.messages.basename=messages/validation-message,这相当于告诉 Spring Boot,需要取占位符内容就到这个文件中找,换句话说,这个属性配置让 Spring Boot 认识了编写的这个文件。

yaml
spring:
application:
name: springboot-webmvc-a
# web:
# resources:
# static-locations: classpath:/file/
# mvc:
# static-path-pattern: /mvc/**
messages:
basename: messages/validation-message
server:
servlet:
context-path: /springboot-webmvc-a
完成配置后就可以将校验的内容由硬编码改为占位符的方式,注意编写占位符时不要加 $ 符号。

配置完成后重启应用,复现一次 name 属性为空的表单提交,发现浏览器依然能收到中文提示 "用户姓名不能为空",说明错误信息的外部化已经实现。