前言
在日常的项目开发中,为了防止非法参数对业务造成的影响,需要对接口的参数做合法性校验,例如在创建用户时,需要效验用户的账号名称不能输入中文与特殊字符,手机号、邮箱格式是否准确。按照原始的处理逻辑需要对每个接口中的参数进行 if/else 处理,如果这样开发,后期代码难以维护,可读性极差。
为了解决上述问题,validation
框架诞生了,代码量大大减少,参数的效验不再穿插业务逻辑代码中,代码美观又易于维护。
基本概念
@Valid
是 JSR303 声明的,JSR是Java Specification Requests
的缩写,其中 JSR303 是JAVA EE 6 中的一项子规范,叫做 Bean Validation,为 JavaBean 验证定义了相应的元数据模型和 API,需要注意的是,JSR 只是一项标准,它规定了一些校验注解的规范,但没有实现,而 Hibernate validation
对其进行实现。
Spring Validation
验证框架对参数的验证机制提供了@Validated
(Spring JSR-303规范,是标准JSR-303的一个变种)。
@Valid
和@Validated
区别
区别 | @Valid | @Validated |
来源 | JSR-303规范 | Spring |
是否支持分组 | 不支持 | 支持 |
标注位置 | METHOD, FIELD, CONSTRUCTOR, PARAMETER, TYPE_USE | TYPE,METHOD,PARAMETER |
嵌套校验 | 支持 | 不支持 |
基本使用
加入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.3.12.RELEASE</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
</dependency>
复制代码
注:从 boot-2.3.x
开始,spring-boot-starter-web
不再引入 spring-boot-starter-validation
,所以需要额外手动引入validation
依赖,而 2.3
之前的版本只需要引入 web 依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>2.3.12.RELEASE</version>
</dependency>
<!-- <dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.0.18.Final</version>
<scope>compile</scope>
</dependency>-->
复制代码
以上两个依赖都是可以实现功能的。hibernate-validator
、spring-boot-starter-validation
底层都引入了 jakarta.validation-api
依赖。
在实际开发的过程中,请求参数的格式一般有如下几种情况:
对象参数使用
使用对象参数接收分为两种,一种是使用 @RequestBody
注解的application/json
提交,还有一种不使用 @RequestBody
注解的 form-data
提交。
- 使用对象接收参数,在需要校验对象的参数加上
@NotBlank
注解,message
是校验不通过的提示信息。
@Data
public class UserReq {
@NotBlank(message = "name为必传参数")
private String name;
@NotBlank(message = "email为必传参数")
private String email;
}
复制代码
使用 @RequestBody
- Api,在需要校验的对象前面加
@RequestBody
注解以及@Validated
或者@Valid
注解,如果校验失败,会抛出MethodArgumentNotValidException
异常。
@RestController
public class GetHeaderController {
@PostMapping("save")
public void save(@RequestBody @Validated UserReq req){}
}
复制代码
不使用 @RequestBody
只需要校验的对象前面加@Validated
注解或者@Valid
注解,如果校验失败,会抛出BindException
异常。
@PostMapping("save2")
public void save2(@Validated UserReq req){
}
复制代码
基本类型使用
- 其实也就是路径传参,在参数前面加上相对应的校验注解,还必须在
Controller
类上加@Validated
注解。如果校验失败,会抛出ConstraintViolationException
异常。
@RestController
@Validated
public class GetHeaderController {
@PostMapping("get")
public void get(@NotBlank(message = "名称 is required") String name,@NotBlank(message = "邮箱 is required") String email) throws JsonProcessingException {
}
}
复制代码
测试
save方法测试
save2
get方法测试
全局异常处理
通过前面的测试,我们知道如果参数校验失败,三种使用场景会抛出三种异常或者警告,分别是MethodArgumentNotValidException
、ConstraintViolationException
、BindException
异常,每种异常的响应格式又不一致。所以在项目开发中,通常会使用统一异常处理来返回一个统一格式并友好的提示。
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* @RequestBody 上校验失败后抛出的异常是 MethodArgumentNotValidException 异常。
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public String handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
BindingResult bindingResult = e.getBindingResult();
String messages = bindingResult.getAllErrors()
.stream()
.map(ObjectError::getDefaultMessage)
.collect(Collectors.joining(";"));
return messages;
}
/**
* 不加 @RequestBody注解,校验失败抛出的则是 BindException
*/
@ExceptionHandler(value = BindException.class)
public String exceptionHandler(BindException e){
String messages = e.getBindingResult().getAllErrors()
.stream()
.map(ObjectError::getDefaultMessage)
.collect(Collectors.joining(";"));
return messages;
}
/**
* @RequestParam 上校验失败后抛出的异常是 ConstraintViolationException
*/
@ExceptionHandler({ConstraintViolationException.class})
public String methodArgumentNotValid(ConstraintViolationException exception) {
String message = exception.getConstraintViolations().stream().map(ConstraintViolation::getMessage).collect(Collectors.joining(";"));
return message;
}
}
复制代码
save方法测试
save2方法测试
get方法测试
可以发现它是将类中所有的属性进行效验完成之后,才抛出异常的,但其实这有点消耗性能,那能不能只要检测到一个效验不通过的,就抛出异常呢?只需要在容器提供如下代码:
@Configuration
public class ParamValidatorConfig {
@Bean
public Validator validator() {
ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
.configure()
//failFast:只要出现校验失败的情况,就立即结束校验,不再进行后续的校验。
.failFast(true)
.buildValidatorFactory();
return validatorFactory.getValidator();
}
@Bean
public MethodValidationPostProcessor methodValidationPostProcessor() {
MethodValidationPostProcessor methodValidationPostProcessor = new MethodValidationPostProcessor();
methodValidationPostProcessor.setValidator(validator());
return methodValidationPostProcessor;
}
}
复制代码
MethodValidationPostProcessor
是Spring
提供的来实现基于方法Method
的JSR
校验的核心处理器,最终会由 MethodValidationInterceptor
进行校验拦截。
- 测试如下:
其余类型
上面举例使用了NotBlank
注解,但肯定不只一个!我们进入到 @NotBlank
注解所在的包路径。
哦豁,这么多呀!小杰一个一个来介绍一下作用。
注解 | 备注 | 适用类型 | 示例 |
@AssertFalse | 被注释的元素必须为 | boolean 和 Boolean | @AssertFalse(message = "该参数必须为 false") |
@AssertTrue | 被注释的元素必须为 | boolean 和 Boolean | @AssertTrue(message = "该参数必须为 true") |
@DecimalMax | 被注释的元素必须是一个数字,其值必须小于或等于指定的最大值,null 值是有效的。 | BigDecimal、BigInteger、CharSequence、byte、short、int、long以及包装类型 | @DecimalMax(value = "100",message = "该参数不能大于 100") |
@DecimalMin | 被注释的元素必须是一个数字,其值必须大于或等于指定的最小值,null 值是有效的。 | BigDecimal、BigInteger、CharSequence、byte、short、int、long以及包装类型 | @DecimalMax(value = "0",message = "该参数不能小于 0") |
@Digits | 被注释的元素必须是可接受范围内的数字,null 值是有效的。 | BigDecimal、BigInteger、CharSequence、byte、short、int、long以及包装类型 | @Digits(integer = 3,fraction = 2,message = "该参数整数位数不能超出3位,小数位数不能超过2位") |
@Max | 被注释的元素必须是一个数字,其值必须小于或等于指定的最大值,null 值是有效 | BigDecimal、BigInteger、byte、short、int、long以及包装类型 | @Max(value = 200,message = "最大金额不能超过 200") |
@Min | 被注释的元素必须是一个数字,其值必须大于或等于指定的最小值,null 值是有效的。 | BigDecimal、BigInteger、byte、short、int、long以及包装类型 | @Min(value = 0,message = "最小金额不能小于 0") |
@Negative | 被注释的元素必须是负数,null 值是有效 | BigDecimal、BigInteger、byte、short、int、long、float、double 以及包装类型 | @Negative(message = "必须是负数") |
@NegativeOrZero | 被注释的元素必须是负数或 0,null 值是有效的。 | BigDecimal、BigInteger、byte、short、int、long、float、double 以及包装类型 | @NegativeOrZero(message = "必须是负数或者为0") |
@Positive | 被注释的元素必须是正数,null 值是有效的。 | BigDecimal、BigInteger、byte、short、int、long、float、double 以及包装类型 | @Positive(message = "必须是正数") |
@PositiveOrZero | 被注释的元素必须是正数或0,null 值是有效的。 | BigDecimal、BigInteger、byte、short、int、long、float、double 以及包装类型 | @PositiveOrZero(message = "必须是正数或者为0") |
@Future | 被注释的元素必须是未来的日期(年月日),null 值是有效的。 | 基本所有的时间类型都支持。常用的:Date、LocalDate、LocalDateTime、LocalTime、Instant | @Future(message = "预约日期要大于当前日期") |
@FutureOrPresent | 被注释的元素必须是现在或者未来的日期(年月日),null 值是有效的。 | 基本所有的时间类型都支持。常用的:Date、LocalDate、LocalDateTime、LocalTime、Instant | @FutureOrPresent(message = "预约日要大于当前日期") |
@Past | 被注释的元素必须是过去的日期,null 值是有效的。 | 基本所有的时间类型都支持。常用的:Date、LocalDate、LocalDateTime、LocalTime、Instant | @Past(message = "出生日期要小于当前日期") |
@PastOrPresent | 被注释的元素必须是过去或者现在的日期,null 值是有效的。 | 基本所有的时间类型都支持。常用的:Date、LocalDate、LocalDateTime、LocalTime、Instant | @PastOrPresent(message = "出生时间要小于当前时间") |
@NotBlank | 被注释的元素不能为空,并且必须至少包含一个非空白字符 | CharSequence | @NotBlank(message = "name为必传参数") |
@NotEmpty | 被注释的元素不能为 null 也不能为空 | CharSequence、Collection、Map、Array | @NotEmpty(message = "不能为null或者为空") |
@NotNull | 被注释的元素不能为null | 任意类型 | @NotNull(message = "不能为null") |
@Null | 被注释的元素必须为null | 任意类型 | @Null(message = "必须为null") |
@Email | 被注释的元素必须是格式正确的电子邮件地址,null 值是有效的。 | CharSequence | @Email(message = "email格式错误,请重新填写") |
@Pattern | 被注释的元素必须匹配指定的正则表达式,null 值是有效的。 | CharSequence | @Pattern(regexp = "^1[3456789]\d{9}$",message = "手机号格式不正确") |
@Size | 被注释的元素大小必须在指定范围内,null 值是有效的。 | CharSequence、Collection、Map、Array | @Size(min = 5,max = 20,message = "字符长度在 5 -20 之间") |
以上注解有几个需要注意一下,因为经常用到,也经常使用错误
- @NotNull:适用于任何类型,不能为null,但可以是 (""," ")
- @NotBlank:只能用于 String,不能为null,而且调用 trim() 后,长度必须大于0,必须要有实际字符。
- @NotEmpty:用于 String、Collection、Map、Array,不能为null,长度必须大于0。
使用分组效验
有些小伙伴说使用 @Validated
校验的对象不能复用,这我只能说学的还不够深入。
小杰使用 PayReq
来举例,该对象是一个公用的请求体,对接了微信、支付宝两个渠道方,对接微信 payName
参数是非必传的,对接支付宝是必传参数。payAmount
是两个渠道必传参数。其实就跟平常写新增方法、修改方法一样的,用的是同一个 ReqDTO,但是其中 id 字段新增是不用传递的,而修改时是必传的。
定义 ZfbPayGroup
定义ZfbPayGroup
的分组接口,继承 Default
接口。
public interface ZfbPayGroup extends Default {
}
复制代码
添加group
在需要区分组的字段上加 groups 参数。在本例中在 payName
加了groups
参数,值为 ZfbPayGroup.class
,代表对组为 ZfbPayGroup
的进行payName
参数校验。
@Data
public class PayReq {
@NotBlank(message = "支付名称不能为空",groups = {ZfbPayGroup.class})
private String payName;
@NotNull(message = "支付金额不能为空")
private BigDecimal payAmount;
}
复制代码
注意:ZfbPayGroup 要继承 Default
接口,不然 payAmount
字段的效验会对ZfbPayGroup
这个组失效,payAmount
默认的组为 Default
。
使用 group
创建两个接口,在 zfbPaySave
接口中声明@Validated
校验组,wxbPaySave
接口正常编写。
@PostMapping("zfbPaySave")
public void zfbPaySave(@RequestBody @Validated(value = {ZfbPayGroup.class}) PayReq req) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
System.out.println( mapper.writeValueAsString(req));
}
@PostMapping("wxbPaySave")
public void wxbPaySave(@RequestBody @Validated PayReq req) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
System.out.println( mapper.writeValueAsString(req));
}
复制代码
测试
- zfbPaySave:提示支付名称不能为空
- wxbPaySave:没被校验拦截。
嵌套校验
什么是嵌套使用呢?就是一个对象中包含另外一个对象,另外一个对象的字段也是需要进行校验。示例如下:
- UserReq
@Data
public class UserReq {
@NotBlank(message = "name为必传参数")
private String name;
private String email;
@NotNull(message = "proReq对象不能为空")
@Valid
private ProReq proReq;
}
复制代码
嵌套校验需要在效验的对象加上 @Valid
注解。
- ProReq
@Data
public class ProReq {
@NotBlank(message = "proName为必传参数")
private String proName;
}
复制代码
- 测试
集合校验
在某些场景下,我们需要使用集合接收前端传递的参数,并对集合中的每个对象都进行参数校验。但是这时我们的参数校验并不会生效!如下写法:
@PostMapping("save3")
public String save3(@RequestBody @Validated List<UserReq> req){
return "成功";
}
复制代码
下面介绍两种方式对集合进行效验!
方式一
@Validated
+ @Valid
两个注解同时使用!缺点:不能使用分组效验!如果该实体不需要用到分组功能,可以使用该方式!
@RestController
@Validated
public class GetHeaderController {
@PostMapping("save3")
public String save3(@RequestBody @Valid @NotEmpty(message = "该集合不能为空") List<UserReq> req){
return "成功";
}
}
复制代码
测试
方式二
- 自定义一个List
@Data
public class ValidList<E> implements List<E> {
// 使用该注解就不需要手动重新 List 中的方法了
@Delegate
@Valid
public List<E> list = new ArrayList<>();
}
复制代码
- @Delegate,为 lombok 的注解,表示该属性的所有对象的实例方法都将被该类代理。
- 编码如下:
@PostMapping("save4")
public String save4(@RequestBody @Validated @NotEmpty(message = "该集合不能为空") ValidList<UserReq> req){
return "成功";
}
复制代码
测试
自定义校验规则
自定义校验规则小杰在工作当中用的比较少。大部分业务需求使用自带的注解已经够平常开发了。当然自定义validation
规则也非常简单。这里使用校验电话号码是否合法来举例!别抬杠,说我为什么不用 @Pattern(regexp = "^1[3456789]\\d{9}$",message = "手机号格式不正确")
直接实现。对不起,我不想每次写正则。
- 自定义注解
Phone
,跟着内置的注解照葫芦画瓢。不过validatedBy
的值要指定我们自定义的约束验证器!
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = { PhoneValidator.class })
public @interface Phone {
String message() default "手机号码格式异常";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
复制代码
- 实现
ConstraintValidator
约束验证器接口
public class PhoneValidator implements ConstraintValidator<Phone,String> {
private static final String REGEX = "^1[3456789]\\\\d{9}$";
/**
*
* @param value
* @param context
* @return:返回 true 表示效验通过
*/
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 不为null才进行校验
if (value != null) {
return value.matches(REGEX);
}
return true;
}
}
复制代码
- 接下来就可以使用
@Phone
注解了。
@Data
public class UserReq {
@NotBlank(message = "name为必传参数")
private String name;
@Email(message = "email格式错误,请重新填写")
@NotBlank(message = "email为必传参数")
private String email;
@NotNull(message = "proReq对象不能为空")
@Valid
private ProReq proReq;
@Phone
@NotBlank(message = "手机号码为必传参数")
private String tel;
}
复制代码
- 测试