关键词:参数优雅校验
一、背景
- 当我们定义一个rest接口的时候,肯定会想到在接口的内部对输入的参数进行一些合法的校验,提前对一些异常场景进行识别和规避。显然参数的合法校验是rest接口不可缺少的一部分,它能够确保我们的应用程序接收到正确的数据,并且保证了系统的安全性和稳定性。
- 在很多早期的 Java 项目中,我们经常会看到大量的 if-else 语句来对方法的入参进行校验。这种方式虽然可以完成基本的校验功能,但是却存在一些缺点:
- 代码冗余:大量的 if-else 语句使得代码变得冗长,难以阅读和维护。
- 可读性差:过多的条件判断会降低代码的可读性,增加了出错的可能性。
- 重复劳动:相同的参数校验代码可能会在不同的方法中重复出现,增加了代码的重复劳动。
@PostMapping("/loginByVerificationCode/v1")
public ResponseResult loginByVerificationCode1(@RequestBody UserLoginParam userLoginParam){
if(StringUtils.isEmpty(userLoginParam.getAccount())){
return RetResponse.makeErrRsp("账号不能为空");
}
if(StringUtils.isEmpty(userLoginParam.getVerificationCode())){
return RetResponse.makeErrRsp("验证码不能为空");
}
//...其他参数校验,参数越多臃肿
return RetResponse.makeOKRsp();
}
二、优化方案
2.1 优化思路
spring提供了@Validated 和@Valid 两种直接用于参数校验,我们可以尝试使用注解替代传统的if-else进行参数校验。
准备工作:
-
在pomp.xml添加相关依赖即可,在这里我本地搭建的项目springboot版本为2.1.4.RELEASE,spring-boot-starter-web已经自带了依赖。如果springboot版本没有添加依赖可自行在pomp.xml添加依赖
-
<dependency> <groupId>org.hibernate.validator</groupId> <artifactId>hibernate-validator</artifactId> <version>6.2.0.Final</version> </dependency>
-
配置全局异常捕获@Validated 和@Valid参数校验不通过会抛出MethodArgumentNotValidException异常
package com.docker.demo.exception; import com.docker.demo.common.ResponseResult; import com.docker.demo.common.ResultCodeEnum; import com.docker.demo.common.RetResponse; import org.springframework.validation.BindingResult; import org.springframework.validation.ObjectError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import javax.validation.ValidationException; import java.util.List; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseResult handleValidationExceptions(MethodArgumentNotValidException e) { BindingResult result = e.getBindingResult(); String s = "参数验证失败"; if (result.hasErrors()) { List<ObjectError> errors = result.getAllErrors(); //此处只返回提示第一个错误参数,可以在Validator配置类中开启快速校验,开启之后只会返回第一个错误参数 s = errors.get(0).getDefaultMessage(); } return RetResponse.makeErrRsp(ResultCodeEnum.ERROR_OPERATE.getCode(), s); } @ExceptionHandler(ValidationException.class) public ResponseResult handleValidationException(ValidationException e) { return RetResponse.makeErrRsp(ResultCodeEnum.ERROR_OPERATE.getCode(), e.getMessage()); } }
开启快速校验:
package com.docker.demo.config; import org.hibernate.validator.HibernateValidator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.validation.Validation; import javax.validation.Validator; import javax.validation.ValidatorFactory; /** * <p>在上面实体类参数校验的例子中,可以看到会校验所有字段,并将所有不符合校验规则的字段打印出来。这种校验策略是实体类参数校验的普通校验模式(默认的校验模式)。实际上有两种校验模式: * <p>①普通模式(默认是这个模式): 会校验完所有的属性,然后返回所有的验证失败信息 * <p>②快速失败模式: 只要有一个验证失败,则返回。 * * <p> 如果想要配置第二种模式,需要添加如下配置类 */ @Configuration public class ValidateConfig { @Bean public Validator validator() { // 快速失败模式: 只要有一个验证失败,则返回 ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class) .configure() .failFast( true ) .buildValidatorFactory(); Validator validator = validatorFactory.getValidator(); return validator; } }
这里给大家看一下开启快速验证和不配置全局异常捕获的响应结果
-
关闭全局异常捕获
-
响应参数:
{ "timestamp": "2024-07-15T02:35:47.117+0000", "status": 400, "error": "Bad Request", "errors": [ { "codes": [ "NotBlank.userLoginVerificationCodeParam.phone", "NotBlank.phone", "NotBlank.java.lang.String", "NotBlank" ], "arguments": [ { "codes": [ "userLoginVerificationCodeParam.phone", "phone" ], "arguments": null, "defaultMessage": "phone", "code": "phone" } ], "defaultMessage": "手机号不能为空", "objectName": "userLoginVerificationCodeParam", "field": "phone", "rejectedValue": null, "bindingFailure": false, "code": "NotBlank" }, { "codes": [ "NotBlank.userLoginVerificationCodeParam.verificationCode", "NotBlank.verificationCode", "NotBlank.java.lang.String", "NotBlank" ], "arguments": [ { "codes": [ "userLoginVerificationCodeParam.verificationCode", "verificationCode" ], "arguments": null, "defaultMessage": "verificationCode", "code": "verificationCode" } ], "defaultMessage": "验证码不能为空", "objectName": "userLoginVerificationCodeParam", "field": "verificationCode", "rejectedValue": null, "bindingFailure": false, "code": "NotBlank" } ], "message": "Validation failed for object='userLoginVerificationCodeParam'. Error count: 2", "path": "/param/loginByVerificationCode/v2" }
开启快速验证响应结果:
{ "timestamp": "2024-07-15T02:36:39.379+0000", "status": 400, "error": "Bad Request", "errors": [ { "codes": [ "NotBlank.userLoginVerificationCodeParam.verificationCode", "NotBlank.verificationCode", "NotBlank.java.lang.String", "NotBlank" ], "arguments": [ { "codes": [ "userLoginVerificationCodeParam.verificationCode", "verificationCode" ], "arguments": null, "defaultMessage": "verificationCode", "code": "verificationCode" } ], "defaultMessage": "验证码不能为空", "objectName": "userLoginVerificationCodeParam", "field": "verificationCode", "rejectedValue": null, "bindingFailure": false, "code": "NotBlank" } ], "message": "Validation failed for object='userLoginVerificationCodeParam'. Error count: 1", "path": "/param/loginByVerificationCode/v2" }
开启全局异常捕获:
2.2 场景样例
现在模拟几个平时常用的参数传递场景:
-
接口1:@Valid 参数整体校验
在接口入参中使用参数校验的注解对参数的长度,必填等进行校验
@PostMapping("/loginByVerificationCode/v2")
public ResponseResult loginByVerificationCode2(@RequestBody @Valid UserLoginVerificationCodeParam userLoginParam){
//...问题:登录的场景越多,入参的实体类个数越多
return RetResponse.makeOKRsp();
}
@PostMapping("/loginByPassword/v2")
public ResponseResult loginByPassword2(@RequestBody @Valid UserLoginPasswordParam userLoginParam){
//...问题:登录的场景越多,入参的实体类个数越多
return RetResponse.makeOKRsp();
}
实体类:
@Data
public class UserLoginVerificationCodeParam {
@NotBlank(message = "验证码不能为空")
private String verificationCode;
@NotBlank(message = "手机号不能为空")
private String phone;
}
@Data
public class UserLoginPasswordParam {
@NotBlank(message = "账号不能为空")
@Length(max = 50, min = 10, message = "账号长度需要在10和50之间")
private String account;
@NotBlank(message = "密码不能为空")
private String password;
}
响应结果:
可以看到上面的入参是和接口的业务场景强绑定的,随着接口场景的多样化,就需要定义更多的入参实体类与之相对应,为了解决这一问题,spring框架提供了分组的概念
-
接口2:@Validated 对入参进行分组校验
在入参实体类的参数校验注解中指定属于哪个组,在控制层根据业务场景指定组进行参数校验
@PostMapping("/loginByVerificationCode/v3") public ResponseResult loginByVerificationCode3(@RequestBody @Validated(UserLoginGroupParam.VerificationCode.class) UserLoginGroupParam userLoginParam){ //...问题:需要接口和分组绑定 return RetResponse.makeOKRsp(); } @PostMapping("/loginByPassword/v3") public ResponseResult loginByPassword3(@RequestBody @Validated(UserLoginGroupParam.Password.class) UserLoginGroupParam userLoginParam){ //...问题:需要接口和分组绑定 return RetResponse.makeOKRsp(); }
实体类:
@Data public class UserLoginGroupParam { @NotBlank(message = "账号不能为空", groups = {Password.class}) private String account; @NotBlank(message = "密码不能为空", groups = {Password.class}) private String password; @NotBlank(message = "验证码不能为空", groups = {VerificationCode.class}) private String verificationCode; @NotBlank(message = "手机号不能为空", groups = {VerificationCode.class}) private String phone; public interface Password{} public interface VerificationCode{} }
响应结果:
-
可以看到通过在入参配置组,使得不同的接口可以共用一个请求参数,并且校验规则互不干扰
-
接口3:根据入参的某一个参数的值分组校验
实际开发中可能还会遇到,请求参数列表很长,但是参数是否必填,参数的格式等是根据某一个关键参数去校验的,关键参数的值不同,校验参数的范围也会不同。这里模拟一个用户保存的接口,根据用户类型,校验不同的业务参数,注意这里参数校验的组和用户类型的对应关系需要提前在枚举类中进行维护。
@RequestMapping("/save")
public ResponseResult save(@RequestBody UserSaveParam userSaveParam){
//根据入参的userType 分组校验不同的参数
if(StringUtils.isEmpty(userSaveParam.getUserType())){
return RetResponse.makeErrRsp("用户类型不能为空");
}
UserSaveEnum saveEnum = UserSaveEnum.getUserSaveEnumByCode(userSaveParam.getUserType());
if(Objects.isNull(saveEnum)){
return RetResponse.makeErrRsp("用户类型错误");
}
if(Objects.nonNull(saveEnum.getGroups()) && saveEnum.getGroups().length > 0){
ValidatorUtil.validate(userSaveParam, saveEnum.getGroups());
}
return RetResponse.makeOKRsp();
}
实体类:
@Data
public class UserSaveParam {
@NotBlank(message = "用户类型不能为空", groups = {Default.class})
private String userType;
@NotBlank(message = "用户名不能为空", groups = {PhoneUser.class})
private String name;
@NotBlank(message = "用户性别不能为空", groups = {Default.class})
private String sex;
@javax.validation.constraints.Email(message = "邮箱格式错误", groups = {EmailUser.class})
@NotBlank(message = "邮箱不能为空", groups = {EmailUser.class})
private String email;
public interface EmailUser{}
public interface PhoneUser{}
public interface Default{}
}
public enum UserSaveEnum {
EMAIL_USER("0", UserSaveParam.Default.class, UserSaveParam.EmailUser.class),
PHONE_USER("1", UserSaveParam.Default.class, UserSaveParam.PhoneUser.class);
private String code;
private Class[] groups;
UserSaveEnum(String code, Class<?>... groups) {
this.code = code;
this.groups = groups;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public Class[] getGroups() {
return groups;
}
public void setGroups(Class<?>... groups) {
this.groups = groups;
}
public static UserSaveEnum getUserSaveEnumByCode(String code){
for(UserSaveEnum userSaveEnum : UserSaveEnum.values()){
if (userSaveEnum.getCode().equals(code)){
return userSaveEnum;
}
}
return null;
}
}
响应结果:
接口4:集合校验
当请求有一些属性为集合的时候,这时候相对集合的每一个对象都进行参数校验
@PostMapping("/listParam1")
public ResponseResult listParam1(@RequestBody @Valid ListParam listParam) {
//请求参数为集合
return RetResponse.makeOKRsp();
}
实体类:
@Data
public class ListParam {
@Valid
@NotEmpty(message = "列表不能为空")
private List<ImageParam> imageParams;
}
响应结果:
2.3其他校验注解:
- 空和非空检查
@NotBlank :只能用于字符串不为 null ,并且字符串 #trim() 以后 length 要大于 0
@NotEmpty :集合对象的元素不为 0 ,即集合不为空,也可以用于字符串不为 null
@NotNull :不能为 null
@Null :必须为 null
- 数值检查
@DecimalMax(value) :被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value) :被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Digits(integer, fraction) :被注释的元素必须是一个数字,其值必须在可接受的范围内
@Positive :判断正数
@PositiveOrZero :判断正数或 0
@Max(value) :该字段的值只能小于或等于该值
@Min(value) :该字段的值只能大于或等于该值
@Negative :判断负数
@NegativeOrZero :判断负数或 0
- Boolean 值检查
@AssertFalse :被注释的元素必须为 true
@AssertTrue :被注释的元素必须为 false
- 长度检查
@Size(max, min) :检查该字段的 size 是否在 min 和 max 之间,可以是字符串、数组、集合、Map 等
- 日期检查
@Future :被注释的元素必须是一个将来的日期
@FutureOrPresent :判断日期是否是将来或现在日期
@Past :检查该字段的日期是在过去
@PastOrPresent :判断日期是否是过去或现在日期
-
其它检查
@Email :被注释的元素必须是电子邮箱地址
@Pattern(value) :被注释的元素必须符合指定的正则表达式