如何干掉你代码里的if,让请求参数校验变的更加优雅?

简介: 日常开发过程中,代码各处充满着“陷阱”,稍不留神,就容易出现各类稀奇古怪的Bug,这回咱们来说开发中经常处理的情况,即:参数校验!大多数需要校验参数的情况,都出现在”数据交互“这个场景下,比如前端向后端提交表单数据、后端调用兄弟部门的RPC接口等。今天我们重点站在后端的角度,聊聊不同场景下的多种参数校验手段。

引言

日常开发过程中,代码各处充满着“陷阱”,稍不留神,就容易出现各类稀奇古怪的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规范对应的实现,所以其groupIdjavax开头,那后面两个又是咋回事呢?hibernate-validator也是Java Validation API规范的落地者,除开实现了规范中的constraint(约束)外,还额外拓展了许多新的constraint

001.png

可以很明显看到,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,来看看依赖关系图:

002.png

2.2、特殊的引入方式

不知大家是否记得之前文章提过的《Maven依赖传递性》,所谓依赖传递性,即:

当引入的一个包,如果依赖于其他包(类库),当前的工程就必须再把其他包引入进来。

而参数校验是Web开发中常用的功能,为此,当你需要使用前面提到的几种校验库时,无需手动通过GAV坐标引入,只要你的项目中,引入spring-boot-starter-web这个依赖,根据依赖传递原则,你的工程会自动引入前面提到的校验类库,只不过在不同版本中有所区别,如下:

003.png

从上图可观测出,根据项目中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工具调用一下该接口,一起看看结果:

004.png

大家可以发现,由于咱们通过@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注解可以搭配任意正则表达式使用,所以面对特殊的校验场景时,大家可以基于该注解进行处理,比如手机号、身份证、邮箱、网址校验等等。

@Email
  • 作用:被该注解修饰的字段,其值必须为邮箱地址;
  • 适用类型:String

使用示例:

@Email(message = "邮箱地址格式有误")
private String email;

这个注解是官方对@Pattern的封装,内部基于正则表达式进行了邮箱格式校验。

3.3、Hibernate-Validator校验注解与SpringBoot封装

上阶段详细介绍了Bean-Validation定义的六大类、22个细分注解,而这些在Hibernate-Validator库中都支持,并且Hibernate还对其进行了拓展,新增了几个常用的校验注解,如下:

005.png

左图为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接口的调用结果:

006.png

通过这种方式,就避免了直接将异常堆栈信息直接返回给调用,而是将不美观的堆栈信息统一变成了固定格式,这样不管是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对象并返回,这时来看接口调用结果:

007.png

通过自定义全局异常捕获器,可以节省大家手动处理校验结果集的时间。除非你需要在参数校验出错之后,这时可以通过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("用户信息编辑成功!");
}

这两个注解有何区别呢?@ValidJSR定义的标准校验注解,而@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、参数分组校验

除开嵌套校验外,在实际开发过程中,通常还会有不同规则的校验,例如下述业务场景:

008.png

这是电商营销推广的一小部分场景,营销推广的类型有好几种,看图中截出的“活动预热”,对应的触达方式又有好多种,那对应的活动保存接口,请求参数也得根据不同的类型来校验,毕竟某个参数在第一种推广类型中是必填项,可在第二种类型中又不是,所以前面提到的校验方式无法满足这种场景,怎么办?使用分组校验。

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注解,下面来看实际调用结果:

009.png

如上图所示,当status传入3时,因为该值未在枚举类中定义,所以接口状态码为5000msg为定义的默认出错提示语。再看右图,当传入枚举里定义的值时,参数校验通过,用户信息编辑成功!

4.3、参数校验底层原理

看到这里,前面的内容已经能完全满足日常开发所需,可是这些框架究竟是如何实现参数校验的呢?下面来简单捋一遍,通过上阶段的自定义校验注解,我们能得知一点:每个校验注解都对应着一个ConstraintValidator实现类,比如@NotNull注解,最终能找到NotNullValidator这个实现类,如下:

010.png

那这些实现类对应的isValid()方法,究竟何时会被调用呢?答案是SpringMVC绑定接口入参时调用,大家感兴趣可以找到SpringMVCModelAttributeMethodProcessor类的resolveArgument()方法上打个断点,这个方法就是处理参数校验的具体逻辑,最终的调用在这行代码里:

011.png

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;
}

这也是一种另类的参数校验方式,有些时候比较管用,但多个字段需要一起校验时就无能为力,大家对于这种方式有所了解即可~

所有文章已开始陆续同步至公众号:竹子爱熊猫,想在微信上便捷阅读的小伙伴可搜索关注~

相关文章
|
12月前
|
Java 数据库连接 Spring
JavaWeb优雅实现接口参数校验
JavaWeb优雅实现接口参数校验
110 0
FeignClient打印请求失败的日志,打印所有feignCliet接口请求失败的错误日志,方便排查原因
FeignClient打印请求失败的日志,打印所有feignCliet接口请求失败的错误日志,方便排查原因
206 0
|
2月前
|
Java 数据库连接 应用服务中间件
表单数据返回不到,HTTP状态 404 - 未找未找到,解决方法,针对这个问题,写一篇文章,理一下思路,仔细与原项目比对,犯错的原因是Mapper层的select查询表单数据写错,注意打开的路径对不对
表单数据返回不到,HTTP状态 404 - 未找未找到,解决方法,针对这个问题,写一篇文章,理一下思路,仔细与原项目比对,犯错的原因是Mapper层的select查询表单数据写错,注意打开的路径对不对
后端登录接口使用postman,无法接收返回数据,怎样解决,认真比较与原项目的代码,看看有没有写的不一样的,问题就能解决,不要多少写,根据postman的提示先找到错误的进程,看错误进程出现在那个进程
后端登录接口使用postman,无法接收返回数据,怎样解决,认真比较与原项目的代码,看看有没有写的不一样的,问题就能解决,不要多少写,根据postman的提示先找到错误的进程,看错误进程出现在那个进程
|
3月前
|
NoSQL Java 数据库
重复点击提交、产生多笔数据、保持数据只操作一次---->接口幂等性校验
重复点击提交、产生多笔数据、保持数据只操作一次---->接口幂等性校验
28 0
|
4月前
|
JSON 测试技术 API
记一个低级错误导致的接口失败
记一个低级错误导致的接口失败
|
JSON 前端开发 数据格式
前端对接口参数错误如何解决
前端对接口参数错误如何解决
76 0
|
JSON 前端开发 数据格式
前端对接口参数错误如何解决
前端对接口参数错误如何解决
107 0
|
前端开发 安全 JavaScript
在SpringMVC框架中统一处理异常及请求参数验证(5)
在SpringMVC框架中统一处理异常及请求参数验证(5)
182 0
在SpringMVC框架中统一处理异常及请求参数验证(5)
|
Java
解决校验失败时,提示信息国际化失效问题
SpringBoot校验(Validator)失败,使用MessageSource国际化失效问题记录
786 0
解决校验失败时,提示信息国际化失效问题