【Java】JSR 303 数据校验

JSR 303 数据校验

https://www.jianshu.com/p/48725b7328c9

JSR 是 Java Specification Requests 的缩写,即 Java 规范提案。简单的理解为 JSR 是一种 Java 标准,存在各种各样的 JSR,JSR 303 就是数据检验的一个标准。JSR-303 是 Java EE 6 中的一项子规范,叫做 Bean Validation,官方参考实现是hibernate Validator。此实现与 Hibernate ORM 没有任何关系。 JSR 303 用于对 Java Bean 中的字段的值进行验证。 Spring MVC 3.x 之中也大力支持 JSR-303,可以在控制器中对表单提交的数据方便地验证。

Spring Boot 2.2 版本之前的场景启动器依赖中默认内置了该依赖(2.3之后的版本失效),如果需要单独引入依赖,可以使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>5.4.0.Final</version>
</dependency>
<dependency>
<groupId>javax.el</groupId>
<artifactId>el-api</artifactId>
<version>2.2</version>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>javax.el</artifactId>
<version>2.2.4</version>
</dependency>

相关注解

Bean Validation 中内置的 constraint:

Constraint 详细信息
@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) 被注释的元素必须符合指定的正则表达式

Hibernate Validator 附加的 constraint:

Constraint 详细信息
@Email 被注释的元素必须是电子邮箱地址
@Length 被注释的字符串的大小必须在指定的范围内
@NotEmpty 被注释的字符串的必须非空
@Range 被注释的元素必须在合适的范围内

使用示例

  1. 首先在实体类的相关属性上添加相应注解:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* 品牌
*
* @author yuyun.zhao
* @email im.yuyunzhao@gmail.com
* @date 2021-12-21 16:22:28
*/
@Data
@TableName("pms_brand")
public class BrandEntity implements Serializable {
private static final long serialVersionUID = 1L;

/**
* 品牌id
*/
@TableId
private Long brandId;
/**
* 品牌名,校验:至少得是一个非空格字符
*/
@NotBlank(message = "品牌名必须提交")
private String name;
/**
* 品牌logo地址
*/
@NotEmpty
@URL(message = "log必须是一个合法的url地址")
private String logo;
/**
* 介绍
*/
private String descript;
/**
* 显示状态[0-不显示;1-显示]
*/
private Integer showStatus;
/**
* 检索首字母
*/
@NotEmpty
@Pattern(regexp = "^[a-zA-Z]$", message = "检索首字母必须是一个字母")
private String firstLetter;
/**
* 排序
*/
@NotNull
@Min(value = 0, message = "排序必须大于等于0")
private Integer sort;

}
  1. 在 Controller 层相应实体类参数上开启校验 @Valid
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 保存,开启校验注解,校验前端发来的数据是否合规,bindingResultL获取到校验结果
*/
@RequestMapping("/save")
public R save(@Valid @RequestBody BrandEntity brand, BindingResult result){
if (result.hasErrors()) {
Map<String, String> map = new HashMap<>();
result.getFieldErrors().forEach(item -> map.put(item.getField(), item.getDefaultMessage()));
return R.error(400, "提交的数据不合法").put("data", map);
}
// 如果合法再向服务器保存
brandService.save(brand);
return R.ok();
}

参数中的 BindingResult 用于绑定校验异常的信息。一旦解析时发现某个参数不合法,就会将相应信息(字段名、注解中配置的 message 等)包装到该对象中,通过调用该对象的相关方法即可得到字段与信息。

如果没有在参数列表中写 BindingResult,则异常就会向外抛出,不会进入到业务方法内。此时就可以写一个异常处理的切面类,统一处理这些抛出的校验异常。

统一异常处理

创建统一处理异常的切面类 MallExceptionControllerAdvice,使用 Spring MVC 提供的 @ControllerAdvice 注解并指定要扫描的包,这样这些 Controller 在抛出异常时就会被该切面类拦截到,并做相应处理,从而实现业务和异常处理的解耦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* 集中处理所有异常,AOP的思想,拦截Controller层抛出的所有异常,无侵入的实现统一异常处理
* @author yuyun zhao
* @date 2021/12/28 10:47
*/
@Slf4j
@RestControllerAdvice(basePackages = "com.zhao.yunmall.product.controller")
// 效果等同于 @ResponseBody + @ControllerAdvice
public class MallExceptionControllerAdvice {

/**
* 统一处理异常
* @param e:Controller层抛出的MethodArgumentNotValidException异常会被当前方法捕获,处理完毕后发送回前端
* @return R.error()
*/
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public R handleValidException(MethodArgumentNotValidException e) {
log.error("数据校验出现问题{},异常类型{}", e.getMessage(), e.getClass());
// 从异常类对象中获取到异常的字段和信息
BindingResult bindingResult = e.getBindingResult();
// 将异常的字段以及其默认展示的信息封装到map中
Map<String, String> map = new HashMap<>();
bindingResult.getFieldErrors().forEach(item -> map.put(item.getField(), item.getDefaultMessage()));
// 以JSON形式返回
return R.error(BizCodeEnum.VALID_EXCEPTION.getCode(), BizCodeEnum.VALID_EXCEPTION.getMsg()).put("data", map);
}

/**
* 拦截Controller层抛出的其他异常(优先级最低,用于兜底拦截)
* @param throwable:拦截的异常
* @return R.error()
*/
@ExceptionHandler(value = Throwable.class)
public R handlException(Throwable throwable) {
return R.error(BizCodeEnum.UNKNOWN_EXCEPTION.getCode(), BizCodeEnum.UNKNOWN_EXCEPTION.getMsg());
}
}

这样 Controller 层的业务就不需要修改,实现了无侵入的异常处理:

1
2
3
4
5
6
7
8
9
10
/**
* 保存。开启校验注解,校验前端发来的数据是否合法,如果发生异常(不合法),
* 将直接被MallExceptionControllerAdvice拦截,其处理完后直接返回给前端
*/
@RequestMapping("/save")
public R save(@Valid @RequestBody BrandEntity brand){
// 如果合法再向服务器保存。不合法时抛出的异常直接被MallExceptionControllerAdvice拦截,不会进入到该方法内
brandService.save(brand);
return R.ok();
}

其中,错误码和错误信息定义类用于同一规定错误码信息,在 mall-common 包下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.zhao.common.exception;

/**
* 错误码和错误信息定义类
* 1. 错误码定义规则为5为数字
* 2. 前两位表示业务场景,最后三位表示错误码。例如:100001。10:通用 001:系统未知异常
* 3. 维护错误码后需要维护错误描述,将他们定义为枚举形式
* 错误码列表:
* 10: 通用
* 001:参数格式校验
* 11: 商品
* 12: 订单
* 13: 购物车
* 14: 物流
* @author yuyun zhao
* @date 2021/12/28 11:09
*/
public enum BizCodeEnum {

UNKNOWN_EXCEPTION(10000,"系统未知异常"),
VALID_EXCEPTION(10001,"参数格式校验失败");

private final int code;
private final String msg;

BizCodeEnum(int code,String msg){
this.code = code;
this.msg = msg;
}

public int getCode() {
return code;
}

public String getMsg() {
return msg;
}
}

JSR 303 分组校验

当需要多场景复杂校验时(例如新增时 id 字段必须为空,修改时不能为空),就要给校验注解标注什么情况需要进行校验,也就是分组校验。

  1. 添加分组后的实体类注解:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.zhao.yunmall.product.entity;

import com.zhao.common.valid.AddGroup;
import com.zhao.common.valid.UpdateGroup;
import com.zhao.common.valid.UpdateStatusGroup;

// import ...

/**
* 品牌
*
* @author yuyun.zhao
* @email im.yuyunzhao@gmail.com
* @date 2021-12-21 16:22:28
*/
@Data
@TableName("pms_brand")
public class BrandEntity implements Serializable {
private static final long serialVersionUID = 1L;

/**
* 品牌。使用分组校验,分情况决定是否为null。新增时必须为空;修改时不能为空
*/
@TableId
@NotNull(message = "修改时必须指定品牌id", groups = {UpdateGroup.class})
@Null(message = "新增时不能指定品牌id", groups = {AddGroup.class})
private Long brandId;
/**
* 品牌名。任何情况都要校验:至少得是一个非空格字符
*/
@NotBlank(message = "品牌名必须提交", groups = {AddGroup.class, UpdateGroup.class})
private String name;
/**
* 品牌logo地址。新增时需要校验:不能为空且必须是合法URL;修改时可以为空,如果不为空时要得是合法URL
*/
@NotEmpty(groups = {AddGroup.class})
@URL(message = "log必须是一个合法的url地址", groups = {AddGroup.class,UpdateGroup.class})
private String logo;
/**
* 介绍
*/
private String descript;
/**
* 显示状态[0-不显示;1-显示]
* 新增时不能为空,修改状态时不能为空。普通修改可以为空
*/
@NotNull(groups = {AddGroup.class, UpdateStatusGroup.class})
private Integer showStatus;
/**
* 检索首字母。新增时不能为空,修改时可以为空;如果不为空时必须得符合正则
*/
@NotEmpty(groups = {AddGroup.class})
@Pattern(regexp = "^[a-zA-Z]$", message = "检索首字母必须是一个字母", groups = {AddGroup.class,UpdateGroup.class})
private String firstLetter;
/**
* 排序。新增时不能为空,修改时可以为空;如果不为空则必须要满足大于0
*/
@NotNull(groups = {AddGroup.class})
@Min(value = 0, message = "排序必须大于等于0", groups = {AddGroup.class,UpdateGroup.class})
private Integer sort;

}

其中,groups = {AddGroup.class,UpdateGroup.class} 中的接口在 mall-common 模块的 valid 包下定义:

image-20211228135908575

  1. 在 Controller 上添加 @Validated({AddGroup.class}) 注解(该注解由 Spring 提供,非 Java 标准 JSR),并在其中指定当前请求的分组:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 保存。开启校验注解,校验前端发来的数据是否合法,如果发生异常(不合法),
* 将直接被MallExceptionControllerAdvice拦截,其处理完后直接返回给前端
* 所属分组:{AddGroup.class}
*/
@RequestMapping("/save")
public R save(@Validated({AddGroup.class}) @RequestBody BrandEntity brand){
// 如果合法再向服务器保存
brandService.save(brand);
return R.ok();
}

/**
* 修改状态信息。
* 所属分组:{UpdateStatusGroup.class}
*/
@RequestMapping("/update/status")
public R updateStatus(@Validated({UpdateStatusGroup.class}) @RequestBody BrandEntity brand){
brandService.updateById(brand);
return R.ok();
}

/**
* 修改信息
* 所属分组:{UpdateGroup.class}
*/
@RequestMapping("/update")
public R update(@Validated({UpdateGroup.class}) @RequestBody BrandEntity brand){
brandService.updateById(brand);
return R.ok();
}

注意:如果一些字段的注解没有指定分组,则在分组校验情况下其校验逻辑不生效。这些字段只会在 @Validated() 内容为空时生效,一旦 @Validated() 中带了分组信息,那些没有指定分组的字段就会失效。

  1. 测试,发送不合法的请求,返回错误信息:
1
2
3
4
5
6
7
8
9
10
{
"msg": "参数格式校验失败",
"code": 10001,
"data": {
"brandId": "新增时不能指定品牌id",
"showStatus": "必须提交指定的值",
"sort": "排序必须大于等于0",
"firstLetter": "检索首字母必须是一个字母"
}
}

自定义校验

  1. 编写一个自定义的校验器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ListValueConstraintValidator implements ConstraintValidator<ListValue, Integer> {
// 存储合法的值
private Set<Integer> set = new HashSet<>();

/**
* 初始化方法。初始化时,将注解中标注的值加入到 set 中,后续就可以从该 set 中判断前端传来的数据是否在该范围内
*/
@Override
public void initialize(ListValue constraintAnnotation) {
int[] vals = constraintAnnotation.vals();
for(int val : vals) {
// 将结果添加到set集合
set.add(val);
}
}

/**
* 判断效验是否成功
* @param value 需要效验的值
* @param context 上下文环境
* @return 返回是否包含当前值
*/
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
// 判断是包含该值
return set.contains(value);
}
}
  1. 编写一个自定义的校验注解绑定校验注解和其对应的校验类 @Constraint(validatedBy = {ListValueConstraintValidator.class})。一个校验注解可以绑定多个校验类,其将根据该注解标注的字段的实际类型决定使用哪个校验类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Documented
// 绑定注解和对应类:可以指定多个不同的校验器,适配不同类型的校验
@Constraint(validatedBy = {ListValueConstraintValidator.class, xxx.class, xxx.class})
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ListValue {
// 三要素不能丢
// 回显的异常信息,默认绑定了 com.zhao.common.valid.ListValue.message 属性,需要在该位置创建配置文件写上message属性
String message() default "{com.zhao.common.valid.ListValue.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };

// 自定义的属性
int[] vals() default { };
}
  1. 按照 JSR 规范,在 mall-common 模块添加配置文件:ValidationMessages.properties,在其内填写自定义的异常回显信息:
1
com.zhao.common.valid.ListValue.message=必须提交指定的值
  1. 在字段上添加自定义校验注解:
1
2
3
4
5
6
/**
* 显示状态[0-不显示;1-显示]
* 只有取值0或1才合法,否则都不合法
*/
@ListValue(vals = {0, 1})
private Integer showStatus;

这样如果前端发来的数据中的 showStatus字段的范围不在 0,1 之内,则会返回:

1
2
3
4
5
6
7
{
"msg": "参数格式校验失败",
"code": 10001,
"data": {
"showStatus": "必须提交指定的值"
}
}