引言
日常开发过程中,代码各处充满着“陷阱”,稍不留神,就容易出现各类稀奇古怪的Bug
,这回咱们来说开发中经常处理的情况,即:参数校验!大多数需要校验参数的情况,都出现在”数据交互“这个场景下,比如前端向后端提交表单数据、后端调用兄弟部门的RPC
接口等。
通常而言,参数校验的工作只需在一端完成即可,比如前端已经校验了,后端就无需校验,反之同理。可每个人的天性都是懒惰,后端下意识的会拿着参数直接用,前端默认把用户提交的参数无脑往接口传递……
就这样,直到有一天线上突然出现了Bug
,一经排查发现是由于某个参数为空,或者参数不合法造成的,前端与后端面面相觑,接着异口同声的道:“这不能怪我啊!我以为前端(后端)会做呢”!从此刻起,双方之间的信任就此崩塌……
当然,参数到底该由调用方校验,还是由被调用方校验,这点我们不做过多探讨,今天我们重点站在后端的角度,聊聊不同场景下的多种参数校验手段。
一、传统的参数校验模式
先来看一段大家再熟悉不过的代码,如下:
/**
* 熊猫实体类
*/
@Data
public class Pandas implements Serializable {
private static final long serialVersionUID = 1L;
private Integer id;
private String name;
private Integer age;
private String color;
}
/*=======================================*/
/**
* 熊猫接口类
*/
@RestController
@RequestMapping("/pandas")
public class PandaController {
@PostMapping("/info/edit")
public Result<Void> edit(@RequestBody Pandas pandas){
pandasService.updateById(pandas);
return Result.success("熊猫信息编辑成功!");
}
}
/*=======================================*/
/**
* 统一的响应结果集对象
*/
@Data
public class Result<T> implements Serializable {
private static final long serialVersionUID = 1L;
private static final ResponseCode SUCCESS = ResponseCode.SUCCESS;
// 编码:0表示成功,其他值表示失败
private int code = SUCCESS.getCode();
// 消息内容
private String msg = SUCCESS.getMessage();
// 响应数据
private T data;
/**
* 返回错误的结果集
*/
public static <T> Result<T> error(Exception e) {
return error(ResponseCode.ERROR.getCode(), e.getMessage());
}
/**
* 返回成功的结果集
*/
public static Result<Void> success(String msg) {
Result<Void> result = new Result<>(SUCCESS);
result.setMsg(msg);
return result;
}
public Result<T> ok(T data) {
this.setData(data);
return this;
}
}
上述提供了一个“编辑熊猫信息”的接口,理想情况下,百分百信任前端传递的参数时,按上述代码执行不会有任何问题。但假设前端未对参数进行校验,用户在界面上填写了这样的参数:
{
"name": "竹竹",
"age": -100,
"color": "黑白色"
}
上面这个数据,明显能看出年龄字段的值并不合理。
同时,就算前端对数据做了校验,万一有人绕过页面抓包,调接口传递了不合理的数据咋办?为此,通常后端会在Contrller
方法中,加上这样的校验代码:
public Result<Void> edit(@RequestBody Pandas pandas){
if (Objects.isNull(pandas)) {
throw new RuntimeException("参数不能为空!");
}
if (pandas.getId() <= 0) {
throw new RuntimeException("参数校验不通过,ID必须大于0!");
}
if (pandas.getAge() < 0 || pandas.getAge() > 150) {
throw new RuntimeException("参数校验不通过,年龄超出正常范围!");
}
pandasService.updateById(pandas);
return Result.success("熊猫信息编辑成功!");
}
为了避免空指针异常,以及确保数据合理性,Contrller
方法里加了一堆判断。而案例中的实体类,其属性并不算多,假设一个类有20
个参数怎么办?如果挨个去写,假设Contrller
里有10
个接口,目前还有十个类似的Contrller
类咋办?有些小伙伴或许会单独抽出公用的verify()
方法,可是编写大量无营养的参数校验代码,这个过程无疑十分痛苦。
而参数校验,除开保证了数据合理性的原因之外,参数校验带来的好处还有:
- 数据有效性:经过校验的请求参数,可以确保下层方法拿到的数据内容、格式、类型完全符合预期;
- 短路阻断执行:可以在早期阶段检测出错误参数,不需要等到使用(执行)时才发现并抛出异常;
- 系统安全性:直接使用未经校验的参数,可能会由于
SQL
注入、XSS
攻击等因素出现安全隐患; - 结果准确性:使用校验后的参数,能保证执行结果
100%
正确,未经校验的参数有可能出现预期外的结果; - ……
综上所述,校验参数很有必要,可是写一堆if/else
校验代码太累,同时即不美观也不优雅,有没有两全其美的办法呢?答案当然是有的,即使用validator
包来完成参数校验,下面展开聊聊这种常用的参数校验方式。
二、常用的参数校验类库
讲到参数校验框架,常用的有三种,各自的依赖如下:
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.2.0.Final</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
这三者都属于参数校验的类库,它们之间有没有关联呢?答案是有的,在正式讲述之前,先来捋捋这几个的关系。
首先,参数校验几乎是每个Java
方法需要做的事情,经常阅读源码的小伙伴肯定有经验,无论是阅读JDK
源码,还是开源框架的源码,大多数方法点进去后,总能在方法的前面,看到几行参数校验的代码。同样,我们编写的业务代码亦是如此,一个方法在使用外部传入的参数,都应该先对参数内容进行校验,除非你能确保参数绝对合法、正确(比如经过controller
层校验后,传递给service
层的参数),这时才无需重复编写校验逻辑。
校验参数这个“动作”在代码中随处可见,按理来说,这种通用性强的基础动作,应该由官方来集成到JDK
里,从而给业界提供统一的标准、规范,可JDK
中并没有提供。直到后来JCP
组织(Java
标准制定组织)站出来了,制定了规范“校验操作”的标准:Java Validation API
,也叫Bean Validation
规范,先后经历了JSR303、JSR349、JSR380
三个版本。
2.1、三个类库之间的关系
而前面给出的第一个GAV
坐标,则是与Java Validation API
规范对应的实现,所以其groupId
以javax
开头,那后面两个又是咋回事呢?hibernate-validator
也是Java Validation API
规范的落地者,除开实现了规范中的constraint
(约束)外,还额外拓展了许多新的constraint
:
可以很明显看到,hibernate-validator
涵盖了validation-api
依赖,并且额外做了些许拓展。
下面再把目光放到最后一个GAV
上:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
从名字就能看出来,这是SpringBoot
的一个starter
依赖,这是Spring
组织的相应实现吗?不是,Spring
不是Java Validation API
规范的落地者,而是hibernate-validator
的封装者,spring-boot-starter-validation
最终依赖于hibernate-validator
,来看看依赖关系图:
2.2、特殊的引入方式
不知大家是否记得之前文章提过的《Maven依赖传递性》,所谓依赖传递性,即:
当引入的一个包,如果依赖于其他包(类库),当前的工程就必须再把其他包引入进来。
而参数校验是Web
开发中常用的功能,为此,当你需要使用前面提到的几种校验库时,无需手动通过GAV
坐标引入,只要你的项目中,引入spring-boot-starter-web
这个依赖,根据依赖传递原则,你的工程会自动引入前面提到的校验类库,只不过在不同版本中有所区别,如下:
从上图可观测出,根据项目中spring-boot-starter-web
依赖版本的不同,web
依赖默认使用的校验库也不同:
2.2
之前,默认会引入hibernate-validator
作为校验库;2.2
之后,默认会引入spring-boot-starter-validation
作为校验库;2.3
之后,spring-boot-starter-web
依赖不再默认引入校验库,需要单独手动引入。
综上所述,如果你项目里使用的SpringBoot
版本在2.3.x
及以上,想要使用校验库提供的功能,必须手动引入相关GAV
坐标,通常引入spring-boot-starter-validation
依赖即可。
三、参数校验实践
前面说了一大堆理论,接下来就让咱们一起实操,不过在此之前,还需要带大家认识下校验库提供的注解,以及不同注解的具体作用、适用范围(类型)。
3.1、请求参数校验示例
为了帮未使用过这些校验库的小伙伴快速建立出认知,这里以最常见的Controller
参数校验场景,举个简单的例子,首先需搭建一个简单的SpringBoot-Web
工程,我使用的版本为2.4.2
,接着引入下述依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
</dependency>
顺手写一个简单的application.yml
配置文件:
server:
port: 80
servlet:
context-path: /
接着来创建一个UserDTO
参数类:
@Data
public class UserDTO {
@Min(value = 0, message = "ID不能小于0")
private Long userId;
@NotNull(message = "用户名不能为空")
private String userName;
}
这个参数类只有两个字段,接着创建一个Controller
类,并对外提供一个“编辑用户信息”的接口:
@RestController
@RequestMapping("/user")
public class UserController {
@PutMapping
public Result<Void> edit(@Valid @RequestBody UserDTO userDTO) {
System.out.println("模拟修改操作:" + userDTO);
return Result.success("用户信息编辑成功!");
}
}
这里封装了一个Result
结果集对象,相信大家对此并不陌生,因此就不贴出来了,重点来看看上述代码中的不同:
- ①在
UserDTO
类的属性上,多了@Min、@NotNull
两个注解; - ②在
UserController#edit()
方法的参数前,多了一个@Valid
注解。
下面启动项目,接着通过PostMan
工具调用一下该接口,一起看看结果:
大家可以发现,由于咱们通过@Min
注解配置了“ID
必须大于0
”的校验规则,当传递一个为负数的ID
时,就会由于校验不通过而抛出MethodArgumentNotValidException
异常,并且在异常信息中,给出了配置的message
信息。
3.2、Bean-Validation校验注解
好了,经过前面的小案例后,相信以前就算未曾接触过参数校验库的小伙伴,也对这方面有了一定认知,接着来看看校验库提供的注解,这里先说Bean-Validation
库提供的注解,主要位于javax.validation.constraints
包下,共计定义了22
个校验注解,可被归纳为六大类。
3.2.1、空值校验注解
顾名思义,空值校验则是用于判断某个参数是否为空值、null
值的注解,下面挨个看看。
@Null
- 作用:被该注解修饰的字段必须,值为
null
值; - 适用类型:
Object
的所有子类,即所有引用类型;
使用示例如下:
@Null(message = "该字段必须为Null")
private ZhuZi zhuZi;
说实话,这个注解比较鸡肋,大多数场景中并未用武之地,毕竟提供出来的字段肯定希望对端传值,必须为Null
的字段不对外暴露即可,像这种即提供字段、又不让别人传值的情况,完全属于多此一举。
@NotNull
- 作用:被该注解修饰的字段,值不能为空;
- 适用类型:所有引用类型;
使用示例:
@NotNull(message = "ID不能为空")
private Long id;
与上个注解的作用完全相反,这个注解也是最常用的,如果一个对象为null
,直接调用其get/set()
方法会报NPE
,为了避免NEP
打断程序执行,通常都会使用if
进行判断,而@NotNull
注解可以代替if
判断。
PS:
@NotNull
无法检测一个长度为零的空字符串,即:""
!
@NotEmpty
- 作用:被该注解修饰的字段,值不能为
Null
,以及长度不能为0
; - 适用类型:
String
使用示例:
@NotEmpty(message = "姓名不能为空")
private String name;
该注解可以理解成对@NotNull
的补充,@NotNull
无法校验字符串为""
的情况,而@NotEmpty
注解可以。
@NotBlank
- 作用:被该注解修饰的字段,值不能为
Null
、空格,以及长度不能为0
; - 适用类型:
String
使用示例:
@NotBlank(message = "手机号码不能为空")
private String phoneNumber;
乍一看,这个注解和上个注解没有区别,但其实有,该注解是对@NotEmpty
进一步补充,@NotEmpty
注解无法校验空格字符串(" "
)的场景,而@NotBlank
会先对值进行Trim
,去掉前后的空格,而后再进行校验,为此,当尝试给@NotBlank
注解修饰的字段,传递一个空格字符串值时,会抛出异常。
3.2.2、布尔值校验注解
@AssertTrue
- 作用:被该注解修饰的字段,其值必须为
true
; - 适用类型:
boolean
及其包装类型;
使用示例:
@AssertTrue(message = "订单支付状态必须为True")
private Boolean isPay;
这个注解用于校验必须为true
的字段,很简单不做过多解释。
@AssertFalse
- 作用:被该注解修饰的字段,其值必须为
false
; - 适用类型:
boolean
及其包装类型;
使用示例:
@AssertFalse(message = "核销状态必须为Flase")
private boolean isWriteOff;
和上个注解的含义相反,同样不做过多解释。
3.2.3、长度校验注解
@Size
- 作用:被该注解修饰的字段,字段的长度必须要在给定的范围内;
- 适用类型:
String、Array、Collection、Map
;
使用示例:
@Size(min = 1, max = 8, message = "昵称长度必须在1~8个字符")
private String nickName;
@Size(min = 1, max = 5, message = "请至少添加一位联系人,最多不能超过五位")
private List<Contacts> contacts;
如果将该注解修饰在String
类型的字段上,校验的则是字符串长度,而如果修饰在数组、集合等类型上,则校验的是元素的个数。但要注意,该注解无法校验值为Null
的场景,需要配合@NotNull
注解共同使用。
3.2.4、时间校验注解
@Past
- 作用:被该注解修饰的时间字段,其值必须在当前时间之前;
- 适用类型:
Date、Calendar、Instant、LocalDateTime、LocalDate、LocalTime
等时间类型;
使用示例:
@Past(message = "生日必须小于当前时间")
private LocalDate birthday;
private List<@Past(message = "时间组必须小于当前时间") Date> dates;
这个注解没啥好说的,主要看看第二种用法,校验注解是可以加在泛型之前的,如果加在一个List
的泛型之前,意味着这个集合中的每个元素,都需要被该注解校验,任意元素未通过校验都会报错。
@Future
- 作用:被该注解修饰的时间字段,其值必须在当前时间之后;
- 适用类型:
Date、Calendar、Instant、LocalDateTime、LocalDate、LocalTime
等时间类型;
使用示例:
@Future(message = "活动结束时间必须大于当前时间")
private Date activityEndTime;
没啥好说的,和上个注解的作用相反。
@PastOrPresent
- 作用:被该注解修饰的时间字段,其值必须为当前时间,或之前;
- 适用类型:
Date、Calendar、Instant、LocalDateTime、LocalDate、LocalTime
等时间类型;
使用示例:
@PastOrPresent(message = "签名时间必须小于或等于当前时间")
private Date signTime;
该注解是@Past
注解的补充版,之前是<
关系,现在是<=
关系。
@FutureOrPresent
- 作用:被该注解修饰的时间字段,其值必须为当前时间,或之后;
- 适用类型:
Date、Calendar、Instant、LocalDateTime、LocalDate、LocalTime
等时间类型;
使用示例:
@FutureOrPresent(message = "活动开始时间必须大于或等于当前时间")
private Date activityStartTime;
该注解是@Future
注解的补充版,之前是>
关系,现在是>=
关系。
3.2.5、数值校验注解
数值校验的含义很简单,即是校验某个数值字段的值是否满足约束,但这类注解也可以对String
类型生效,即可以校验3
,也可以校验"3"
,下面挨个看看。
PS:下述所有数值校验注解,都无法校验
null
值情况,想要避免NPE
,需要配合@NotNull
注解使用。
@Min
- 作用:被该注解修饰的字段,其值不能小于给定值(必须大于或等于);
- 适用类型:
BigDecimal、BigInteger、String
,以及基本数据类型中的数值类型与包装类;
使用示例:
@Min(value = 0, message = "年龄不能小于0岁")
private Integer age;
根据前面说的内容,就算把age
字段的类型改为String
,这时@Min
注解的约束依旧有效。
@Max
- 作用:被该注解修饰的字段,其值不能大于给定值(必须小于或等于);
- 适用类型:
BigDecimal、BigInteger、String
,以及基本数据类型中的数值类型与包装类;
使用示例:
@Max(value = 200, message = "年龄不能大于200岁")
private Integer age;
@DecimalMin
- 作用:被该注解修饰的字段,其值不能大于给定值(小于等于);
- 适用类型:
BigDecimal、BigInteger、String
,以及基本数据类型中的数值类型与包装类;
使用示例:
@(value = "0.00", message = "产品费用不能小于0.00元")
private BigDecimal productFee;
@Min
注解的增强版,其约束值通过String
字符串指定,可以精准小数位,常用于BigDecimal
类型字段的校验。
@DecimalMax
- 作用:被该注解修饰的字段,其值不能小于给定值(大于等于);
- 适用类型:
BigDecimal、BigInteger、String
,以及基本数据类型中的数值类型与包装类;
使用示例:
@DecimalMax(value = "9999999.99", message = "产品单价不能大于9999999.99元")
private BigDecimal unitPrice;
@Max
注解的增强版,与@DecimalMin
注解的作用相反。
@Digits
- 作用:被该注解修饰的字段,其值的整数位、小数位,必须小于或等于给定长度;
- 适用类型:
BigDecimal、BigInteger、String
,以及基本数据类型中的数值类型与包装类;
使用示例:
@Digits(integer = 5, fraction = 2, message = "单次充值金额只允许5位整数、2位小数")
private BigDecimal rechargeAmount;
这个注解可以用于校验小数的精度,比如你期望接收一个“保留小数点后三位”的值,就可以用这个注解进行约束。同时注意,如果一个字段上,只用@Digits
注解修饰,不指定整数位、小数位长度,默认只会校验收到的数值是否合法,好比外部传入一个"66x"
,此时会违反约束而报错。
@Negative
- 作用:被该注解修饰的字段,其值必须为负数;
- 适用类型:
BigDecimal、BigInteger、String
,以及基本数据类型中的数值类型与包装类;
使用示例:
@Negative(message = "优惠金额必须为负数")
private BigDecimal couponFee;
@NegativeOrZero
- 作用:被该注解修饰的字段,其值必须为负数或零;
- 适用类型:
BigDecimal、BigInteger、String
,以及基本数据类型中的数值类型与包装类;
使用示例:
@Negative(message = "优惠金额必须为负数或零")
private BigDecimal couponFee;
上个注解的增强版,值为0
的情况,无法通过@Negative
注解的校验,而@NegativeOrZero
允许零值存在。
@Positive
- 作用:被该注解修饰的字段,其值必须为正数;
- 适用类型:
BigDecimal、BigInteger、String
,以及基本数据类型中的数值类型与包装类;
使用示例:
@Positive(message = "价格必须为正数")
private BigDecimal price;
与前面的@Negative
注解作用相反。
@PositiveOrZero
- 作用:被该注解修饰的字段,其值必须为正数;
- 适用类型:
BigDecimal、BigInteger、String
,以及基本数据类型中的数值类型与包装类;
使用示例:
@PositiveOrZero(message = "价格必须为正数或零")
private BigDecimal price;
与前面的@NegativeOrZero
注解作用相反。
3.2.6、正则校验注解
前面列出了许多Bean-Validation
定义的校验注解,但JCP
组织不可能考虑到所有的校验场景,为此,官方定义了一个万能的约束注解,即正则校验注解!
@Pattern
- 作用:被该注解修饰的字段,其值必须满足给定的正则表达式;
- 适用类型:
String
;
使用示例:
public static final String MOBILE_REGEXP =
"^(((13[0-9])|(14[579])|(15([0-3]|[5-9]))|(16[6])" +
"|(17[0135678])|(18[0-9])|(19[589]))\\d{8})$";
@Pattern(regexp = MOBILE_REGEXP, message = "手机号格式错误")
private Integer phoneNumber;
正则表达式,可以定义出各自校验规则,而@Pattern
注解可以搭配任意正则表达式使用,所以面对特殊的校验场景时,大家可以基于该注解进行处理,比如手机号、身份证、邮箱、网址校验等等。
- 作用:被该注解修饰的字段,其值必须为邮箱地址;
- 适用类型:
String
;
使用示例:
@Email(message = "邮箱地址格式有误")
private String email;
这个注解是官方对@Pattern
的封装,内部基于正则表达式进行了邮箱格式校验。
3.3、Hibernate-Validator校验注解与SpringBoot封装
上阶段详细介绍了Bean-Validation
定义的六大类、22
个细分注解,而这些在Hibernate-Validator
库中都支持,并且Hibernate
还对其进行了拓展,新增了几个常用的校验注解,如下:
左图为Bean-Validation
定义的注解,右图则为Hibernate-Validator
拓展的注解,细数下来,共计拓展了21
个新注解。当然,最初的Bean-Validation
并没有@NotBlank、@NotEmpty、@Email
等注解,而后面借鉴Hibernate-Validator
库新引入的,为此,Hibernate-Validator
新版本的类库中,已经将作用相同的注解废弃(右图中划横线的注解)。
3.3.1、Hibernate-Validator常用注解
由于Hibernate-Validator
提供的注解较多,这里不挨个进行讲解,毕竟许多注解并不实用,下面挑几个重要的注解进行概述。
@Length
- 作用:被该注解修饰的字段,其值的长度,必须在给定的范围之间;
- 适用类型:
String、Array
;
使用示例:
@Length(min = 2, max = 8, message = "昵称长度必须在2~8字符")
private String nickName;
注意,@Length
修饰在String
字段时,校验的是字符长度,而并不是字节大小。
@Range
- 作用:被该注解修饰的字段,其值必须在给定的范围之间;
- 适用类型:
BigDecimal、BigInteger、String
,以及基本数据类型中的数值类型与包装类;
使用示例:
@Range(min = 0L, max = 200L, message = "年龄必须在0~200岁之间")
private Integer age;
这个注解是Hibernate-Validator
对@Min、@Max
注解的二次封装,整合了两个注解的作用进行实现。
@URL
- 作用:被该注解修饰的字段,其值必须符合
URL
格式; - 适用类型:
String
;
使用示例:
@URL(message = "文件地址格式有误")
private String fileUrl;
这个注解是Hibernate-Validator
对@Pattern
注解的二次封装,基于正则表达式实现了URL
格式校验,并且该注解有三个可选参数,protocol
代表协议名,host
代表主机地址/域名,port
代表端口,根据场景需要,可以定制化的校验网址。
@UniqueElements
- 作用:被该注解修饰的集合、数组字段,其元素值不允许重复;
- 适用类型:
Collection、Array
;
使用示例:
@UniqueElements(message = "爱好不能重复")
private List<String> hobbys;
这个注解在某些情况下有用,但大多数情况下无用,毕竟如果要接收一个元素不重复的列表参数,直接适用Set
作为接收格式即可,除非你希望元素有序、且不能重复(其实也能找到替代的数据结构)。
@CreditCardNumber
- 作用:被该注解修饰的字段,其值必须符合信用卡号格式;
- 适用类型:
String
;
使用示例:
@CreditCardNumber(message = "信用卡号格式不正确")
private String creditCardNumber;
这个注解只会对信用卡号码的格式进行校验,不区分具体的信用卡类型和发行国家,如果需要完全验证国内银行卡号,仍然需要使用正则表达式来实现。
3.3.2、Hibernate-Validator其他注解
前面简单列了几个Hibernate-Validator
提供的校验注解,这里不再继续细说,大概列一下其作用:
@Currency
:用于验证一个字符串是否为有效的货币符号。@ConstraintComposition
:用于组合多个注解,实现多个约束条件的组合。@EAN
:用于验证一个字符串是否为有效的国际商品条码(EAN
)。@ISBN
:用于验证一个字符串是否为有效的国际标准书号(ISBN
)。@LuhnCheck
:用于验证一个字符串是否通过Luhn
算法校验(常用于信用卡号等场景)。@ScriptAssert
:用于自定义脚本校验,可以灵活实现复杂校验逻辑。@Mod10Check
:用于指明一个字段需要进行mod10
校验。@CodePointLength
:用于校验字符串的Unicode
代码点长度,可以对各种字符串长度进行校验;......
上述这些注解用的频率不是特别高,所以不做过多展开,大家简单了解即可。
3.3.3、BindingResult参数校验结果
默认情况下,参数校验未通过会抛出异常,而这个异常信息会直接返回给客户端,这显然不大好看,那有什么办法可以将其美化吗?答案是有,Spring
中提供了一个BindingResult
接口,该接口可以配合@Valid
注解一起使用,在方法上加一个BindingResult
类型的入参即可,如下:
@PutMapping("/v2")
public Result<Void> editV2(@Validated @RequestBody UserDTO userDTO, BindingResult bindingResult) {
// 判断参数校验是否通过
if (bindingResult.hasErrors()) {
List<String> errors = new ArrayList<>();
bindingResult.getFieldErrors().forEach(error -> {
errors.add(error.getDefaultMessage());
});
// 未通过则手动处理并返回错误信息
return Result.error(String.join(",", errors));
}
// 如果校验通过,就执行对应的业务逻辑
System.out.println("V2-模拟修改操作:" + userDTO);
return Result.success("用户信息编辑成功!");
}
先来观察/v2
接口的调用结果:
通过这种方式,就避免了直接将异常堆栈信息直接返回给调用,而是将不美观的堆栈信息统一变成了固定格式,这样不管是RPC
调用,还是前端联调,都能清晰的感知到错误原因,两个字总结就是:优雅!
下面简单了解下BindingResult
里的常用方法:
hasErrors()
:判断参数校验是否通过;getAllErrors()
:获取所有校验出错的信息;getFieldErrors()
:获取所有字段校验出错的信息;reject(…)
:手动往结果集里塞入一个错误信息;getFieldError(…)
:获取指定字段的错误信息;- ……
当然,BindingResult
里面的这些API
简单了解就行,毕竟实际开发中,很少会用到这个对象来处理校验后的结果集,Why?因为我们有更优雅的方式!
3.3.4、Spring自定义全局异常捕获器
前面可以通过BindingResult
来手动处理校验出错的结果集,可如果每个接口都要加一个BindingResult
类型的入参,虽然对客户端来说,接口调用结果变优雅了,但这个工作量对开发者本身无疑特别大,有没有能帮我们节省工作量的方式呢?有,SpringMVC
提供了全局异常处理器的功能,为此,我们可以通过自定义全局异常捕获器来统一“美化”校验未通过的结果返回:
/*
* 全局异常处理器
* */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 自定义异常捕获器
*/
@SuppressWarnings("rawtypes")
@ExceptionHandler(BusinessException.class)
public Result handleBusinessException(BusinessException e) {
log.warn(e.getMessage(), e);
return Result.error(e.getCode(), e.getMessage());
}
/*
* 全局参数校验异常处理器
* */
@ExceptionHandler(BindException.class)
public Result<?> handleBindException(BindException e) {
log.error(e.getMessage(), e);
return Result.error("参数错误: " +
Objects.requireNonNull(e.getBindingResult()
.getFieldError()).getDefaultMessage());
}
}
其他代码不用看,主要看其中的handleBindException()
方法,这里定义了一个全局异常处理器,通过@ExceptionHandler()
注解指定捕获校验参数出错的BindException
异常,接着将异常封装成了统一的Result
对象并返回,这时来看接口调用结果:
通过自定义全局异常捕获器,可以节省大家手动处理校验结果集的时间。除非你需要在参数校验出错之后,这时可以通过BindingResult
来完成,执行一些特定的后置处理,否则我们就可以和BindingResult
说拜拜了。
3.3.5、@Valid与@Validated注解
如果有玩过spring-boot-starter-validation
参数校验的小伙伴,应该知道除开前面使用的@Valid
注解外,还有另一个注解@Validated
,在Controller
里面,两个注解都可以使用,如下:
@PutMapping
public Result<Void> edit(@Valid @RequestBody UserDTO userDTO) {
System.out.println("模拟修改操作:" + userDTO);
return Result.success("用户信息编辑成功!");
}
@PostMapping("/v2")
public Result<Void> editV2(@Validated @RequestBody UserDTO userDTO) {
System.out.println("模拟修改操作:" + userDTO);
return Result.success("用户信息编辑成功!");
}
这两个注解有何区别呢?@Valid
是JSR
定义的标准校验注解,而@Validated
则由Spring
提供,后者完全具备前者的功能,并且更加强大,额外支持分组校验、嵌套验证、校验排序等功能,同时还能让参数校验的功能在非Controller
层生效,如Service
层等。当然,对于这些高级特性会在后续单独讲述。
四、参数校验高级特性
好了,其实掌握前面的参数校验注解后,下面来看看参数校验的一些高级特性,先来快速过下嵌套校验,在前面的例子中,里面的字段几乎都是基本数据类型,而实际开发中,往往会存在引用类型嵌套的情况,例如:
@Data
public class PandaDTO implements Serializable {
private static final long serialVersionUID = 1L;
@NotBlank(message = "编号不能为空")
private Integer id;
@NotBlank(message = "姓名不能为空")
private String name;
@Min(value = 0, message = "年龄不能小于0")
private Integer age;
@NotBlank(message = "颜色不能为空")
private String color;
@NotNull(message = "饲养员不能为空")
private UserDTO stockMan;
}
这是一个熊猫参数类,其中有个字段为饲养员,该字段是一个引用类型,这里对应一个保存接口:
@PostMapping
public Result<Void> savePanda(@Valid @RequestBody PandaDTO pandaDTO) {
System.out.println("模拟保存操作:" + pandaDTO);
return Result.success("熊猫信息保存成功!");
}
虽然在pandaDTO
参数前加上了@Valid
注解,可是校验时并不会继续校验stockMan
里的每个字段,怎么办?很简单,再在PandaDTO
类的stockMan
字段上,加上一个@Valid
注解即可。
4.1、参数分组校验
除开嵌套校验外,在实际开发过程中,通常还会有不同规则的校验,例如下述业务场景:
这是电商营销推广的一小部分场景,营销推广的类型有好几种,看图中截出的“活动预热”,对应的触达方式又有好多种,那对应的活动保存接口,请求参数也得根据不同的类型来校验,毕竟某个参数在第一种推广类型中是必填项,可在第二种类型中又不是,所以前面提到的校验方式无法满足这种场景,怎么办?使用分组校验。
PS:如果不理解上面这个业务场景,大家可以换成“新增、编辑”两个接口,通常来说,新增和编辑的参数基本相同,唯一不同的点在于:编辑时需要传递并校验
ID
,而新增则不需要,咋办?直接在id
字段上加个@NotNull
的注解吗?貌似不行,因为新增并不需要传递,所以只能将DTO
分成xxxCreateDTO、xxxUpdateDTO
,这显然有点冗余,而分组校验就可以满足这个场景。
先来看DTO
参数类:
@Data
public class UserDTO {
@NotNull(message = "ID不能为空", groups = UserUpdate.class)
@Min(value = 0, message = "ID不能小于0", groups = UserUpdate.class)
private Long userId;
@NotNull(message = "用户名不能为空", groups = {
UserUpdate.class, UserCreate.class})
private String userName;
// 定义两个接口,仅用于分组
public interface UserCreate {
}
public interface UserUpdate {
}
}
上面定义了UserCreate、UserUpdate
两个接口,这两个接口单纯用于分组而已,接着在每个字段的参数校验注解上,通过groups
指定了对应校验所属的分组,那如何使用呢?如下:
@PutMapping("/group")
public Result<Void> groupEdit(@Validated(UserDTO.UserUpdate.class) @RequestBody UserDTO userDTO) {
System.out.println("模拟修改操作:" + userDTO);
return Result.success("用户信息编辑成功!");
}
@PostMapping("/group")
public Result<Void> groupSave(@Validated(UserDTO.UserCreate.class) @RequestBody UserDTO userDTO) {
System.out.println("模拟新增操作:" + userDTO);
return Result.success("用户信息保存成功!");
}
要注意的是,只有@Validated
注解才支持分组校验,因此这里需要使用该注解,而后在注解后面,指定当前接口校验参数时的分组即可,如上述代码中,Put
请求对应着编辑用户信息接口,所以将分组指定成UserUpdate
;而Post
请求对应着保存用户信息接口,为此将分组指定成UserCreate
即可。通过分组校验,可以满足不同接口需要校验的入参不同时,共用一个参数类的需求。
4.2、自定义校验注解
上面学习了分组校验后,那假设我们需要某类规则的校验,但没有现成注解怎么办?其实JSR
也想到了这点,因此特意留下了拓展接口,即:
public interface ConstraintValidator<A extends Annotation, T> {
default void initialize(A constraintAnnotation) {
}
boolean isValid(T var1, ConstraintValidatorContext var2);
}
该接口定义的两个泛型,A
代表自定义注解,T
代表需要被验证的元素类型,同时两个方法对应的作用为:
initialize()
:初始化方法,定义为default
方法,子类可以不用实现;isValid()
:参数校验实现逻辑的方法,返回true
代表通过,false
代表未通过。
现在来实战体验一下,先来定义一个枚举类、一个注解:
/**
* 状态枚举类
*/
@Getter
@AllArgsConstructor
public enum StatusEnum {
ENABLE(0, "未开始"),
DISABLED(1, "进行中");
private final Integer code;
private final String statusDesc;
}
/**
* 状态校验注解
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({
ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Constraint(validatedBy = {
StatusValidated.class})
public @interface Status {
String message() default "状态必须为启用或禁用!";
Class[] groups() default {
};
Class[] payload() default {
};
}
目前的需求是,接口调用方传递的status
必须在枚举定义范围内,如果在枚举中未找到对应的定义,就抛出异常提示校验不通过,下面来写自定义注解的实现类:
/**
* 状态校验类
*/
public class StatusValidated implements ConstraintValidator<Status, Integer> {
/*
* 参数校验的实现逻辑
* */
@Override
public boolean isValid(Integer status, ConstraintValidatorContext constraintValidatorContext) {
if (Objects.isNull(status)) {
return false;
}
// 检查当前字段的值是否在枚举里有定义
for (StatusEnum statusEnum : StatusEnum.values()) {
if (Objects.equals(statusEnum.getCode(), status)) {
return true;
}
}
return false;
}
}
这段代码极其简单,先判断对应字段的值是否为空,如果为空直接返回校验未通过。如果不为空,则获取StatusEnum
类中定义的所有枚举,然后与当前字段的值进行比对,如果字段值在枚举中找到了,代表校验通过,反之则代表未通过,下面来看具体的使用:
@Data
public class UserDTO {
@NotNull(message = "ID不能为空", groups = UserUpdate.class)
@Min(value = 0, message = "ID不能小于0", groups = UserUpdate.class)
private Long userId;
@NotNull(message = "用户名不能为空", groups = {
UserUpdate.class, UserCreate.class})
private String userName;
@Status(groups = {
UserUpdate.class, UserCreate.class})
private Integer status;
public interface UserCreate {
}
public interface UserUpdate {
}
}
/**
* 编辑用户信息的接口
*/
@PutMapping("/group")
public Result<Void> groupEdit(@Validated(UserDTO.UserUpdate.class) @RequestBody UserDTO userDTO) {
System.out.println("模拟修改操作:" + userDTO);
return Result.success("用户信息编辑成功!");
}
上述代码中,在UserDTO
类中新增了一个status
字段,并且在字段上加上了刚才自定义的@Status
注解,下面来看实际调用结果:
如上图所示,当status
传入3
时,因为该值未在枚举类中定义,所以接口状态码为5000
,msg
为定义的默认出错提示语。再看右图,当传入枚举里定义的值时,参数校验通过,用户信息编辑成功!
4.3、参数校验底层原理
看到这里,前面的内容已经能完全满足日常开发所需,可是这些框架究竟是如何实现参数校验的呢?下面来简单捋一遍,通过上阶段的自定义校验注解,我们能得知一点:每个校验注解都对应着一个ConstraintValidator实现类,比如@NotNull
注解,最终能找到NotNullValidator
这个实现类,如下:
那这些实现类对应的isValid()
方法,究竟何时会被调用呢?答案是SpringMVC
绑定接口入参时调用,大家感兴趣可以找到SpringMVC
的ModelAttributeMethodProcessor
类的resolveArgument()
方法上打个断点,这个方法就是处理参数校验的具体逻辑,最终的调用在这行代码里:
PS:
ModelAttributeMethodProcessor
类实现了HandlerMethodArgumentResolver
接口,如果对MVC
源码比较熟悉的小伙伴,应该知道这个接口定义的resolveArgument()
方法,就是处理网络请求调用Heandler
处理器绑定参数的方法。
当然,具体源码就不在本篇里一行行Debug
了,其实大致流程很简单:
- ①出现请求调用具体接口时,会触发
SpringMVC
参数绑定机制; - ②
SpringMVC
通过反射调用参数类的setXXX()
方法来完成参数绑定; - ③当检测到参数类对应的字段上,存在校验参数的注解时,就会处理参数校验逻辑;
- ④经过层层初始化、组件调度,最终会找到对应注解的实现类,然后调用
isValid()
方法; - ⑤如果
isValid()
返回false
,代表校验未通过,这时会将信息放入BindingResult
对象; - ⑥当某个字段未通过校验时,
SpringMVC
中会继续校验其他字段,直至遍历完所有字段为止; - ⑦当参数校验结束后,如果没有
false
则执行业务逻辑,反之则返回BindingResult
对象或抛出异常。
这就是@Valid
注解校验参数的流程,如果目前控制层用的是SpringMVC
框架,触发时机就是SpringMVC
绑定参数时,SpringMVC
会通过SPI
机制反向找到hibernate-validator
提供的ValidatorImpl
实现类(其他控制层框架也类似),然后调用它验证值的具体实现。
也就是因为这样,@Valid
注解只在Controller
层生效,就算你在Service
层方法的入参前面,加上@Valid
注解也并不会生效。当然,如果你在Service
类或方法上加上@Validated
注解,那么参数校验依旧会生效的,因为@Validated
的底层是AOP
,会有专门的切面来触发参数校验机制。
五、参数校验篇总结
看到这里,本文已经接近了尾声,通过spring-boot-starter-validation
这种类库,能极大程度上减少手动编写参数校验逻辑的工作量,而且@Valid
会在请求参数绑定触发校验,因此也不会比直接写if
的性能差距不会太离谱(但@Valid
每次会走完所有字段的校验,就算某个字段校验出错也不会阻断执行)。
当然,如果非要死磕性能对比,在请求次数比较多的前提下,展示出的性能结果可能会存在不小差异,毕竟if
才是最干脆的校验方式,一方面少了很多层方法调用,另一方面还能在某个字段校验出错后有效阻断执行。可为了一丢丢性能,你或许要多写许多代码,要性能还是要便捷,这就取决于诸位自己啦~
最后再来说一种另类的参数校验方式,大家都知道,基于SpringMVC
定义的接口,接口入参都会调用set
方法来绑定,那就意味着你其实可以将参数校验逻辑写在set
方法里面,比如:
public void setUserName(String userName) {
if (userName == null || userName.equals("")) {
throw new BusinessException(ResponseCode.ERROR.getCode(), "用户名不能为空");
}
this.userName = userName;
}
这也是一种另类的参数校验方式,有些时候比较管用,但多个字段需要一起校验时就无能为力,大家对于这种方式有所了解即可~
所有文章已开始陆续同步至公众号:竹子爱熊猫,想在微信上便捷阅读的小伙伴可搜索关注~