一、概述
当我们想提供可靠的 API 接口,对参数的校验,以保证最终数据入库的正确性,是 必不可少 的活。比如下图就是 我们一个项目里 新增一个菜单校验 参数的函数,写了一大堆的 if else 进行校验,非常的不优雅,比起枯燥的CRUD来说,参数校验更是枯燥。
这只是一个创建菜单的校验,只需要判断菜单,菜单url 以及菜单的父类id是否为空,上级菜单是否挂载正确,这样已经消耗掉了30,40行代码了,更不要说,管理后台创建商品这种参数贼多的接口。估计要写几百行校验代码了。
二、注解
在开始入门之前,我们先了解下本文可能会涉及到的注解。javax.validation.constraints 包下,定义了一系列的约束( constraint )注解。共 22个,如下:
大致可以分为以下几类:
2.1 空和非空检查
@NotBlank
:只能用于字符串不为 null ,并且字符串#trim()
以后 length 要大于 0 。@NotEmpty
:集合对象的元素不为 0 ,即集合不为空,也可以用于字符串不为 null 。@NotNull
:不能为 null 。@Null
:必须为 null 。
2.2 数值检查
@DecimalMax(value)
:被注释的元素必须是一个数字,其值必须小于等于指定的最大值。@DecimalMin(value)
:被注释的元素必须是一个数字,其值必须大于等于指定的最小值。@Digits(integer, fraction)
:被注释的元素必须是一个数字,其值必须在可接受的范围内。@Positive
:判断正数。@PositiveOrZero
:判断正数或 0 。@Max(value)
:该字段的值只能小于或等于该值。@Min(value)
:该字段的值只能大于或等于该值。-@Negative
:判断负数。@NegativeOrZero
:判断负数或 0 。
2.3 Boolean 值检查
@AssertFalse
:被注释的元素必须为 true 。@AssertTrue
:被注释的元素必须为 false 。
2.4 长度检查
@Size(max, min)
:检查该字段的 size 是否在 min 和 max 之间,可以是字符串、数组、集合、Map 等。
2.5 日期检查
@Future
:被注释的元素必须是一个将来的日期。@FutureOrPresent
:判断日期是否是将来或现在日期。@Past
:检查该字段的日期是在过去。@PastOrPresent
:判断日期是否是过去或现在日期。
2.6 其它检查
@Email
:被注释的元素必须是电子邮箱地址。@Pattern(value)
:被注释的元素必须符合指定的正则表达式。
2.7 Hibernate Validator 附加的约束注解
org.hibernate.validator.constraints
包下,定义了一系列的约束( constraint )注解。如下:
@Range(min=, max=)
:被注释的元素必须在合适的范围内。@Length(min=, max=)
:被注释的字符串的大小必须在指定的范围内。@URL(protocol=,host=,port=,regexp=,flags=)
:被注释的字符串必须是一个有效的 URL 。@SafeHtml
:判断提交的 HTML 是否安全。例如说,不能包含 javascript 脚本等等。
2.8 @Valid 和 @Validated
@Valid
注解,是 Bean Validation 所定义,可以添加在普通方法、构造方法、方法参数、方法返回、成员变量上,表示它们需要进行约束校验。
@Validated
注解,是 Spring Validation 锁定义,可以添加在类、方法参数、普通方法上,表示它们需要进行约束校验。同时,@Validated 有 value 属性,支持分组校验。属性如下:
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {
/**
* Specify one or more validation groups to apply to the validation step
* kicked off by this annotation.
* <p>JSR-303 defines validation groups as custom annotations which an application declares
* for the sole purpose of using them as type-safe group arguments, as implemented in
* {@link org.springframework.validation.beanvalidation.SpringValidatorAdapter}.
* <p>Other {@link org.springframework.validation.SmartValidator} implementations may
* support class arguments in other ways as well.
*/
Class<?>[] value() default {};
}
对于初学的胖友来说,很容易搞混 @Valid(javax.validation
包下) 和 @Validated (org.springframework.validation.annotation
包下)注解。两者大致有以下的区别:
@Valid 有嵌套对象校验功能 例如说:如果不在 User.profile
属性上,添加 @Valid 注解,就会导致 UserProfile.nickname
属性,不会进行校验。
// User.java
public class User {
private String id;
@Valid
private UserProfile profile;
}
// UserProfile.java
public class UserProfile {
@NotBlank
private String nickname;
}
总的来说,绝大多数场景下,我们使用 @Validated 注解即可。而在有嵌套校验的场景,我们使用 @Valid 注解添加到成员属性上。
三、快速入门
3.1 引入依赖
<?xml version="1.0" encoding="UTF-8"?>
<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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.4.RELEASE</version>
<relativePath/>
</parent>
<groupId>com.ratel</groupId>
<artifactId>java-validation</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>java-validation</name>
<description>java validation action</description>
<properties>
<java.version>1.8</java.version>
</properties>
<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>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
在 Spring Boot 体系中,也提供了 spring-boot-starter-validation
依赖。在这里,我们并没有引入。为什么呢?因为 spring-boot-starter-web
已经引入了 spring-boot-starter-validation
,而 spring-boot-starter-validation
中也引入了 hibernate-validator
依赖,所以无需重复引入。三者的依赖引入关系可见下图
3.2 创建基本的类
UserAddDTO 实体类:
package com.ratel.validation.entity;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
/**
* @Description
* @Author ratelfu
* @Date 2023/04/07
* @Version 1.0
*/
@Data
public class UserAddDTO {
/**
* 账号
*/
@NotEmpty(message = "登录账号不能为空")
@Length(min = 5, max = 16, message = "账号长度为 5-16 位")
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
private String username;
/**
* 密码
*/
@NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password;
}
UserController 用来写接口,在类上,添加 @Validated 注解,表示 UserController 是所有接口都需要进行参数校验。
package com.ratel.validation.cotroller;
import com.ratel.validation.entity.UserAddDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.Min;
@RestController
@RequestMapping("/users")
@Validated
public class UserController {
private Logger logger = LoggerFactory.getLogger(getClass());
@GetMapping("/get")
public UserAddDTO get(@RequestParam("id") @Min(value = 1L, message = "编号必须大于 0") Integer id) {
logger.info("[get][id: {}]", id);
UserAddDTO userAddDTO = new UserAddDTO();
userAddDTO.setUsername("张三");
userAddDTO.setPassword("123456");
return userAddDTO;
}
@PostMapping("/add")
public void add(@Valid @RequestBody UserAddDTO addDTO) {
logger.info("[add][addDTO: {}]", addDTO);
}
}
3.3 启动程序,进行测试
启动程序,然后再浏览器里我们就可以进行输入: swagger访问地址: http://localhost:8080/doc.html#/home
打开swagger文档 就可以进行测试了:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Kwy6GAGL-1682496366878)(data:image/svg+xml,%3C%3Fxml version=‘1.0’ encoding=‘UTF-8’%3F%3E%3Csvg width=‘1px’ height=‘1px’ viewBox=‘0 0 1 1’ version=‘1.1’ xmlns=‘http://www.w3.org/2000/svg’ xmlns:xlink=‘http://www.w3.org/1999/xlink’%3E%3Ctitle%3E%3C/title%3E%3Cg stroke=‘none’ stroke-width=‘1’ fill=‘none’ fill-rule=‘evenodd’ fill-opacity=‘0’%3E%3Cg transform=‘translate(-249.000000, -126.000000)]’ fill=‘%23FFFFFF’%3E%3Crect x=‘249’ y=‘126’ width=‘1’ height=‘1’%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
首先我们访问 http://localhost:8080/users/get?id=-1
进行测试,查看返回结果,果然对我们的 id 进行校验。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MdKTHYCp-1682496366879)(data:image/svg+xml,%3C%3Fxml version=‘1.0’ encoding=‘UTF-8’%3F%3E%3Csvg width=‘1px’ height=‘1px’ viewBox=‘0 0 1 1’ version=‘1.1’ xmlns=‘http://www.w3.org/2000/svg’ xmlns:xlink=‘http://www.w3.org/1999/xlink’%3E%3Ctitle%3E%3C/title%3E%3Cg stroke=‘none’ stroke-width=‘1’ fill=‘none’ fill-rule=‘evenodd’ fill-opacity=‘0’%3E%3Cg transform=‘translate(-249.000000, -126.000000)]’ fill=‘%23FFFFFF’%3E%3Crect x=‘249’ y=‘126’ width=‘1’ height=‘1’%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
接下来我们访问 http://localhost:8080/users/add
进行新增用户的校验:请求体我们写成:
{
"password": "233",
"username": "33"
}
然后返回结果的如下:
{
"timestamp": "2023-04-09T13:33:58.864+0000",
"status": 400,
"error": "Bad Request",
"errors": [
{
"codes": [
"Length.userAddDTO.password",
"Length.password",
"Length.java.lang.String",
"Length"
],
"arguments": [
{
"codes": [
"userAddDTO.password",
"password"
],
"arguments": null,
"defaultMessage": "password",
"code": "password"
},
16,
4
],
"defaultMessage": "密码长度为 4-16 位",
"objectName": "userAddDTO",
"field": "password",
"rejectedValue": "233",
"bindingFailure": false,
"code": "Length"
},
{
"codes": [
"Length.userAddDTO.username",
"Length.username",
"Length.java.lang.String",
"Length"
],
"arguments": [
{
"codes": [
"userAddDTO.username",
"username"
],
"arguments": null,
"defaultMessage": "username",
"code": "username"
},
16,
5
],
"defaultMessage": "账号长度为 5-16 位",
"objectName": "userAddDTO",
"field": "username",
"rejectedValue": "33",
"bindingFailure": false,
"code": "Length"
}
],
"message": "Validation failed for object='userAddDTO'. Error count: 2",
"path": "/users/add"
}
返回结果的json串中的 errors 字段,参数错误明细数组。每一个数组元素,对应一个参数错误明细。这里,username 违背了 账号长度为 5-16 位规定。password违反了 密码长度为 4-16 位的规定。
返回结果示意图:
3.4 一些疑问
在这里 细心的小伙伴可能会有几个疑问:
3.4.1 疑问一
#get(id)
方法上,我们并没有给 id 添加 @Valid 注解,而 #add(addDTO)
方法上,我们给 addDTO 添加 @Valid 注解。这个差异,是为什么呢?
因为 UserController
使用了 @Validated 注解,那么 Spring Validation 就会使用 AOP 进行切面,进行参数校验。而该切面的拦截器,使用的是 MethodValidationInterceptor
。
- 对于
#get(id)
方法,需要校验的参数 id ,是平铺开的,所以无需添加 @Valid 注解。 - 对于
#add(addDTO)
方法,需要校验的参数 addDTO ,实际相当于嵌套校验,要校验的参数的都在 addDTO 里面,所以需要添加 @Valid (其实实测加@Validated也行,暂时不知道为啥 为了好区分就先用 @Valid 吧 )注解。
3.4.2 疑问二
#get(id)
方法的返回的结果是 status = 500
,而 #add(addDTO)
方法的返回的结果是 status = 400
。
- 对于
#get(id)
方法,在MethodValidationInterceptor
拦截器中,校验到参数不正确,会抛出ConstraintViolationException
异常。 - 对于
#add(addDTO)
方法,因为 addDTO 是个 POJO 对象,所以会走 SpringMVC 的 DataBinder 机制,它会调用DataBinder#validate(Object... validationHints)
方法,进行校验。在校验不通过时,会抛出BindException
。
在 SpringMVC 中,默认使用 DefaultHandlerExceptionResolver
处理异常。
- 对于
BindException
异常,处理成 400 的状态码。 - 对于
ConstraintViolationException
异常,没有特殊处理,所以处理成 500 的状态码。
这里,我们在抛个问题,如果 #add(addDTO)
方法,如果参数正确,在走完 DataBinder 中的参数校验后,会不会在走一遍 MethodValidationInterceptor
的拦截器呢?思考 100 毫秒…
答案是会。这样,就会导致浪费。所以 Controller 类里,如果 只有 类似的 #add(addDTO)
方法的 嵌套校验,那么我可以不在 Controller 类上添加 @Validated 注解。从而实现,仅使用 DataBinder 中来做参数校验。
3.4.3 返回提示很不友好,太长了
第三点,无论是 #get(id)
方法,还是 #add(addDTO)
方法,它们的返回提示都非常不友好,那么该怎么办呢?我们将在 第四章节通过 处理校验异常 进行处理。
四、处理校验异常
4.1 校验不通过的枚举类
package com.ratel.validation.enums;
/**
* 业务异常枚举
*/
public enum ServiceExceptionEnum {
// ========== 系统级别 ==========
SUCCESS(0, "成功"),
SYS_ERROR(2001001000, "服务端发生异常"),
MISSING_REQUEST_PARAM_ERROR(2001001001, "参数缺失"),
INVALID_REQUEST_PARAM_ERROR(2001001002, "请求参数不合法"),
// ========== 用户模块 ==========
USER_NOT_FOUND(1001002000, "用户不存在"),
// ========== 订单模块 ==========
// ========== 商品模块 ==========
;
/**
* 错误码
*/
private final int code;
/**
* 错误提示
*/
private final String message;
ServiceExceptionEnum(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
4.2 统一返回结果实体类
package com.ratel.validation.common;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.springframework.util.Assert;
import java.io.Serializable;
/**
* 通用返回结果
*
* @param <T> 结果泛型
*/
public class CommonResult<T> implements Serializable {
public static Integer CODE_SUCCESS = 0;
/**
* 错误码
*/
private Integer code;
/**
* 错误提示
*/
private String message;
/**
* 返回数据
*/
private T data;
/**
* 将传入的 result 对象,转换成另外一个泛型结果的对象
*
* 因为 A 方法返回的 CommonResult 对象,不满足调用其的 B 方法的返回,所以需要进行转换。
*
* @param result 传入的 result 对象
* @param <T> 返回的泛型
* @return 新的 CommonResult 对象
*/
public static <T> CommonResult<T> error(CommonResult<?> result) {
return error(result.getCode(), result.getMessage());
}
public static <T> CommonResult<T> error(Integer code, String message) {
Assert.isTrue(!CODE_SUCCESS.equals(code), "code 必须是错误的!");
CommonResult<T> result = new CommonResult<>();
result.code = code;
result.message = message;
return result;
}
public static <T> CommonResult<T> success(T data) {
CommonResult<T> result = new CommonResult<>();
result.code = CODE_SUCCESS;
result.data = data;
result.message = "";
return result;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
@JsonIgnore
public boolean isSuccess() {
return CODE_SUCCESS.equals(code);
}
@JsonIgnore
public boolean isError() {
return !isSuccess();
}
@Override
public String toString() {
return "CommonResult{" +
"code=" + code +
", message='" + message + '\'' +
", data=" + data +
'}';
}
}
4.3 增加全局异常处理类 GlobalExceptionHandler
package com.ratel.validation.exception;
import com.ratel.validation.common.CommonResult;
import com.ratel.validation.enums.ServiceExceptionEnum;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.validation.BindException;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
@ControllerAdvice(basePackages = "com.ratel.validation.cotroller")
public class GlobalExceptionHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
/**
* 处理 MissingServletRequestParameterException 异常
*
* SpringMVC 参数不正确
*/
@ResponseBody
@ExceptionHandler(value = MissingServletRequestParameterException.class)
public CommonResult missingServletRequestParameterExceptionHandler(HttpServletRequest req, MissingServletRequestParameterException ex) {
logger.error("[missingServletRequestParameterExceptionHandler]", ex);
// 包装 CommonResult 结果
return CommonResult.error(ServiceExceptionEnum.MISSING_REQUEST_PARAM_ERROR.getCode(),
ServiceExceptionEnum.MISSING_REQUEST_PARAM_ERROR.getMessage());
}
@ResponseBody
@ExceptionHandler(value = ConstraintViolationException.class)
public CommonResult constraintViolationExceptionHandler(HttpServletRequest req, ConstraintViolationException ex) {
logger.error("[constraintViolationExceptionHandler]", ex);
// 拼接错误
StringBuilder detailMessage = new StringBuilder();
for (ConstraintViolation<?> constraintViolation : ex.getConstraintViolations()) {
// 使用 ; 分隔多个错误
if (detailMessage.length() > 0) {
detailMessage.append(";");
}
// 拼接内容到其中
detailMessage.append(constraintViolation.getMessage());
}
// 包装 CommonResult 结果
return CommonResult.error(ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getCode(),
ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getMessage() + ":" + detailMessage.toString());
}
@ResponseBody
@ExceptionHandler(value = BindException.class)
public CommonResult bindExceptionHandler(HttpServletRequest req, BindException ex) {
logger.info("========进入了 bindException======");
logger.error("[bindExceptionHandler]", ex);
// 拼接错误
StringBuilder detailMessage = new StringBuilder();
for (ObjectError objectError : ex.getAllErrors()) {
// 使用 ; 分隔多个错误
if (detailMessage.length() > 0) {
detailMessage.append(";");
}
// 拼接内容到其中
detailMessage.append(objectError.getDefaultMessage());
}
// 包装 CommonResult 结果
return CommonResult.error(ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getCode(),
ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getMessage() + ":" + detailMessage.toString());
}
@ResponseBody
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public CommonResult MethodArgumentNotValidExceptionHandler(HttpServletRequest req, MethodArgumentNotValidException ex) {
logger.info("-----------------进入了 MethodArgumentNotValidException-----------------");
logger.error("[MethodArgumentNotValidException]", ex);
// 拼接错误
StringBuilder detailMessage = new StringBuilder();
for (ObjectError objectError : ex.getBindingResult().getAllErrors()) {
// 使用 ; 分隔多个错误
if (detailMessage.length() > 0) {
detailMessage.append(";");
}
// 拼接内容到其中
detailMessage.append(objectError.getDefaultMessage());
}
// 包装 CommonResult 结果
return CommonResult.error(ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getCode(),
ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getMessage() + ":" + detailMessage.toString());
}
/**
* 处理其它 Exception 异常
* @param req
* @param e
* @return
*/
@ResponseBody
@ExceptionHandler(value = Exception.class)
public CommonResult exceptionHandler(HttpServletRequest req, Exception e) {
// 记录异常日志
logger.error("[exceptionHandler]", e);
// 返回 ERROR CommonResult
return CommonResult.error(ServiceExceptionEnum.SYS_ERROR.getCode(),
ServiceExceptionEnum.SYS_ERROR.getMessage());
}
}
4.4 测试
访问:http://localhost:8080/users/add
可以看到异常结果已经被拼接成一个字符串,相比之前清新 易懂了不少。