spring validate 实现优雅的参数校验
常用注解
@Valid:用于方法参数,表示该参数需要进行数据校验。
@NotNull:用于字段,表示该字段不能为null。
@NotEmpty:用于集合或字符串字段,表示该字段不能为null并且长度/大小必须大于0。
@NotBlank:用于字符串字段,表示该字段不能为null并且去除首尾空格后的长度必须大于0。
@Size(min=, max=):用于字符串或集合字段,表示该字段的长度/大小必须在指定的范围内。
@Min(value=) 和 @Max(value=):用于数值字段,表示该字段的值必须在指定的范围内。
@Pattern(regexp=):用于字符串字段,表示该字段的值必须匹配指定的正则表达式。
@Email:用于字符串字段,表示该字段的值必须是一个有效的电子邮件地址。
@Past 和 @Future:用于日期字段,表示该字段的值必须是一个过去的日期或一个未来的日期。
@Positive 和 @Negative:用于数值字段,表示该字段的值必须是正数或负数。
引入依赖
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.0.1.Final</version>
</dependency>
对于web服务来说,为防止非法参数对业务造成影响,在Controller层一定要做参数校验的!大部分情况下,请求参数分为如下两种形式:
- POST、PUT请求,使用requestBody传递参数;
- GET请求,使用requestParam/PathVariable传递参数。
requestBody参数校验
POST、PUT请求一般会使用requestBody传递参数,这种情况下,后端使用DTO对象进行接收。只要给DTO对象加上@Validated注解就能实现自动参数校验。比如,有一个保存User的接口,要求userName长度是2-10,account和password字段长度是6-20。如果校验失败,会抛出MethodArgumentNotValidException异常,Spring默认会将其转为400(Bad Request)请求。
DTO表示数据传输对象(Data Transfer Object),用于服务器和客户端之间交互传输使用的。在spring-web项目中可以表示用于接收请求参数的Bean对象。
@Data
public class LoginFormDTO {
@NotNull(message = "手机号不能为空")
@Length(min = 11, max = 11, message = "手机号格式不正确")
private String phone;
@NotNull(message = "密码不能为空")
@Length(min = 6, max = 20, message = "密码格式不正确")
private String password;
@NotNull(message = "验证码不能为空")
@Length(min = 4, max = 4, message = "验证码格式不正确")
private String code;
}
在方法参数上声明注解:
@PostMapping("easyLogin")
@ApiOperation("简单登录")
@AuthAccess
public Result easyLogin(@RequestBody @Validated LoginFormDTO loginFormDTO) throws SerialException {
if (StrUtil.isBlank(loginFormDTO.getPhone()) || StrUtil.isBlank(loginFormDTO.getPassword())) {
return Result.Failed("输入数据不合法");
}
return this.userInfoService.easyLogin(loginFormDTO);
}
这种情况下使用@validated和@valid都可以
requestParam/PathVariable参数校验
GET请求一般会使用requestParam/PathVariable传参。如果参数比较多(比如超过6个),还是推荐使用DTO对象接收。否则,推荐将一个个参数平铺到方法入参中。在这种情况下,必须在Controller类上标注@Validated注解,并在入参上声明约束注解(如@Min等)。如果校验失败,会抛出ConstraintViolationException异常。代码示例如下:
@PostMapping("/sendCode")
@ApiOperation("发送验证码")
@AuthAccess
public Result sendCode(@ApiParam("手机号")
@RequestParam
@Length(min = 11, max = 11, message = "手机号格式不正确")
@NotNull String phone) {
if (log.isInfoEnabled()) {
log.info("userController.sendCode.sendCode.phone:{}", phone);
}
return userInfoService.sendCode(phone);
}
统一异常处理
如果校验失败,会抛出MethodArgumentNotValidException或者ConstraintViolationException异常。在实际项目开发中,通常会用统一异常处理来返回一个更友好的提示。比如我们系统要求无论发送什么异常,http的状态码必须返回200,由业务码去区分系统的异常情况。
package com.cqie.datacommon.exception;
import com.cqie.datacommon.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolationException;
/**
* @Author QingYuQiao
* @date 2024/5/6 11:20
* 参数校验异常处理类
*/
@RestControllerAdvice
@Slf4j
public class CommonExceptionHandler {
/**
* 方法参数校验异常处理
* @param exception
* @return
*/
@ExceptionHandler
@ResponseBody
@ResponseStatus(HttpStatus.OK)
public Result handlerMethodArgumentNotValidException(MethodArgumentNotValidException exception){
BindingResult bindingResult = exception.getBindingResult();
StringBuilder sb = new StringBuilder("校验失败:");
for(FieldError fieldError : bindingResult.getFieldErrors()){
sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(",");
}
String msg = sb.toString();
log.error(msg);
return Result.Failed(msg);
}
/**
* 约束校验异常处理(外键)
* @param exception
* @return
*/
@ExceptionHandler({ConstraintViolationException.class})
@ResponseBody
@ResponseStatus(HttpStatus.OK)
public Result handlerConstraintViolationException(ConstraintViolationException exception){
return Result.Failed("参数校验失败:" + exception.getMessage());
}
}
进阶使用
分组校验
package com.cqie.datauser.entity.dto;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotNull;
/**
* @Author QingYuQiao
* @date 2024/3/18 10:11
*/
@Data
public class UserInfoDTO {
/**
* 自增主键
*/
@TableId(type = IdType.AUTO)
@ApiModelProperty("id")
@NotNull(groups = {Update.class})
private Integer id;
/**
* 用户名
*/
@ApiModelProperty("用户名")
@NotNull(message = "密码不能为空", groups = {Save.class})
private String userName;
/**
* 密码
*/
@ApiModelProperty("密码")
@NotNull(message = "密码不能为空", groups = {Save.class, ResetPassword.class})
@Length(
groups = {Save.class, ResetPassword.class, Update.class},
min = 6,
max = 16,
message = "密码长度必须在6-16之间"
)
private String password;
/**
* 账号
*/
@ApiModelProperty("账号")
@NotNull(message = "账号不能为空", groups = {Save.class, Update.class})
private String account;
/**
* 性别(0 - 男 1 - 女 2 - 待确认)
*/
@ApiModelProperty("性别(0 - 男 1 - 女 2 - 待确认)")
private Integer sex;
/**
* 住址
*/
@ApiModelProperty("住址")
private String address;
/**
* 电话
*/
@ApiModelProperty("电话")
@Length(max = 11, message = "电话长度不能超过11位")
@NotNull(message = "电话不能为空", groups = {Save.class, Update.class})
private String phone;
@ApiModelProperty("头像")
private String image;
@ApiModelProperty("token")
private String token;
@ApiModelProperty("新密码")
@NotNull(groups = {ResetPassword.class}, message = "新密码不能为空")
@Length(groups = {ResetPassword.class}, min = 6, max = 16, message = "新密码长度必须在6-16之间")
private String newPassword;
@ApiModelProperty("确认密码")
@NotNull(groups = {ResetPassword.class}, message = "新密码不能为空")
@Length(groups = {ResetPassword.class}, min = 6, max = 16, message = "新密码长度必须在6-16之间")
private String confirmPassword;
@ApiModelProperty("邮箱")
private String email;
/**
* 分组校验
*/
public interface Save {
}
public interface Update {
}
public interface ResetPassword {
}
;
}
在@Validate注解上指定校验分组
@PutMapping("/resetPassword")
@ApiOperation("重置密码")
public Result resetPassword(@RequestBody @Validated(UserInfoDTO.ResetPassword.class) UserInfoDTO userInfoDTO) {
if (log.isInfoEnabled()) {
log.info("userController.resetPassword.userInfoDTO:{}", JSON.toJSONString(userInfoDTO));
}
return this.userInfoService.resetPassword(userInfoDTO);
}
嵌套校验
DTO类里面的字段都是基本数据类型和String类型。但是实际场景中,有可能某个字段也是一个对象,这种情况先,可以使用嵌套校验。需要注意的是,此时DTO类的对应字段必须标记@Valid注解。
嵌套校验可以结合分组校验一起使用。还有就是嵌套集合校验会对集合里面的每一项都进行校验。
集合校验
如果请求体直接传递了json数组给后台,并希望对数组中的每一项都进行参数校验。此时,如果我们直接使用java.util.Collection下的list或者set来接收数据,参数校验并不会生效!我们可以使用自定义list集合来接收参数:
package com.cqie.datacommon.utils;
import lombok.experimental.Delegate;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.List;
/**
* @Author QingYuQiao
* @date 2024/5/7 16:11
*/
public class ValidationList<E> implements List<E> {
@Delegate
@Valid
public List<E> list = new ArrayList<>();
@Override
public String toString() {
return list.toString();
}
}
比如,我们需要一次性保存多个User对象,Controller层的方法可以这么写:
@PostMapping("/saveList")
public Result saveList(@RequestBody @Validated(UserDTO.Save.class) ValidationList<UserDTO> userList) {
// 校验通过,才会执行业务逻辑处理
return Result.ok();
}
自定义校验
自定义约束注解
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {EncryptIdValidator.class})
public @interface EncryptId {
// 默认错误消息
String message() default "加密id格式错误";
// 分组
Class<?>[] groups() default {};
// 负载
Class<? extends Payload>[] payload() default {};
}
实现ConstraintValidator接口编写约束校验器
public class EncryptIdValidator implements ConstraintValidator<EncryptId, String> {
private static final Pattern PATTERN = Pattern.compile("^[a-f\\d]{32,256}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 不为null才进行校验
if (value != null) {
Matcher matcher = PATTERN.matcher(value);
return matcher.find();
}
return true;
}
}
这样我们就可以使用@EncryptId进行参数校验了!
编程式校验
上面的示例都是基于注解来实现自动校验的,在某些情况下,我们可能希望以编程方式调用验证。这个时候可以注入javax.validation.Validator对象,然后再调用其api。
@Autowired
private javax.validation.Validator globalValidator;
// 编程式校验
@PostMapping("/saveWithCodingValidate")
public Result saveWithCodingValidate(@RequestBody UserDTO userDTO) {
Set<ConstraintViolation<UserDTO>> validate = globalValidator.validate(userDTO, UserDTO.Save.class);
// 如果校验通过,validate为空;否则,validate包含未校验通过项
if (validate.isEmpty()) {
// 校验通过,才会执行业务逻辑处理
} else {
for (ConstraintViolation<UserDTO> userDTOConstraintViolation : validate) {
// 校验失败,做其它逻辑
System.out.println(userDTOConstraintViolation);
}
}
return Result.ok();
}
快速失败
Spring Validation默认会校验完所有字段,然后才抛出异常。可以通过一些简单的配置,开启Fali Fast模式,一旦校验失败就立即返回。
/**
* spring validate 快速失败配置
* @return
*/
@Bean
public Validator validator(){
ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
.configure()
//快速失败
.failFast(true)
.buildValidatorFactory();
return validatorFactory.getValidator();
}