💁 作者:小瓦匠
💖 欢迎关注我的个人公众号:小瓦匠学编程。微信号: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项目参数校验最佳实践,真香
发布评论