@Valid 注解 + 全局处理器优雅处理参数验证
相关地址:
参考 文档:公众号文章
文章目录
系统环境:
Jdk 版本:jdk 8
SpringBoot 版本:2.3.12.RELEASE
一、为什么使用 @Valid 来验证参数
在平常通过 Spring 框架写代码时候,会经常写接口类,相信大家对该类的写法非常熟悉。在写接口时经常要写效验请求参数逻辑,这时候我们会常用做法是写大量的 if 与 if else 类似这样的代码来做判断,如下:
@RestController
public class TestController {
@PostMapping("/user")
public String addUserInfo(@RequestBody User user) {
if (user.getName() == null || "".equals(user.getName()) {
......
} else if(user.getSex() == null || "".equals(user.getSex())) {
......
} else if(user.getUsername() == null || "".equals(user.getUsername())) {
......
} else {
......
}
......
}
}
这样的代码如果按正常代码逻辑来说,是没有什么问题的,不过按优雅来说,简直糟糕透了。不仅不优雅,而且如果存在大量的验证逻辑,这会使代码看起来乱糟糟,大大降低代码可读性,那么有没有更好的方法能够简化这个过程呢?
答案当然是有,推荐的是使用 @Valid 注解来帮助我们简化验证逻辑。
二、@Valid 注解的作用
注解 @Valid 的主要作用是用于数据效验,可以在定义的实体中的属性上,添加不同的注解来完成不同的校验规则,而在接口类中的接收数据参数中添加 @valid 注解,这时你的实体将会开启一个校验的功能。
三、@Valid 的相关注解
下面是 @Valid 相关的注解,在实体类中不同的属性上添加不同的注解,就能实现不同数据的效验功能。
@Valid注解大全及用法规范
注解 | 描述 |
---|---|
@AssertFalse | 带注解的元素必须为false,支持boolean/Boolean |
@AssertTrue | 带注解的元素必须为true,支持boolean/Boolean |
@DecimalMax | 带注解的元素必须是一个数字,其值必须小于等于指定的最大值 |
@DecimalMin | 带注解的元素必须是一个数字,其值必须大于等于指定的最小值 |
@Digits | 带注解的元素必须是一个可接受范围内的数字 |
@Future | 带注解的元素必须是将来的某个时刻、日期或时间 |
@Max | 带注解的元素必须是一个数字,其值必须小于等于指定的最大值 |
@Min | 带注解的元素必须是一个数字,其值必须大于等于指定的最小值 |
@NotNull | 带注解的元素不能是Null |
@Null | 带注解的元素必须是Null |
@Past | 带注解的元素必须是过去的某个时刻、日期或时间 |
@Pattern | 带注解的元素必须符合指定的正则表达式 |
@Size | 带注解的元素必须大于等于指定的最小值,小于等于指定的最大值 |
带注解的元素必须是格式良好的电子邮箱地址 | |
@NotEmpty | 带注解的元素不能是空,String类型不能为null,Array、Map不能为空,切size/length大于0 |
@NotBlank | 字符串不能为空、空字符串、全空格 |
@URL | 字符串必须是一个URL |
四、使用 @Valid 进行参数效验步骤
整个过程如下图所示,用户访问接口,然后进行参数效验,因为 @Valid 不支持平面的参数效验(直接写在参数中字段的效验)所以基于 GET 请求的参数还是按照原先方式进行效验,而 POST 则可以以实体对象为参数,可以使用 @Valid 方式进行效验。如果效验通过,则进入业务逻辑,否则抛出异常,交由全局异常处理器进行处理。
1、实体类中添加 @Valid 相关注解
使用 @Valid 相关注解非常简单,只需要在参数的实体类中属性上面添加如 @NotBlank、@Max、@Min 等注解来对该字段进限制,如下:
User:
@Data
public class User {
@NotBlank(message = "姓名不为空")
private String username;
@NotBlank(message = "密码不为空")
private String password;
}
如果是嵌套的实体对象,则需要在最外层属性上添加 @Valid 注解:
User:
@Data
public class User {
@NotBlank(message = "姓名不为空")
private String username;
@NotBlank(message = "密码不为空")
private String password;
//嵌套必须加 @Valid,否则嵌套中的验证不生效
@Valid
@NotNull(message = "用户信息不能为空")
private UserInfo userInfo;
}
UserInfo:
@Data
public class UserInfo {
@NotBlank(message = "年龄不为空")
@Max(value = 18, message = "不能超过18岁")
private String age;
@NotBlank(message = "性别不能为空")
private String gender;
}
2、接口类中添加 @Valid 注解
在 Controller 类中添加接口,POST 方法中接收设置了 @Valid 相关注解的实体对象,然后在参数中添加 @Valid 注解来开启效验功能,需要注意的是, @Valid 对 Get 请求中接收的平面参数请求无效,稍微略显遗憾。
@RestController
public class TestController {
@PostMapping("/save")
public String addUserInfo(@Valid @RequestBody User user) {
return "调用成功!";
}
}
3、全局异常处理类中处理 @Valid 抛出的异常
最后,我们写一个全局异常处理类,然后对接口中抛出的异常进行处理,而 @Valid 配合 Spring 会抛出 MethodArgumentNotValidException 异常,这里我们需要对该异常进行处理即可。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ResponseStatus(HttpStatus.BAD_REQUEST) //设置状态码为 400
@ExceptionHandler({MethodArgumentNotValidException.class})
public String paramExceptionHandler(MethodArgumentNotValidException e) {
BindingResult exceptions = e.getBindingResult();
// 判断异常中是否有错误信息,如果存在就使用异常中的消息,否则使用默认消息
if (exceptions.hasErrors()) {
List<ObjectError> errors = exceptions.getAllErrors();
if (!errors.isEmpty()) {
// 这里列出了全部错误参数,按正常逻辑,只需要第一条错误即可
FieldError fieldError = (FieldError) errors.get(0);
return fieldError.getDefaultMessage();
}
}
return "请求参数错误";
}
}
五、SpringBoot 中使用 @Valid 示例
1、Maven 引入相关依赖
Maven 引入 SpringBoot 相关依赖,这里引入了 Lombok 包来简化开发过程。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
2、自定义个异常类
自定义个异常类,方便我们处理 GET 请求(GET 请求参数中一般是没有实体对象的,所以不能使用 @Valid),当请求验证失败时,手动抛出自定义异常,交由全局异常处理。
public class ParamErrorException extends RuntimeException {
public ParamErrorException() {
}
public ParamErrorException(String message) {
super(message);
}
}
3、自定义响应类
定义一个返回信息的枚举类,方便我们快速响应信息,不必每次都写返回消息和响应码。
public enum RespCode {
SUCCESS(200, "OK", "请求成功"),
FAIL(9999, "FAIL", "请求失败"),
BAD_REQUEST(400, "Bad Request", "参数无效"),
UNAUTHORIZED(401, "Unauthorized", "未经授权的访问,由于凭据无效被拒绝"),
FORBIDDEN(403, "Forbidden", "请求资源的访问被服务器拒绝"),
NOT_FOUND(404, "Not Found","请求路径不存在"),
METHOD_NOT_ALLOWED(405, "Method Not Allowed", "请求的HTTP方法不允许"),
REQUEST_TIMEOUT(408, "Request Timeout", "请求超时"),
INTERNAL_SERVER_ERROR(500, "Internal Server Error", "服务器内部系统未知异常"),
BAD_GATEWAY(502, "Bad Gateway", "网关异常"),
GATEWAY_TIMEOUT(504, "Gateway Timeout", "网关超时"),
CONNECT_EXCEPTION(530, "Service Connect Exception", "服务连接异常"),
NULL_POINTER_EXCEPTION(550, "Null Pointer Exception", "服务器内部空指针异常"),;
private Integer code;
private String desc;
private String message;
RespCode(Integer code, String desc,String message) {
this.code = code;
this.desc = desc;
this.message = message;
}
public Integer getCode() {
return code;
}
public String getMessage() {
return message;
}
}
4、自定义响应对象类
创建用于返回调用方的响应信息的实体类。
import lombok.Data;
@Data
public class ResponseResult {
private Integer code;
private String msg;
public ResponseResult(){
}
public ResponseResult(RespCode resultEnum){
this.code = resultEnum.getCode();
this.msg = resultEnum.getMessage();
}
public ResponseResult(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
}
5、自定义实体类中添加 @Valid 相关注解
下面将创建用于 POST 方法接收参数的实体对象,里面添加 @Valid 相关验证注解,并在注解中添加出错时的响应消息。
User
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* user实体类
*/
@Data
public class User {
private Long userId;
@NotBlank(message = "姓名不为空")
private String username;
@NotBlank(message = "密码不为空")
private String password;
// 嵌套必须加 @Valid,否则嵌套中的验证不生效
@Valid
@NotNull(message = "userinfo不能为空")
private UserInfo userInfo;
}
UserInfo
import lombok.Data;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
@Data
public class UserInfo {
@NotBlank(message = "年龄不为空")
@Max(value = 18, message = "不能超过18岁")
private String age;
@NotBlank(message = "性别不能为空")
private String gender;
}
6、Controller 中添加 @Valid 注解
接口类中添加 GET 和 POST 方法的两个接口用于测试,其中 POST 方法以上面创建的 Uer 实体对象接收参数,并使用 @Valid,而 GET 请求一般接收参数较少,所以使用正常判断逻辑进行参数效验。
package com.it.controller;
import com.it.entity.User;
import com.it.enums.ResponseResult;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
public class TestController {
/**
* 获取用户信息
*
* @param userId 用户id
* @return ResponseResult
*/
@GetMapping("/info")
public ResponseResult findUserInfo(@RequestParam String userId) {
if (StringUtils.isEmpty(userId)) {
throw new ParamErrorException("userId 不能为空");
}
return new ResponseResult(ResultEnum.SUCCESS);
}
/**
* 新增用户
*
* @param user 用户信息
* @return ResponseResult
*/
@PostMapping("/save")
public String addUserInfo(@Valid @RequestBody User user) {
return "调用成功!";
}
}
7、全局异常处理
这里创建一个全局异常处理类,方便统一处理异常错误信息。里面添加了不同异常处理的方法,专门用于处理接口中抛出的异常信。
package com.it.exception;
import com.it.response.ResponseResult;
import com.it.response.RespCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
import java.util.List;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 请求参数缺失异常
*
* @param e 请求参数缺失异常
* @return ResponseResult
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseResult parameterMissingExceptionHandler(MissingServletRequestParameterException e) {
log.error("", e);
return new ResponseResult(RespCode.BAD_REQUEST.getCode(), "请求参数 " + e.getParameterName() + " 不能为空");
}
/**
* 404找不到资源
*/
@ExceptionHandler(NoHandlerFoundException.class)
@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseResult notFound(NoHandlerFoundException e) {
return new ResponseResult(RespCode.NOT_FOUND.getCode(),RespCode.NOT_FOUND.getMessage());
}
/**
* 缺少请求体异常处理器
*
* @param e 缺少请求体异常
* @return ResponseResult
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseResult parameterBodyMissingExceptionHandler(HttpMessageNotReadableException e) {
log.error("", e);
if (!StringUtils.isEmpty(e.getMessage())) {
return new ResponseResult(RespCode.BAD_REQUEST.getCode(), e.getMessage());
}
return new ResponseResult(RespCode.BAD_REQUEST.getCode(), "缺少请求体!");
}
@ExceptionHandler(value = NullPointerException.class)
public ResponseResult nullPointerExceptionHandler(NullPointerException e) {
log.error("", e);
return new ResponseResult(RespCode.NULL_POINTER_EXCEPTION);
}
/**
* 参数效验异常处理器 拦截 @Valid 校验失败的情况
*
* @param e 参数验证异常
* @return ResponseInfo
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseResult parameterExceptionHandler(MethodArgumentNotValidException e) {
log.error("", e);
// 获取异常信息
return getResponseResult(e.getBindingResult());
}
/**
* 请求参数校验失败,拦截 @Validated 校验失败的情况
* 两个注解 @Valid 和 @Validated 区别是后者可以加分组校验,前者没有分组校验
*/
@ResponseStatus(value = HttpStatus.BAD_REQUEST)
@ExceptionHandler(BindException.class)
public ResponseResult bindException(BindException e) {
log.error("", e);
return getResponseResult(e.getBindingResult());
}
/**
* 自定义参数错误异常处理器
*
* @param e 自定义参数
* @return ResponseInfo
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler({ParamErrorException.class})
public ResponseResult paramExceptionHandler(ParamErrorException e) {
log.error("", e);
// 判断异常中是否有错误信息,如果存在就使用异常中的消息,否则使用默认消息
if (!StringUtils.isEmpty(e.getMessage())) {
return new ResponseResult(RespCode.BAD_REQUEST.getCode(), e.getMessage());
}
return new ResponseResult(RespCode.BAD_REQUEST);
}
private static ResponseResult getResponseResult(BindingResult e) {
// 获取异常信息
BindingResult exceptions = e;
// 判断异常中是否有错误信息,如果存在就使用异常中的消息,否则使用默认消息
if (exceptions.hasErrors()) {
List<ObjectError> errors = exceptions.getAllErrors();
if (!errors.isEmpty()) {
// 这里列出了全部错误参数,按正常逻辑,只需要第一条错误即可
FieldError fieldError = (FieldError) errors.get(0);
return new ResponseResult(RespCode.BAD_REQUEST.getCode(), fieldError.getDefaultMessage());
}
}
return new ResponseResult(RespCode.BAD_REQUEST);
}
}
8、启动类
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
9、示例测试
下面将针对上面示例中设置的两种接口进行测试,分别来验证参数效验功能。
|| - 测试接口 /info
使用 GET 方法请求地址 http://localhost:8080/info?userId=1 时,返回信息:
{
"code": 200,
"msg": "请求成功"
}
当不输入参数,输入地址 http://localhost:8080/info 时,返回信息:
{
"code": 400,
"msg": "请求参数 userId 不能为空"
}
可以看到在执行 GET 请求,能够正常按我们全局异常处理器中的设置处理异常信息。
|| - 测试接口 /save
(1)、使用 POST 方法发起请求,首先进行不加 JSON 请求体来对 http://localhost:8080/save 地址进行请求,返回信息:
{
"code": 400,
"msg": "参数体不能为空"
}
(2)、输入部分参数进行测试。
请求内容:
{
"username":"test",
"pa***rd":"***"
}
返回信息:
{
"code": 400,
"msg": "用户信息不能为空"
}
(3)、输入完整参数,且设置 age > 18 时,进行测试。
{
"username":"zs",
"pa***ord":"111",
"userInfo": {
"age":"19",
"gender":"男"
}
}
返回信息:
2022-09-14 15:53:01.483 ERROR 33592 --- [nio-8000-exec-3] com.it.exception.GlobalExceptionHandler :
org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.it.response.ResponseResult com.it.controller.TestController1.addUserInfo(com.it.entity.User): [Field error in object 'user' on field 'userInfo.age': rejected value [19]; codes [Max.user.userInfo.age,Max.userInfo.age,Max.age,Max.java.lang.String,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.userInfo.age,userInfo.age]; arguments []; default message [userInfo.age],18]; default message [不能超过18岁]]
{
"code": 400,
"msg": "不能超过18岁"
}
可以看到在执行 POST 请求,也能正常按我们全局异常处理器中的设置处理异常信息,且提示信息为我们设置在实体类中的 Message。
10 @Validated 注解开启注解校验功能
上述例子咱们用的是@Valid进行校验的,用@Validated注解也可以,那么他两的区别是什么呢?
所属不同:
该注解所属包为:javax.validation.Valid,而 @Validated是Spring基于@Valid进一步封装,并提供了一些高级功能,如分组,分组顺序等
11.@Valid和@Validated高级使用
1.Valid级联校验
级联校验也叫嵌套检测,嵌套即一个实体类包含另一个实体类
@Data
public class User {
@NotBlank(message = "姓名不为空")
private String username;
@NotBlank(message = "密码不为空")
private String password;
//嵌套必须加 @Valid,否则嵌套中的验证不生效
@Valid
@NotNull(message = "用户信息不能为空")
private UserInfo userInfo;
}
2.@Validated分组校验
分组校验是由@Validated的value方法提供的,用于开启指定的组校验,分别作用不同的业务场景中
BaseRequest:
@Data
public class BaseRequest implements Serializable {
public BaseRequest() {
}
public interface Detail {
}
public interface Delete {
}
public interface Add {
}
public interface Edit {
}
}
User:
@Data
public class User extends BaseRequest {
@NotNull(message = "id不能为空",groups = {Detail.class,Delete.class, Edit.class})
@Min(value = 1, message = "id必须为正整数",groups = {Detail.class,Delete.class, Edit.class})
private Long userId;
@NotBlank(message = "姓名不为空",groups = {Add.class})
private String username;
@NotBlank(message = "密码不为空",groups = {Add.class})
private String password;
@NotBlank(message = "地址不为空")
private String add;
private String mobile;
//嵌套必须加 @Valid,否则嵌套中的验证不生效
@Valid
@NotNull(message = "用户信息不能为空",groups = {Add.class})
private UserInfo userInfo;
}
Controller
@Validated
@RestController
public class TestController {
/**
* 获取用户信息
*
* @param userId 用户id
* @return ResponseResult
*/
@GetMapping("/info")
public ResponseResult info(@RequestParam String userId) {
if (StringUtils.isEmpty(userId)) {
throw new ParamErrorException("userId 不能为空");
}
return new ResponseResult(RespCode.SUCCESS);
}
//@Validated注解对于非实体类的校验,在类上注解才会起效果
@GetMapping("/info1")
public ResponseResult info1(@NotBlank(message = "参数不能为空") @RequestParam String userId) {
return new ResponseResult(RespCode.SUCCESS);
}
/**
* 新增用户
*
* @param user 用户信息
* @return ResponseResult
*/
@RequestMapping(value = "/save", method = RequestMethod.POST)
public ResponseResult save(@Validated({BaseRequest.Add.class}) @RequestBody User user) {
return new ResponseResult(RespCode.SUCCESS);
}
/**
* 修改用户信息
*
* @param user 用户信息
* @return ResponseResult
*/
@RequestMapping(value = "/edit", method = RequestMethod.PUT)
public ResponseResult update(@Validated({BaseRequest.Edit.class}) @RequestBody User user) {
return new ResponseResult(RespCode.SUCCESS);
}
}
12. 测试接口 /save
(1)、使用 POST 方法发起请求,对 http://localhost:8080/save 地址进行请求,
请求信息:
{
"username":"zs",
"userInfo": {
"age":"18",
"gender":"男"
}
}
返回信息:
{
"code": 400,
"msg": "密码不为空"
}
结论:
如果校验注解添加上groups方法并指定分组,只有@Validated注解value方法指定该分组,才会开启校验注解的校验数据功能
13. 解决@ControllerAdvice不能捕获NoHandlerFoundException
对不存在的接口进行请求 http://localhost:8080/save1
{
"timestamp": "2022-09-14T08:32:26.863+00:00",
"status": 404,
"error": "Not Found",
"message": "",
"path": "/save2"
}
确保异常类里面已经添加了捕获Handler
/**
* 404找不到资源
*/
@ExceptionHandler(NoHandlerFoundException.class)
@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseResult notFound(NoHandlerFoundException e) {
return new ResponseResult(RespCode.NOT_FOUND.getCode(),RespCode.NOT_FOUND.getMessage());
}
研究DispatcherServlet源码发现,NoHandlerFoundException异常能否被抛出,关键在如下代码:
// Determine handler for the current request.
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
protected void noHandlerFound(HttpServletRequest request, HttpServletResponse response) throws Exception {
if (pageNotFoundLogger.isWarnEnabled()) {
pageNotFoundLogger.warn("No mapping for " + request.getMethod() + " " + getRequestUri(request));
}
//private boolean throwExceptionIfNoHandlerFound = false;
if (this.throwExceptionIfNoHandlerFound) {
throw new NoHandlerFoundException(request.getMethod(), getRequestUri(request),
new ServletServerHttpRequest(request).getHeaders());
}
else {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
只有找不到对应该请求的处理器时,才会进入下面的noHandler方法去抛出NoHandlerFoundException异常。
所以配置如下:
spring:
mvc:
throw-exception-if-no-handler-found: true
通过测试发现,光配置这个还不行,springboot的WebMvcAutoConfiguration会默认配置如下资源映射:
/映射到/static(或/public、/resources、/META-INF/resources) /webjars/ 映射到classpath:/META-INF/resources/webjars/ /**/favicon.ico映射favicon.ico文件.
这下就明白了,即使你的地址错误,仍然会匹配到/**这个静态资源映射地址,就不会进入noHandlerFound方法,自然不会抛出NoHandlerFoundException了。
所以,我们需要的就是改掉默认的静态资源映射访问路径就可以了。
配置如下属性,NoHandlerFoundException异常就能被@ControllerAdvice捕获了
application.yaml
server:
port: 8000
spring:
mvc:
throw-exception-if-no-handler-found: true
static-path-pattern: /statics/**
{
"code": 404,
"msg": "请求路径不存在"
}