admin管理员组

文章数量:1794759

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

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 来开启参数校验,那么这两者有什么区别吗?

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

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

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

  • @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 🔎 扫描二维码或微信搜索 “小瓦匠学编程” 即可关注。

    (本文完)

    本文标签: 参数项目java