Java项目参数校验最佳实践,真香

💁 作者:小瓦匠
💖 欢迎关注我的个人公众号:小瓦匠学编程。微信号:xiaowajiangxbc
📢 本中涉及到的所有代码资源,可以在公众号中获取,关注并回复:源码下载
👉 本文涉及的源码资源在 coding-002-validation 项目中


在系统开发中,后端接口的参数校验是我们必须要考虑的事情,可是如何才能优雅、简洁地实现参数校验呢?本文将围绕这个问题进行深入探讨。

文章内容:

  • JSR-303规范是什么
  • 参数校验的快递入门实践与统一异常处理
  • 分组校验场景
  • 嵌套校验
  • 集合校验
  • 自定义校验
  • 快速失败机制

代码层面的参数校验

在项目中你是不是经常看到下面这样的代码逻辑。

public String checkParams(Student student) {
    if (StringUtils.isEmpty(student.getName())) {
        return "学生名称不能为空";
    }
    if (student.getName().length() > 10) {
        return "学生名称长度不能超过10位";
    }
    if (Objects.isNull(student.getAge())) {
        return "学生年龄不能为空";
    }
    // 省略其他校验……
    return "ok";
}

这是最简单的参数校验方式。首先来说这样进行参数校验并没有错误,但是这样做会导致方法冗长,代码不够优雅,代码编写也比较繁琐。那么有没有更好的方法让我们的参数校验更简洁更优雅呢?

JSR-303规范

JDK1.6 中推出了一种规范:JSR-303,JSR 是 Java Specification Requests 的缩写,意思是 Java 规范提案,又叫做 Bean Validation。它是 Java 为 Bean 数据合法性校验提供的标准框架。而且我们常用的 Hibernate Validator 也是 Bean Validation 的参考实现。

Spring 框架也支持 JSR-303 规范,这为我们在项目中对接口做参数校验提供了便利性。

参数校验约束注解

在 JSR-303 规范中定义了很多校验注解,比如:
在这里插入图片描述
Hibernate Validator 中提供的参数校验注解,这里也列举了一部分,比如:
在这里插入图片描述

快速入门

了解了上面这些基础后,在实际项目中对接口做参数检验时,我们只需要进行如下操作。

引入依赖

项目环境基于 Spring Boot 2.3.2.RELEASE 构建。

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

定义 Bean 的校验约束

通过校验约束注解,我们可以方便的为各个接口的请求参数定义约束条件。比如我们可以在接口入参实体中定义如下校验约束。

@Data
public class StudentDTO implements Serializable {

    // 姓名
    @NotBlank(message = "姓名必须填写")
    private String name;
    // 电话
    @NotBlank(message = "电话必须填写")
    @Pattern(regexp = "^\\d{3}-\\d{8}$", message = "电话格式不正确")
    private String phone;
    // 头像
    @NotBlank(message = "头像必须上传")
    @URL(message = "头像地址不正确")
    private String photo;

}

为对象属性添加了校验注解,并通过 message 自定义了错误提示。这些并不能实现参数校验,我们还需要开启校验功能。

接口参数校验

校验 Bean 对象入参

在需要校验的接口参数Bean对象前添加 @Valid / @Validated 注解,来开启参数校验。如果想要获取失败结果,则可以在参数实体后添加一个 BindingResult 对象,BindingResult 对象封装了参数校验失败的结果。

校验非Bean对象入参

对于 @RequestParam/@PathVariable 注解修饰的非 Bean 对象参数,我们应该如何进行参数校验呢?

首先,必须在 Controller 类上标注 @Validated 注解;

然后,在接口入参前声明校验约束注解(如 @Min、@URL 等)。

针对上面提到的参数校验情况,我在下面给出了几种代码示例。

@Slf4j
@RestController
@Validated
public class BasicValidController {
    /**
     * 请求体中发送 JSON 数据
     * 校验失败后,抛出 MethodArgumentNotValidException 异常
     */
    @PostMapping("basic/valid/student/saveStudentWithJson")
    public R saveStudentWithJson(@Validated @RequestBody StudentDTO stu) {
        log.info("保存学员信息,入参:{}", JSON.toJSONString(stu));
        // 业务逻辑
        return R.ok();
    }
    /**
     * 请求体中发送 JSON 数据
     * 使用 BindingResult 对象可以获取校验失败的结果
     */
    @PostMapping("basic/valid/student/updateStudentWithJson")
    public R updateStudentWithJson(@Validated @RequestBody StudentDTO stu, BindingResult result) {
        log.info("修改学员信息,入参:{}", JSON.toJSONString(stu));
        if (result.hasErrors()) {
            return R.error(400, "参数校验异常").put("data", ErrorResultUtil.getErrorMap(result));
        }
        // 业务逻辑
        return R.ok();
    }
    /**
     * 请求体中发送 form-data 数据
     * 校验失败后,抛出 BindException 异常
     */
    @PostMapping("basic/valid/student/saveStudentWithForm")
    public R saveStudentWithForm(@Valid StudentDTO stu) {
        log.info("保存学员信息,入参:{}", JSON.toJSONString(stu));
        // 业务逻辑
        return R.ok();
    }
    /**
     * URL Query传参
     * 校验失败后,抛出 ConstraintViolationException 异常
     */
    @PostMapping("basic/valid/student/update/photo")
    public R updatePhoto(@RequestParam Long id, @URL @RequestParam String photo) {
        log.info("修改学员头像,入参:{}, {}", id, photo);
        // 业务逻辑
        return R.ok();
    }
    /**
     * Path Info传参
     * 校验失败后,抛出 ConstraintViolationException 异常
     */
    @PostMapping("basic/valid/student/info/{id}")
    public R updatePhoto(@Min(10000) @PathVariable Long id) {
        log.info("查询学员信息,入参:{}", id);
        // 业务逻辑
        return R.ok();
    }
}

public class ErrorResultUtil {
    /**
     * 获取校验失败的结果
     */
  public static Map<String, String> getErrorMap(BindingResult result) {
        return result.getFieldErrors().stream().collect(
                Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage, (k1, k2) -> k1)
        );
    }
}

执行上面的接口时,你会发现如果校验失败时,会抛出 MethodArgumentNotValidException / BindException / ConstraintViolationException 这些异常。当然我们可以通过 BindingResult 对象,来绑定参数校验失败的结果。

统一异常处理

通过上面的介绍,我们已经知道了校验失败时程序抛出的异常类型,那么接下来我们就借助统一异常处理机制,来对参数校验异常做统一拦截处理。这样的话我们就不需要在每一个参数校验接口中使用 BindingResult 对象来绑定参数校验失败结果了。

@ControllerAdvice

作用于类上,用于标识这个类是用于处理全局异常的。另外,我们也可以使用 @RestControllerAdvice,其实它是 @ControllerAdvice和 @ResponesBody 的合体,可以返回 JSON 格式的数据。

@ExceptionHandler

作用于方法上,用于对拦截的异常类型进行处理。value 属性用于指定具体的拦截异常类型,如果有多个 ExceptionHandler 存在,则需要指定不同的 value 类型,由于异常类拥有继承关系,所以 ExceptionHandler 会首先执行在继承树中靠前的异常类型。

基于 Spring 注解的统一异常处理代码如下,大家也可以根据自己的业务需求增加或调整。

@Slf4j
@RestControllerAdvice
public class ExceptionControllerAdvice {
    /**
     * 参数校验异常统一处理,拦截 MethodArgumentNotValidException 异常
     */
    @ExceptionHandler(value = {MethodArgumentNotValidException.class})
    public R handleValidException(MethodArgumentNotValidException e) {
        log.error("数据校验异常,{},异常类型:{}", e.getMessage(), e.getClass());
        BindingResult bindingResult = e.getBindingResult();
        Map<String, String> errorMap = ErrorResultUtil.getErrorMap(bindingResult);
        return R.error(400,"参数校验失败").put("data", errorMap);
    }
    /**
     * 参数绑定异常统一处理,拦截 BindException 异常
     */
    @ExceptionHandler(value = {BindException.class})
    public R handleValidException(BindException e) {
        log.error("数据校验异常,{},异常类型:{}", e.getMessage(), e.getClass());
        BindingResult bindingResult = e.getBindingResult();
        Map<String, String> errorMap = ErrorResultUtil.getErrorMap(bindingResult);
        return R.error(400,"参数校验失败").put("data", errorMap);
    }
    /**
     * 约束校验异常统一处理
     */
    @ExceptionHandler(value = {ConstraintViolationException.class})
    public R handleValidException(ConstraintViolationException e) {
        log.error("数据校验异常,{},异常类型:{}", e.getMessage(), e.getClass());
        List<String> violations = e.getConstraintViolations().stream()
                .map(ConstraintViolation::getMessage).collect(Collectors.toList());
        String error = violations.get(0);
        return R.error(400, error);
    }
    /**
     * 未知异常处理
     */
    @ExceptionHandler(value = Throwable.class)
    public R handleException(Throwable t) {
        log.error("未知异常,{},异常类型:{}", t.getMessage(), t.getClass());
        return R.error("未知异常");
    }
}

统一异常处理是站在整个项目全局的角度来统一处理异常,它的实现方式非常简单。

@Valid与@Validated区别

前面说到我们可以使用 @Valid 与 @Validated 来开启参数校验,那么这两者有什么区别吗?

  1. 它们所属的包不同:@Validated 属于 Spring,而 @Valid 属于 javax。
    @Validated :org.springframework.validation.annotation.Validated
    @Valid:javax.validation.Valid

  2. @Validated 支持分组功能,在校验参数时,可以根据不同的分组采用不同的校验机制。默认验证没有分组的验证属性。

  3. 标注位置不同
    @Validated 可以标注在:类, 方法, 参数
    @Valid 可以标注在:方法, 字段属性, 构造函数, 参数等

  4. @Valid 支持嵌套校验,而 @Validated 不支持嵌套校验。

分组校验

在实际项目开发中,可能多个接口需要使用同一个实体类来完成参数接收,而不同接口的校验规则很可能是不一样的。因此,spring-validation 提供了分组校验的功能,专门用来解决这类问题。具体的实现方案主要分为下面几个方面。

定义分组场景注解

定义分组注解,比如:AddGroup、UpdateGroup等

// 新增分组
public interface AddGroup { }
// 修改分组
public interface UpdateGroup { }

标注校验场景

通过 groups 属性在约束注解上声明适用的分组。

@Data
public class StudentDTO implements Serializable {
    
    // id主键
    @NotNull(message = "修改操作必须指定id", groups = { UpdateGroup.class })
    @Null(message = "新增操作不能指定id", groups = {AddGroup.class })
    private Long id;
    // 姓名
    @NotBlank(message = "姓名必须填写", groups = { AddGroup.class, UpdateGroup.class })
    private String name;
    // 电话
    @NotBlank(message = "电话必须填写", groups = { AddGroup.class })
    @Pattern(regexp = "^\\d{3}-\\d{8}$", message = "电话格式不正确", groups = { AddGroup.class, UpdateGroup.class })
    private String phone;
    // 头像
    @NotBlank(message = "头像必须上传", groups = { AddGroup.class })
    @URL(message = "头像地址不正确", groups = { AddGroup.class, UpdateGroup.class })
    private String photo;
    
}

指定校验场景

在 Controller 接口中我们可以使用分组注解来指定分组场景,所以我们可以这样写:

@Slf4j
@RestController
public class GroupValidController {
    /**
     * 新增操作,通过 AddGroup 来指定分组场景
     */
    @PostMapping("group/valid/student/save")
    public R save(@Validated(value = AddGroup.class) @RequestBody StudentDTO stu) {
        log.info("保存学员信息,入参:{}", JSON.toJSONString(stu));
        // 业务逻辑
        return R.ok();
    }
    /**
     * 修改操作,通过 UpdateGroup 来指定分组场景
     */
    @PostMapping("group/valid/student/update")
    public R update(@Validated(value = UpdateGroup.class) @RequestBody StudentDTO stu) {
        log.info("修改学员信息,入参:{}", JSON.toJSONString(stu));
        // 业务逻辑
        return R.ok();
    }
}

注意:默认没有执行分组的校验注解,在分组校验情况下不生效。

嵌套校验

在实际业务场景中,有可能某个字段也是一个对象,对于这种情况,我们可以使用嵌套校验。

使用嵌套校验时,我们必须在对应的对象字段上标记@Valid注解。例如,在 Student 对象中有一个 Course 对象属性。

@Data
public class StudentDTO implements Serializable {
    // 姓名
    @NotBlank(message = "姓名必须填写")
    private String name;
    // 课程
    @Valid
    private List<Course> course;
    @Data
    public static class Course {
        @NotBlank(message = "课程编码不能为空")
        private String code;
        @NotBlank(message = "课程名称不能为空")
        @Length(min = 2, max = 10)
        private String name;
    }
}

嵌套校验可以结合分组校验一起使用。并且嵌套集合校验会对集合里面的每一项都进行校验。

集合校验

如果接口请求体直接传递 JSON 数组给后台,并希望对数组中的每一项都进行参数校验。此时,如果我们直接使用 java.util.Collection 下的 List 或者 Set 来接收数据,参数校验并不会生效。在这种情况下,我们需要使用自定义的 List 集合来接收参数,即包装 List 类型,并声明 @Valid 注解。

public class StudentList<E> implements List<E> {
    @Delegate // @Delegate 为标记属性生成委托方法(lombok 1.18.6 版本以上)
    @Valid
    public List<E> list = new ArrayList<>();
    // 一定要记得重写toString方法
    @Override
    public String toString() {
        return list.toString();
    }
}

如果,我们需要在一次请求中保存多个 StudentDTO 对象,我们在 Controller 层可以这么写:

@Slf4j
@RestController
public class CollectionValidController {
    /**
     * 请求体中发送 JSON 数组
     */
    @PostMapping("collection/valid/saveList")
    public R saveList(@RequestBody @Validated StudentList<StudentDTO> list) {
        log.info("保存学员信息,入参:{}", JSON.toJSONString(list));
        // 业务逻辑处理
        return R.ok();
    }
}

还没有结束,完成上面的工作后,我们还需要在统一异常处理器中添加 DataBinder 数据绑定器。这样我们才能接收到校验失败时抛出的 MethodArgumentNotValidException 异常。具体代码如下:

@RestControllerAdvice
public class ExceptionHandler {
    /**
     * DataBinder 数据绑定器
     * @param dataBinder
     */
    @InitBinder
    private void activateDirectFieldAccess(DataBinder dataBinder) {
        dataBinder.initDirectFieldAccess();
    }
}

只有配置了 DataBinder 数据绑定器以后,我们才能在参数校验失败时接收到 MethodArgumentNotValidException 异常。然后再通过统一异常处理器来完成异常结果输出。

自定义校验

业务需求总是比框架提供的这些简单校验要复杂的多,所以我们还需要掌握自定义校验注解,来满足多变的业务需求。

自定义需求

实现一个自定义校验注解,该注解修饰的字段只能接收注解中列举的数据值。

自定义约束注解

参照官方约束注解的写法,自定义约束注解的实现如下:

@Documented
@Constraint(validatedBy = { ListValueConstraintValidator.class }) // 指定校验器,这里不指定时,就需要在初始化时指定
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
public @interface ListValue {
    // 默认的提示内容
    String message() default "必须提交指定的值哦";
    Class<?>[] groups() default { };
    Class<? extends Payload>[] payload() default { };
    int[] values() default { };
}

在约束注解中我们需要通过 @Constraint(validatedBy = {}) 来指定校验器。

编写约束校验器

约束校验器需要实现 ConstraintValidator 接口

public class ListValueConstraintValidator implements ConstraintValidator<ListValue, Integer> {
    private Set<Integer> set = new HashSet<>();
    /**
     * 初始化方法
     */
    @Override
    public void initialize(ListValue constraintAnnotation) {
        int[] values = constraintAnnotation.values();
        for (int val : values) {
            set.add(val);
        }
    }
    /**
     * 判断是否校验成功
     */
    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext context) {
        return set.contains(value);
    }
}

这样我们就可以使用 @ListValue 进行参数校验了!

快速失败(Fail Fast)

Spring Validation 默认会校验完所有字段,然后才抛出异常。但通常情况下我们希望遇到校验异常就立即返回,此时可以通过一些简单的配置,开启 Fali Fast 模式,一旦校验失败就立即返回。

@Configuration
public class ValidatorConfiguration {
    @Bean
    public Validator validator() {
        ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
                .configure()
                // 快速失败模式
                .failFast(true)
                .buildValidatorFactory();
        return validatorFactory.getValidator();
    }
}

在这里插入图片描述
📌 学习 积累 沉淀 分享
💖 欢迎关注我的个人公众号:小瓦匠学编程! 微信号:xiaowajiangxbc
🔎 扫描二维码或微信搜索 “小瓦匠学编程” 即可关注。

(本文完)

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小瓦匠学编程

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值