查看原文
其他

请不要再使用判断进行参数校验了

码农小胖哥 码农小胖哥 2021-05-27

1. 前言

因为网络传输的不可靠性,以及前端数据控制的可篡改性,后端的参数校验是必须的,应用程序必须通过某种手段来确保输入进来的数据从语义上来讲是正确的。

2. 数据校验的痛点

为了保证数据语义的正确,我们需要进行大量的判断来处理验证逻辑。而且项目的分层也会造成一些重复的校验,产生大量与业务无关的代码。不利于代码的维护,增加了开发人员的工作量。

3. JSR 303 校验规范及其实现

为了解决上面的痛点,将验证逻辑与相应的领域模型进行绑定是十分有必要的。为此产生了JSR 303 – Bean Validation 规范。Hibernate Validator 是JSR-303的参考实现,它提供了JSR 303规范中所有的约束(constraint)的实现,同时也增加了一些扩展。

Hibernate Validator 提供的常用约束注解

约束注解详细信息
@Null被注释的元素必须为 null
@NotNull被注释的元素必须不为 null
@AssertTrue被注释的元素必须为 true
@AssertFalse被注释的元素必须为 false
@Min(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@DecimalMax(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max, min)被注释的元素的大小必须在指定的范围内
@Digits (integer, fraction)被注释的元素必须是一个数字,其值必须在可接受的范围内
@Past被注释的元素必须是一个过去的日期
@Future被注释的元素必须是一个将来的日期
@Pattern(value)被注释的元素必须符合指定的正则表达式
@Email被注释的元素必须是电子邮箱地址
@Length被注释的字符串的大小必须在指定的范围内
@NotEmpty被注释的字符串的必须非空
@Range被注释的元素必须在合适的范围内

4. 验证注解的使用

Spring Boot开发中使用Hibernate Validator是非常容易的,引入下面的starter就可以了:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

一种可以实现接口来定制Validator,一种是使用约束注解。胖哥觉得注解可以满足绝大部分的需求,所以建议使用注解来进行数据校验。而且注解更加灵活,控制的粒度也更加细。接下来我们来学习如何使用注解进行数据校验。

4.1 约束注解的基本使用

我们对需要校验的方法入参进行注解约束标记,例子如下:

@Data
public class Student {

    @NotBlank(message = "姓名必须填")
    private String name;
    @NotNull(message = "年龄必须填写")
    @Range(min = 1,max =50, message = "年龄取值范围1-50")
    private Integer age;
    @NotEmpty(message = "成绩必填")
    private List<Double> scores;
}

POST 请求

然后定义一个POST请求的Spring MVC接口:

@RestController
@RequestMapping("/student")
public class StudentController {


    @PostMapping("/add")
    public Rest<?> addStudent(@Valid @RequestBody Student student) {
        return RestBody.okData(student);
    }
}

通过对addStudent方法入参添加@Valid来启用参数校验。当使用下面数据进行请求将会抛出MethodArgumentNotValidException异常,提示age范围超出1-50

POST /student/add HTTP/1.1
Host: localhost:8888
Content-Type: application/json

{
"name": "felord.cn",
"age": 77,
"scores": [
55
]
}

GET 请求

如法炮制,我们定义一个GET请求的接口:

@GetMapping("/get")
public Rest<?> getStudent(@Valid Student student) {
    return RestBody.okData(student);
}

使用下面的请求可以正确对学生分数scores进行了校验,但是抛出的并不是MethodArgumentNotValidException异常,而是BindException异常。这和使用@RequestBody注解有关系,这对我们后面的统一处理非常十分重要。

GET /student/get?name=felord.cn&age=12 HTTP/1.1
Host: localhost:8888

自定义注解

可能有些同学注意到上面的年龄我进行了这样的标记:

@NotNull(message = "年龄必须填写")
@Range(min = 1,max =50, message = "年龄取值范围1-50")
private Integer age;

这是因为@Range不会去校验为空的情况,它只处理非空的时候是否符合范围约束。所以要用多个注解来约束。如果我们某些场景需要重复的捆绑多个注解来使用时,可以使用自定义注解将它们封装起来组合使用,下面这个注解就是将@NotNull@Range进行了组合,你可以仿一个出来用用看。

import org.hibernate.validator.constraints.Range;

import javax.validation.Constraint;
import javax.validation.Payload;
import javax.validation.ReportAsSingleViolation;
import javax.validation.constraints.NotNull;
import javax.validation.constraintvalidation.SupportedValidationTarget;
import javax.validation.constraintvalidation.ValidationTarget;
import java.lang.annotation.*;

/**
 * @author a
 * @since 17:31
 **/

@Constraint(
        validatedBy = {}
)
@SupportedValidationTarget({ValidationTarget.ANNOTATED_ELEMENT})
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD,
        ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR,
        ElementType.PARAMETER, ElementType.TYPE_USE})
@NotNull
@Range(min = 1, max = 50)
@Documented
@ReportAsSingleViolation
public @interface Age {
    // message 必须有
    String message() default "年龄必须填写,且范围为 1-50 ";

    // 可选
    Class<?>[] groups() default {};

    // 可选
    Class<? extends Payload>[] payload() default {};
}

还有一种情况,我们在后台定义了枚举值来进行状态的流转,也是需要校验的,比如我们定义了颜色枚举:

public enum Colors {

    RED, YELLOW, BLUE

}

我们希望入参不能超出Colors的范围["RED", "YELLOW", "BLUE"],这就需要实现ConstraintValidator<A extends Annotation, T>接口来定义一个颜色约束了,其中泛型A为自定义的约束注解,泛型T为入参的类型,这里使用字符串,然后我们的实现如下:

/**
 * @author felord.cn
 * @since 17:57
 **/

public class ColorConstraintValidator implements ConstraintValidator<Color, String> {
    private static final Set<String> COLOR_CONSTRAINTS = new HashSet<>();

    @Override
    public void initialize(Color constraintAnnotation) {
        Colors[] value = constraintAnnotation.value();
        List<String> list = Arrays.stream(value)
                .map(Enum::name)
                .collect(Collectors.toList());
        COLOR_CONSTRAINTS.addAll(list);

    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return COLOR_CONSTRAINTS.contains(value);
    }
}

然后声明对应的约束注解Color,需要在元注解@Constraint中指明使用上面定义好的处理类ColorConstraintValidator进行校验。

/**
 * @author felord.cn
 * @since 17:55
 **/

@Constraint(validatedBy = ColorConstraintValidator.class)
@Documented
@Target({ElementType.METHOD, ElementType.FIELD,
        ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR,
        ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Color {
    // 错误提示信息
    String message() default "颜色不符合规格";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    // 约束的类型
    Colors[] value();
}

然后我们来试一下,先对参数进行约束:

@Data
public class Param {
    @Color({Colors.BLUE,Colors.YELLOW})
   private String color;
}

接口跟上面几个一样,调用下面的接口将抛出BindException异常:

GET /student/color?color=CAY HTTP/1.1
Host: localhost:8888

当我们把参数color赋值为BLUE或者YELLOW后,能够成功得到响应。

4.2 常见问题

在实际使用起来我们会遇到一些问题,这里总结了一些常见的问题和处理方式。

检验基础类型不生效的问题

上面为了校验颜色我们声明了一个Param对象来包装唯一的字符串参数color,为什么直接使用下面的方式定义呢?

@GetMapping("/color")
public Rest<?> color(@Valid @Color({Colors.BLUE,Colors.YELLOW}) String color) {
    return RestBody.okData(color);
}

或者使用路径变量:

@GetMapping("/rest/{color}")
public Rest<?> rest(@Valid @Color({Colors.BLUE, Colors.YELLOW}) @PathVariable String color) {
    return RestBody.okData(color);
}

上面两种方式是不会生效的。不信你可以试一试,起码在Spring Boot 2.3.1.RELEASE是不会直接生效的。

使以上两种生效的方法是在类上添加@Validated注解。注意一定要添加到方法所在的类上才行。这时候会抛出ConstraintViolationException异常。

集合类型参数中的元素不生效的问题

就像下面的写法,方法的参数为集合时,如何检验元素的约束呢?

/**
 * 集合类型参数元素.
 *
 * @param student the student
 * @return the rest
 */

@PostMapping("/batchadd")
public Rest<?> batchAddStudent(@Valid @RequestBody List<Student> student) {
    return RestBody.okData(student);
}

同样是在类上添加@Validated注解。注意一定要添加到方法所在的类上才行。这时候会抛出ConstraintViolationException异常。

嵌套校验不生效

嵌套的结构如何校验呢?打个比方,如果我们在学生类Student中添加了其所属的学校信息School并希望对School的属性进行校验。

@Data
public class Student {

    @NotBlank(message = "姓名必须填")
    private String name;
    @Age
    private Integer age;
    @NotEmpty(message = "成绩必填")
    private List<Double> scores;
    @NotNull(message = "学校不能为空")
    private School school;
}


@Data
public class School {
    @NotBlank(message = "学校名称不能为空")
    private String name;
    @Min(value = 0,message ="校龄大于0" )
    private Integer age;
}

GET请求时正常校验了School的属性,但是POST请求却无法对School的属性进行校验。这时我们只需要在该属性上加上@Valid注解即可。

@Data
public class Student {

    @NotBlank(message = "姓名必须填")
    private String name;
    @Age
    private Integer age;
    @NotEmpty(message = "成绩必填")
    private List<Double> scores;
    @Valid
    @NotNull(message = "学校不能为空")
    private School school;
}

每加一层嵌套都需要加一层@Valid注解。通常在校验对象属性时,@NotNull@NotEmpty@Valid配合才能起到校验效果。

如果你有其它问题可以通过 felord.cn 联系到我探讨。

5. 总结

通过校验框架我们可以专心于业务开发,本文对Hibernate Validator的使用和一些常见问题进行了梳理。我们可以通过Spring Boot 统一异常处理来解决参数校验的异常信息的提示问题。具体可以通过关注:码农小胖哥 回复 valid获取相关DEMO如果觉得本文对你有用,请点赞、转发、在看,给胖哥以创作动力。

往期推荐:

Spring Security 实战干货:从零手写一个验证码登录

2020-07-22

Spring Security 实战干货:理解AuthenticationManager

2020-07-21



    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存