跳至主要內容

接口参数校验

Mayee...大约 21 分钟

1. 需求

1.1. 什么是 JSR-303

在 JavaWeb 项目开发中,常常需要进行接口参数校验,这个需求在 JSR-303规范中被提到,JSR是Java Specification Requests的缩写,意思是Java 规范提案。是指向JCP(Java Community Process)提出新增一个标准化技术规范的正式请求。任何人都可以提交JSR,以向Java平台增添新的API和服务。JSR已成为Java界的一个重要标准。JSR-303 是 JAVA EE 6 中的一项子规范,叫做 Bean Validation,官方参考实现是 Hibernate Validator。

1.2. hibernate-validator 简介

hibernate-validator 是 JSR-303 规范的一个实现,也是 Java 开发中使用最广泛的参数校验框架,而它也被集成到了 Spring 家族中 spring-boot-starter-validation ,你可以轻易的使用它来完成接口参数校验。使用文档参见 Hibernate Validatoropen in new window

2. 使用前置

2.1. 引入依赖

Maven 项目中引入以下任一坐标即可:

<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>
    <version>2.5.5</version>
</dependency>

2.2. 常用注解

注解可验证的数据类型说明
@AssertFalseBoolean,boolean验证注解的元素值是false
@AssertTrueBoolean,boolean验证注解的元素值是true
@NotNull任意类型验证注解的元素值不是null
@Null任意类型验证注解的元素值是null
@NotEmptyCharSequence子类型、Collection、Map、数组验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0)
@NotBlankCharSequence子类型验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的首位空格
@Min(value=值)BigDecimal,BigInteger, byte,short, int, long,等任何Number或CharSequence(存储的是数字)子类型验证注解的元素值大于等于@Min指定的value值
@Max(value=值)和@Min要求一样验证注解的元素值小于等于@Max指定的value值
@DecimalMin(value=值)和@Min要求一样验证注解的元素值大于等于@ DecimalMin指定的value值
@DecimalMax(value=值)和@Min要求一样验证注解的元素值小于等于@ DecimalMax指定的value值
@Digits(integer=整数位数, fraction=小数位数)和@Min要求一样验证注解的元素值的整数位数和小数位数上限
@Size(min=下限, max=上限)字符串、Collection、Map、数组等验证注解的元素值的在min和max(包含)指定区间之内,如字符长度、集合大小
@Pastjava.util.Date,java.util.Calendar;Joda Time类库的日期类型验证注解的元素值(日期类型)比当前时间早
@Future与@Past要求一样验证注解的元素值(日期类型)比当前时间晚
@Length(min=下限, max=上限)CharSequence子类型验证注解的元素值长度在min和max区间内
@Range(min=最小值, max=最大值)BigDecimal,BigInteger,CharSequence, byte, short, int, long等原子类型和包装类型验证注解的元素值在最小值和最大值之间
@Email(regexp=正则表达式,flag=标志的模式)CharSequence子类型(如String)验证注解的元素值是Email,也可以通过regexp和flag指定自定义的email格式
@Pattern(regexp=正则表达式,flag=标志的模式)String,任何CharSequence的子类型验证注解的元素值与指定的正则表达式匹配
@Valid任何非原子类型指定递归验证关联的对象;如用户对象中有个地址对象属性,如果想在验证用户对象时一起验证地址对象的话,在地址对象上加@Valid注解即可级联验证
@CreditCardNumber数字对信用卡号进行一个大致的验证
@URL (protocol=,host,port)网址检查是否是一个有效的URL,如果提供了protocol,host等,则该URL还需满足提供的条件

3. 开始使用

3.1. 快速开始

3.1.1. 创建模型 User

在需要校验的属性上添加合适的注解,方可校验。

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class User implements Serializable {
    private static final long serialVersionUID = -7242397021777229674L;

    @NotEmpty
    private String name;
    @Min(1)
    private Integer age;
    @Email
    private String email;
    @Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
    private String phone;
}

3.1.2. 创建 Handler Method getpost

在请求参数前加 @Valid@Validated 注解任一即可。 区别在于 @Valid 是 Java 的注解,@Validated 是 Spring 的注解,是对 @Valid 注解的增强,兼容 @Valid 注解。

@RestController
@RequestMapping
public class DemoController {

    @GetMapping
    public String get(@Valid User user) {
        return ObjUtils.toJsonStr(user);
    }

    @PostMapping
    public String post(@Validated @RequestBody User user) {
        return ObjUtils.toJsonStr(user);
    }
}

3.1.3. 统一异常处理,统一响应

当实体类中的注解校验不通过时,会抛出 BindException 异常,这里捕获到异常,获取到属性名和错误提示,组装返回。

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BindException.class)
    public R bindExceptionHandler(BindException e) {
        log.debug("bindExcept: {}", e.getMessage());
        FieldError fieldError = e.getBindingResult().getFieldError();
        assert fieldError != null;
        return R.fail(String.format("%s %s", fieldError.getField(), fieldError.getDefaultMessage()));
    }
}

GET 请求 http://127.0.0.1:8080open in new window ,响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "name must not be empty"
}

3.2. 自定义校验

有时候内置注解不能满足需求,我们可以自定义校验方式,参照文档 Creating custom constraintsopen in new window 一节。
如,前端传来一个值,我们需要验证值是否被包含在我们指定的值中。

3.2.1. 创建自定义注解

strValues 指定字符串类型数组;intValues 指定数字类型数组;enumClass 指定一个枚举类。当指定的数组元素较多时,可以将其封装到枚举类中,简化编码。为了规范使用,这里定义了一个接口,枚举类必须实现这个接口才可以实现校验。

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {MatchAnyValidator.class})
@Repeatable(MatchAny.List.class)// 使注解支持重复定义
public @interface MatchAny {

    String message() default "not match any one";

    String[] strValues() default {};

    int[] intValues() default {};

    Class<? extends ValidateAble> enumClass() default EmptyValidateEnum.class;

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

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

    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        MatchAny[] value();
    }
}
public interface ValidateAble {

    String getValidateValue();
}

3.2.2. 实现校验逻辑

这里需要实现 ConstraintValidator<MatchAny, Object> 接口,泛型的第一个参数是自定义注解类,第二个参数是可以校验的数据类型,因为我们自定义的注解可以校验 String 和 int 类型,所以这里使用了 Object。
initialize 方法用来获取注解中的值,isValid 方法是校验逻辑,校验通过返回 true,否则返回 false

// 该类在 Spring 容器启动时即加载,无需添加 @Component 注解
public class MatchAnyValidator implements ConstraintValidator<MatchAny, Object> {
    private String[] strValues;
    private int[] intValues;
    private Class<? extends ValidateAble> clazz;

    @Override
    public void initialize(MatchAny constraintAnnotation) {
        strValues = constraintAnnotation.strValues();
        intValues = constraintAnnotation.intValues();
        clazz = constraintAnnotation.enumClass();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if (Objects.isNull(value)) {
            return true;
        }
        boolean pass;
        // 集合
        if (value instanceof Collection) {
            pass = ((Collection<?>) value).stream().allMatch(this::validValue);
            // 数组
        } else if (value.getClass().isArray()) {
            pass = Arrays.stream(((Object[]) value)).allMatch(this::validValue);
            // 单值
        } else {
            pass = validValue(value);
        }
        if (!pass) {
            // 禁用默认的错误提示
            context.disableDefaultConstraintViolation();
            String valueStr = getValueStr();
            context.buildConstraintViolationWithTemplate(String.format("must be one of [%s]", valueStr)).addConstraintViolation();
            return false;
        }
        return true;
    }

    /**
     * @param value
     * @Description: 校验单个值,值可能是数字或字符串
     * @return: boolean
     * @Author: Bobby.Ma
     * @Date: 2021/12/15 15:06
     */
    private boolean validValue(Object value) {
        if (clazz != EmptyValidateEnum.class) {
            return Arrays.stream(clazz.getEnumConstants()).map(ValidateAble::getValidateValue).anyMatch(o -> Objects.equals(o, value));
        } else if (value instanceof String) {
            return Arrays.asList(strValues).contains(value);
        } else if (value instanceof Integer) {
            return IntStream.of(intValues).anyMatch(i -> Objects.equals(i, value));
        } else {
            // 只支持校验 数字或字符串
            return false;
        }
    }

    private String getValueStr() {
        if (clazz.isEnum() && clazz != EmptyValidateEnum.class) {
            ValidateAble[] validateAbles = clazz.getEnumConstants();
            return Arrays.stream(validateAbles).map(ValidateAble::getValidateValue).map(Object::toString).collect(Collectors.joining(","));
        } else {
            if (ArrayUtils.isNotEmpty(strValues)) {
                return String.join(",", strValues);
            } else {
                return Arrays.stream(intValues).mapToObj(Objects::toString).collect(Collectors.joining(","));
            }
        }
    }
}

3.2.3. 定义一个枚举类

枚举类要实现 ValidateAble 接口。

@AllArgsConstructor
public enum PermissionEnum implements ValidateAble {
    ADMIN("admin"),
    STAFF("staff"),
    BOSS("boss");

    private final String value;

    @Override
    public String getValidateValue() {
        return this.value;
    }
}

3.2.4. 使用自定义注解

在模型 User 中新增 permission 属性,并添加 @MatchAny 注解。

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class User implements Serializable {
    private static final long serialVersionUID = -7242397021777229674L;

    @NotEmpty
    private String name;
    @Min(1)
    private Integer age;
    @Email
    private String email;
    @Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
    private String phone;

    @MatchAny(enumClass = PermissionEnum.class)
    private String permission;
}

GET 请求 http://127.0.0.1:8080?name=bobby&permission=undefineopen in new window ,响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "permission must be one of [admin,staff,boss]"
}

3.3. PathVariable 数据校验

如果参数是在 uri 中该如何校验呢?

3.3.1. 新增 Handler Method

DemoController 中新增一个 path 方法,其参数注解有 @PathVariable("age")。注意,需要在当前类上注解 @Validated 才生效,@Valid 无效,因此尽量使用 @Validated 注解。
@Validated 注解在类型和注解在方法参数上并不冲突,如 post 方法中的 @Validated 只对 User 实体中的校验注解生效,而 DemoController 类上的 @Validated 只对 path 方法中的参数生效。

@Validated
@RestController
@RequestMapping
public class DemoController {

    @GetMapping
    public String get(@Valid User user) {
        return ObjUtils.toJsonStr(user);
    }

    @PostMapping
    public String post(@Validated @RequestBody User user) {
        return ObjUtils.toJsonStr(user);
    }

    @GetMapping("/{age}")
    public String path(@PathVariable("age") @Min(1) Integer age) {
        return age.toString();
    }
}

3.3.2. 增加异常捕获

方法中校验的参数若不是一个实体类,则用上述方式校验,此时抛出的异常为 ConstraintViolationException ,因此新增异常捕获。

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BindException.class)
    public R bindExceptionHandler(BindException e) {
        log.debug("bindExcept: {}", e.getMessage());
        FieldError fieldError = e.getBindingResult().getFieldError();
        assert fieldError != null;
        return R.fail(String.format("%s %s", fieldError.getField(), fieldError.getDefaultMessage()));
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public R validationExceptionHandler(ConstraintViolationException e) {
        log.debug("validationException: {}", e.getMessage());
        ConstraintViolation<?> constraintViolation = e.getConstraintViolations().stream().findFirst().orElse(null);
        assert constraintViolation != null;
        String[] path = constraintViolation.getPropertyPath().toString().split("\\.");
        String field = path[path.length - 1];
        String message = constraintViolation.getMessage();
        return R.fail(String.format("%s %s", field, message));
    }

    @ExceptionHandler(Exception.class)
    public R exceptionHandler(Exception e) {
        log.debug("unknown error: {}", e.getMessage());
        return R.fail();
    }
}

GET 请求 http://127.0.0.1:8080/0open in new window ,响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "age must be greater than or equal to 1"
}

3.4. 嵌套校验

当模型中的属性嵌套了另一个模型,需要对嵌套的模型进行校验呢?

3.4.1. 新增模型 Information

@Data
public class Information implements Serializable {
    private static final long serialVersionUID = -3128793162502773246L;

    @NotEmpty
    private String address;

    private String telPhone;
}

3.4.2. 在模型 User 中增加模型 Information 属性

注意,Information 属性上注解的是 @Valid@Validated 不支持注解在这里。

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@GroupSequenceProvider(UserGroupSequenceProvider.class)
public class User implements Serializable {
    private static final long serialVersionUID = -7242397021777229674L;

    @Null(groups = {ValidAddGroup.class})
    @NotNull(groups = {ValidUpdateGroup.class})
    private Integer id;

    @NotEmpty(groups = {ValidAddGroup.class, ValidUpdateGroup.class})
    private String name;
    @Min(1)
    private Integer age;
    @Email
    private String email;
    @Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
    private String phone;

    @MatchAny(enumClass = PermissionEnum.class, groups = {ValidAddGroup.class, ValidUpdateGroup.class})
    private String permission;

    @MatchAny(strValues = {"language", "math", "english"}, groups = {ValidLearningGroup.class})
    private String learning;

    @MatchAny(strValues = {"screw", "brick", "coding"}, groups = {ValidWorkingGroup.class})
    private String working;

    @Valid
    private Information information;
}

POST 请求 http://127.0.0.1:8080/conditionopen in new window ,请求体:

{
    "age":20,
    "name":"xxx",
    "information":{
        
    }
}

响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "information.address must not be empty"
}

3.5. 分组校验

有的时候同一个字段,在不同场合下的校验方式不同。例如:新增时我们需要校验 id 字段为空,但更新时需要校验 id 字段不为空,该如何做呢?

3.5.1. 定义两个空接口

接口只需要定义,作为标记,无需任何方法实现.

// 新增时的校验组
public interface ValidAddGroup {
}
// 更新时的校验组
public interface ValidUpdateGroup {
}

3.5.2. 在被校验字段的注解中加上分组

现在新增一个属性 id,并添加注解同时标记分组 @Null(groups = {ValidAddGroup.class})@NotNull(groups = {ValidUpdateGroup.class})
表示,当指定了 ValidAddGroup 组时需要校验 @Null;当指定了 ValidUpdateGroup 组时需要校验 @NotNull

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class User implements Serializable {
    private static final long serialVersionUID = -7242397021777229674L;

    @Null(groups = {ValidAddGroup.class})
    @NotNull(groups = {ValidUpdateGroup.class})
    private Integer id;

    @NotEmpty
    private String name;
    @Min(1)
    private Integer age;
    @Email
    private String email;
    @Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
    private String phone;

    @MatchAny(enumClass = PermissionEnum.class)
    private String permission;
}

3.5.3. 新增两个 Handler Method

DemoController 中新增两个方法:add()update()。在 add() 方法中我们指定了 ValidAddGroup 组,在 update() 中我们指定了 ValidUpdateGroup
注意这里要使用 @Validated 注解,而 @Valid 不支持分组校验。

@Validated
@RestController
@RequestMapping
public class DemoController {

    @GetMapping
    public R get(@Valid User user) {
        return R.data(user);
    }

    @PostMapping
    public R post(@Validated @RequestBody User user) {
        return R.data(user);
    }

    @GetMapping("/{age}")
    public R path(@PathVariable("age") @Min(1) Integer age) {
        return R.data(age);
    }

    @PostMapping("/add")
    public R add(@RequestBody @Validated({ValidAddGroup.class}) User user) {
        return R.data(user);
    }

    @PutMapping("/update")
    public R update(@RequestBody @Validated({ValidUpdateGroup.class}) User user) {
        return R.data(user);
    }
}

POST 请求 http://127.0.0.1:8080/addopen in new window ,请求体:

{
    "id":1
}

响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "id must be null"
}

PUT 请求 http://127.0.0.1:8080/updateopen in new window ,请求体:

{
    "permission": "sudo"
}

响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "id must not be null"
}

这时候有些长得帅的朋友可能要问了,model 中那些没有标记分组的注解还生效吗?
我们来试一试,当给 update() 方法请求体中给出 id 的值:
PUT 请求 http://127.0.0.1:8080/updateopen in new window ,请求体:

{
    "id":1,
    "permission": "sudo"
}

响应体:

{
    "code": 0,
    "data": {
        "id": 1,
        "name": null,
        "age": null,
        "email": null,
        "phone": null,
        "permission": "sudo"
    },
    "success": true,
    "message": "success"
}

我们可以看到成功响应了,但我们明明是有校验 name 属性 @NotEmpty 的,而且请求体中的 permission 属性值也不对,却还是通过了校验。这就说明,当我们在方法参数中指定了校验分组时,只有符合分组标记的校验才会生效,不符合或者没标记的一律不生效,因此就需要给 namepermission 注解上指定校验分组。但 namepermission 在新增和更新时都需要校验,此时可以同时添加 ValidAddGroupValidUpdateGroup 两种标记,因为校验注解中的 groups() 属性值是 一个数组 Class<?>[]@Validated 注解中的 value() 属性值同样也是一个数组 Class<?>[] ,因此也可以指定多个分组。

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class User implements Serializable {
    private static final long serialVersionUID = -7242397021777229674L;

    @Null(groups = {ValidAddGroup.class})
    @NotNull(groups = {ValidUpdateGroup.class})
    private Integer id;

    @NotEmpty(groups = {ValidAddGroup.class,ValidUpdateGroup.class})
    private String name;
    @Min(1)
    private Integer age;
    @Email
    private String email;
    @Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
    private String phone;

    @MatchAny(enumClass = PermissionEnum.class,groups = {ValidAddGroup.class,ValidUpdateGroup.class})
    private String permission;
}

PUT 请求 http://127.0.0.1:8080/updateopen in new window ,请求体:

{
    "id":1,
    "permission": "sudo"
}

响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "permission must be one of [admin,staff,boss]"
}

3.6. 逻辑校验

上面的校验方式仍然比较简单,当我们的需要自定义校验逻辑又需要怎么做呢?
hibernate-validator 为我们提供了一个接口 DefaultGroupSequenceProvider,这个接口不是 JSR-303标准的,没有默认实现类。 例如,我们需要判断模型 User 中的 age 属性值,当 age <= 22 时,校验 learning 属性值,否则校验 working 属性值。其实,核心思想就是动态指定校验分组。

3.6.1. User 模型中新增属性 learningworking

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@GroupSequenceProvider(UserGroupSequenceProvider.class)
public class User implements Serializable {
    private static final long serialVersionUID = -7242397021777229674L;

    @Null(groups = {ValidAddGroup.class})
    @NotNull(groups = {ValidUpdateGroup.class})
    private Integer id;

    @NotEmpty(groups = {ValidAddGroup.class, ValidUpdateGroup.class})
    private String name;
    @Min(1)
    private Integer age;
    @Email
    private String email;
    @Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
    private String phone;

    @MatchAny(enumClass = PermissionEnum.class, groups = {ValidAddGroup.class, ValidUpdateGroup.class})
    private String permission;

    @MatchAny(strValues = {"language", "math", "english"}, groups = {ValidLearningGroup.class})
    private String learning;

    @MatchAny(strValues = {"screw", "brick", "coding"}, groups = {ValidWorkingGroup.class})
    private String working;
}

3.6.2. 定义两个校验组 ValidLearningGroupValidWorkingGroup

public interface ValidLearningGroup {
}
public interface ValidWorkingGroup {
}

3.6.3. 定义一个 DefaultGroupSequenceProvider 的实现

在模型 User 中加上 @GroupSequenceProvider(UserGroupSequenceProvider.class)

public class UserGroupSequenceProvider implements DefaultGroupSequenceProvider<User> {
    @Override
    public List<Class<?>> getValidationGroups(User user) {
        List<Class<?>> defaultGroupSequence = new ArrayList<>();
        defaultGroupSequence.add(User.class); // 这一步不能省,否则Default分组都不会执行了,会抛错的
        if (Objects.nonNull(user)) { // 这块判空请务必要做
            Integer age = user.getAge();
            if (age <= 22) {
                defaultGroupSequence.add(ValidLearningGroup.class);
            } else {
                defaultGroupSequence.add(ValidWorkingGroup.class);
            }
        }
        return defaultGroupSequence;
    }
}

3.6.4. 新增一个 Handler Method condition

@Validated
@RestController
@RequestMapping
public class DemoController {

    @GetMapping
    public R get(@Valid User user) {
        return R.data(user);
    }

    @PostMapping
    public R post(@Validated @RequestBody User user) {
        return R.data(user);
    }

    @GetMapping("/{age}")
    public R path(@PathVariable("age") @Min(1) Integer age) {
        return R.data(age);
    }

    @PostMapping("/add")
    public R add(@RequestBody @Validated({ValidAddGroup.class}) User user) {
        return R.data(user);
    }

    @PutMapping("/update")
    public R update(@RequestBody @Validated({ValidUpdateGroup.class}) User user) {
        return R.data(user);
    }

    @PostMapping("/condition")
    public R condition(@Validated @RequestBody User user) {
        return R.data(user);
    }
}

POST 请求 http://127.0.0.1:8080/conditionopen in new window ,请求体:

{
    "age":50,
    "learning":"xxx",
    "working":"xxx"
}

响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "working must be one of [screw,brick,coding]"
}

这个时候那些长得帅的朋友可能又会问了,其他属性值还会校验吗?
我们来实测一下:

POST 请求 http://127.0.0.1:8080/conditionopen in new window ,请求体:

{
    "age":20,
    "name":"",
    "email":"xxx"
}

响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "email must be a well-formed email address"
}

我们可以看到,虽然 age 有值,但没有给 learning 属性,因此不会去校验。而 name 属性值虽然不符合,但由于其注解上有标记分组,而我们 handler 方法中没有指定分组(其实是默认分组),因此也不会校验。剩下的 email 属性,由于其注解上没有标记分组,即为默认分组,因此执行校验。
综上,对于分组校验,我们可以总结以下2点:

  • 当 handler 中没有指定校验分组(即默认分组)时,模型中的没有指定分组(即默认分组)的属性,以及 DefaultGroupSequenceProvider 指定的分组(包含了默认分组)才会被校验;
  • 当 handler 中指定了校验分组时,此时仅指定的分组才会被校验。

因此,当需要分组校验时,需要格外注意。

3.7. 校验排序

若有多个校验分组时,默认校验是无序的。

3.7.1. 新增一个 Handler Method sequence,此时并未指定任何分组

@Validated
@RestController
@RequestMapping
public class DemoController {

    @GetMapping
    public R get(@Valid User user) {
        return R.data(user);
    }

    @PostMapping
    public R post(@Validated @RequestBody User user) {
        return R.data(user);
    }

    @GetMapping("/{age}")
    public R path(@PathVariable("age") @Min(1) Integer age) {
        return R.data(age);
    }

    @PostMapping("/add")
    public R add(@RequestBody @Validated({ValidAddGroup.class}) User user) {
        return R.data(user);
    }

    @PutMapping("/update")
    public R update(@RequestBody @Validated({ValidUpdateGroup.class}) User user) {
        return R.data(user);
    }

    @PostMapping("/condition")
    public R condition(@Validated @RequestBody User user) {
        return R.data(user);
    }

    @GetMapping("/sequence")
    public R sequence(@Validated User user) {
        return R.data(user);
    }
}

GET 请求 http://127.0.0.1:8080/sequence?email=xxx&phone=123open in new window

响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "phone must match \"[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}\""
}

可以看到,虽然在模型 Useremail 定义在前,请求参数也是 email 在前,但还是先校验的 phone
如果我们需要指定分组的校验顺序,先校验 email 呢,可以使用 @GroupSequence 注解,这是 JSR-303 提供的。

3.7.2. 新增两个分组 EmailSeqPhoneSeq

public interface EmailSeq {
}
public interface PhoneSeq {
}

3.7.3. 定义一个分组序列

当指定校验分组为 UserSequence 时,会依次按照 @GroupSequence 指定的顺序执行校验。

@GroupSequence({EmailSeq.class,PhoneSeq.class})
public interface UserSequence {
}

3.7.4. 指定模型 User 中的 emailphone 属性的校验分组,并给 Handler Method sequence 增加 UserSequence 校验组

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@GroupSequenceProvider(UserGroupSequenceProvider.class)
public class User implements Serializable {
    private static final long serialVersionUID = -7242397021777229674L;

    @Null(groups = {ValidAddGroup.class})
    @NotNull(groups = {ValidUpdateGroup.class})
    private Integer id;

    @NotEmpty(groups = {ValidAddGroup.class, ValidUpdateGroup.class})
    private String name;
    @Min(1)
    private Integer age;
    @Email(groups = {EmailSeq.class})
    private String email;
    @Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}", groups = {PhoneSeq.class})
    private String phone;

    @MatchAny(enumClass = PermissionEnum.class, groups = {ValidAddGroup.class, ValidUpdateGroup.class})
    private String permission;

    @MatchAny(strValues = {"language", "math", "english"}, groups = {ValidLearningGroup.class})
    private String learning;

    @MatchAny(strValues = {"screw", "brick", "coding"}, groups = {ValidWorkingGroup.class})
    private String working;

    @Valid
    private Information information;
}
@Validated
@RestController
@RequestMapping
public class DemoController {

    @GetMapping
    public R get(@Valid User user) {
        return R.data(user);
    }

    @PostMapping
    public R post(@Validated @RequestBody User user) {
        return R.data(user);
    }

    @GetMapping("/{age}")
    public R path(@PathVariable("age") @Min(1) Integer age) {
        return R.data(age);
    }

    @PostMapping("/add")
    public R add(@RequestBody @Validated({ValidAddGroup.class}) User user) {
        return R.data(user);
    }

    @PutMapping("/update")
    public R update(@RequestBody @Validated({ValidUpdateGroup.class}) User user) {
        return R.data(user);
    }

    @PostMapping("/condition")
    public R condition(@Validated @RequestBody User user) {
        return R.data(user);
    }

    @GetMapping("/sequence")
    public R sequence(@Validated(UserSequence.class) User user) {
        return R.data(user);
    }
}

GET 请求 http://127.0.0.1:8080/sequence?phone=123&email=xxxopen in new window

响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "email must be a well-formed email address"
}

可以看到,email 先被校验。

4. 扩展

4.1. 接口参数多态+参数校验

若有一个 Handler Method ,它的参数可能有多种对象,每种对象的校验方式又不相同,但是参数只定义一个对象接收,该如何做呢?
有两种方式实现:

  • 方法一:将所有的子类属性定义在一个对象中,给属性校验添加分组,然后结合逻辑校验来动态指定不同的校验分组。这种方式的缺点在于,将所有子类对象属性揉在一起可能会很多,再添加上一堆校验分组,校验逻辑就很不直观,不便于维护。
  • 方法二:实现方法请求参数的多态。定义一个父类,让所有子类都继承它,然后根据一个属性值来判断反序列化为哪种子类,然后在子类中完成各自的属性校验。这种方式就非常直观。

这里我将介绍方法二的实现步骤。

4.1.1. 定义一个父类 Person,两个子类 TeacherStudent

父类中重写了子类中的所有方法,主要是为了能够不用强转为子类也可以获取子类的值,当然你可以不实现这些方法,使用的时候强转为目标类型即可。
说明:注解 @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", visible = true, defaultImpl = Person.class) 其中 property = "type" 表示判断的属性为 type,注意 defaultImpl = Person.class,这个如果不指定会反序列化失败,直接抛异常。而指定后表示当 type 不存在或者不是给定的值时,默认转为 Person 类型,然后就可以校验对象中的 type 属性值,@NotEmpty@MatchAny 会生效。
注解 @JsonSubTypes 中的属性表示当 type值为 teacher 时实例化为 Teacher 类型,当type值为 student 时实例化为 Student 类型。

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", visible = true, defaultImpl = Person.class)
@JsonSubTypes({
        @JsonSubTypes.Type(value = Teacher.class, name = "teacher"),
        @JsonSubTypes.Type(value = Student.class, name = "student")
})
public class Person {

    @NotEmpty
    @MatchAny(strValues = {"teacher", "student"})
    private String type;

    public Integer getStuId() {
        return null;
    }

    public Integer getTeaId() {
        return null;
    }

    public String getName() {
        return null;
    }

    public Integer getAge() {
        return null;
    }

    public String getClassNo() {
        return null;
    }
}
@EqualsAndHashCode(callSuper = true)
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Teacher extends Person {

    /**
     * 工号
     */
    private Integer teaId;

    private String name;

    @Min(28)
    private Integer age;
}
@EqualsAndHashCode(callSuper = true)
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Student extends Person {

    /**
     * 学号
     */
    private Integer stuId;

    private String name;

    @Min(7)
    private Integer age;

    /**
     * 班号
     */
    private String classNo;
}

4.1.2. 新增一个 Handler Method extend

实测,GETPOST 请求都可以完成类型映射,但只有为 POST 请求时才会校验子类中的属性。

@RestController
@RequestMapping("/extend")
public class ExtendController {

    @PostMapping
    public R extend(@RequestBody @Validated Person person) {
        return R.data(person);
    }
}

POST 请求 http://127.0.0.1:8080/extendopen in new window ,请求体:

{
    "type":"teacher",
    "age":15
}

响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "age must be greater than or equal to 28"
}

4.2. 请求参数解析+参数校验

最近在把 Node.js 项目重构为 Java 项目,其中一个接口为 GET https://host:port/uri?list=["aa","bb"],Node.js 的框架可以将 list 参数解析为数组类型;而在 Java 中,即使将 list 参数定义为集合或数组类型,Srpingmvc 框架默认还是会把 ["aa","bb"] 整个解析为集合中的一个元素。参数未能以预期的方式接收,亦无法通过参数校验。
我们知道 Springmvc 可以将 GET https://host:port/uri?list=aa,bb 形式的参数解析为集合或数组,但 Node.js 的框架无法解析这种传参方式。因为是重构项目,需要兼容 Node.js ,故不能修改传参方式。而一时间没有找到可以让 Springmvc 正确解析参数的方法,因此决定实现自定义参数解析器。

4.2.1. 自定义参数解析注解

用来标记需要解析的参数。

@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ArrayResolver {
}

4.2.2. 自定义参数解析器

实现 HandlerMethodArgumentResolver 接口,自定义参数解析器

public class ArrayHandlerMethodArgumentResolver extends AbstractCustomizeResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(ArrayResolver.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        Object obj = BeanUtils.instantiateClass(parameter.getParameterType());
        BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(obj);
        Iterator<String> parameterNames = webRequest.getParameterNames();
        while (parameterNames.hasNext()) {
            // name 从 request 中获取,可能是下划线格式
            String name = parameterNames.next();
            // 下划线转驼峰
            String camelName = underLineToCamel(name);
            Class<?> propertyType = wrapper.getPropertyType(camelName);
            // 传参在对象中不存在时
            if (Objects.isNull(propertyType)) {
                continue;
            }
            // 属性值(从请求中获取)
            Object o = webRequest.getParameter(name);
            if (Objects.nonNull(o)) {
                // 数组
                if (propertyType.isArray()) {
                    wrapper.setPropertyValue(camelName, value2Array(o));
                } else if (Collection.class.isAssignableFrom(propertyType)) {// 集合
                    wrapper.setPropertyValue(camelName, array2Collection(propertyType, value2Array(o)));
                } else {//其他类型
                    wrapper.setPropertyValue(camelName, o);
                }
            }
        }
        // 参数校验
        valid(parameter, mavContainer, webRequest, binderFactory, obj);
        return obj;
    }

    private Object[] value2Array(Object o) {
        assert o != null;
        // ["a","b","c"] 格式
        if (StringUtils.containsAny(o.toString(), "[", "]")) {
            return JSON.parseArray(o.toString()).toArray();
        } else {
            // a,b,c 格式
            return JSON.parseArray(Arrays.toString(o.toString().split(","))).toArray();
        }
    }

    private Collection<Object> array2Collection(Class<?> propertyType, Object[] array) {
        Collection<Object> collection = CollectionFactory.createCollection(propertyType, array.length);
        Collections.addAll(collection, array);
        return collection;
    }
}

4.2.3. 将参数解析器添加到 Spring 容器中

@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new ArrayHandlerMethodArgumentResolver());
    }
}

4.2.4. 在模型 Teacher 中新增 lessons 属性

@EqualsAndHashCode(callSuper = true)
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Teacher extends Person {

    /**
     * 工号
     */
    private Integer teaId;

    private String name;

    @Min(28)
    private Integer age;

    /**
     * 教授课程
     */
    @MatchAny(enumClass = LessonEnum.class)
    private List<String> lessons;
}

4.2.5. 新增 Handler Method arrayResolve

参数前标记 @ArrayResolver 注解。

@RestController
@RequestMapping("/extend")
public class ExtendController {

    @PostMapping
    public R extend(@RequestBody @Validated Person person) {
        return R.data(person);
    }

    @GetMapping("/arrayResolve")
    public R arrayResolve(@ArrayResolver @Validated Teacher teacher) {
        return R.data(teacher);
    }
}

GET 请求 http://127.0.0.1:8080/extend/arrayResolve?lessons=["math","xxx"]open in new window

响应体:

{
    "code": 500,
    "data": null,
    "success": false,
    "message": "lessons must be one of [language,math,english]"
}

可以看到,参数被正确解析且被校验。


Tip:本文完整示例代码已上传至 Giteeopen in new window