SpringBoot 整合 JSR303 参数校验

前言

在日常开发中,避不开的就是参数校验,有人说前端不是会在表单提交之前进行校验的吗?在后端开发中,不管前端怎么样校验,后端都需要进行再次校验,这是为了系统安全。因为前端的校验很容易被绕过,当使用 PostMan 来测试时,如果后端没有校验,容易引发安全问题。值得一提的是,本文适用于 Spring Boot 与 Spring Cloud 项目。

JSR303 简介

JSR-303 是 JAVA EE 6 中的一项子规范,叫做 Bean Validation,官方的参考实现是 Hibernate Validator。值得一提的是,Hibernate Validator 提供了 JSR-303 规范中所有内置 Constraint 的实现,除此之外还有一些附加的 Constraint。

常用约束注解

约束注解的名称约束注解的说明
@Null 用于校验对象为 Null
@NotNull 用于校验对象不能为 Null,无法校验长度为 0 的字符串
@NotBlank 用于校验 String 类,不能为 Null,且 trim() 之后的 size 大于零
@NotEmpty 用于校验集合类、String 类不能为 Null,且 size 大于零,但是带有空格的字符串校验不出来
@Size 用于校验对象(Array、Collection、Map、String)长度是否在给定的范围之内
@Length 用于校验 String 对象的大小必须在指定的范围内
@Pattern 用于校验 String 对象是否符合正则表达式的规则
@Email 用于校验 String 对象是否符合邮箱格式
@Min 用于校验 Number 和 String 对象是否大等于指定的值
@Max 用于校验 Number 和 String 对象是否小等于指定的值
@AssertTrue 用于校验 Boolean 对象是否为 true
@AssertFalse 用于校验 Boolean 对象是否为 false

常用校验注解

校验注解有两个,分别是 @Validated@Valid,两者的区别如下:

@Validated 注解:

  • Spring 提供的
  • 支持分组校验
  • 可以用在类型、方法和方法参数上,但是不能用在成员对象属性上
  • 由于无法加在成员对象属性上,所以无法单独完成级联校验,需要配合 @Valid 一起使用

@Valid 注解:

  • JDK 提供的(标准 JSR-303 规范)
  • 不支持分组校验
  • 可以用在方法、构造函数、方法参数和成员对象属性上
  • 可以加在成员对象属性上,能够独自完成级联校验

提示

@Validated 注解一般是用到分组校验时才使用。

一个学校对象里有很多个学生对象,学校和学生都需要校验参数;此时可以在学校的 Controller 类的方法参数前添加 @Validated 注解,同时在学校对象的学生属性上添加 @Valid 注解,不加则无法对学生对象里的属性进行校验。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Data
public class School {

private Long id;

@NotBlank
private String name;

@Valid // 需要加上,否则不会验证Student类中的约束注解
@NotNull // 且需要触发该字段的验证才会进行级联校验
private List<Student> list;
}
1
2
3
4
5
6
7
8
9
@Data
public class Student {

private Long id;

@NotBlank
private String name;

}
1
2
3
4
5
6
7
8
9
10
@RestController
@RequestMapping("/school")
public class SchoolController {

@PostMapping("/add")
public Result add(@Validated @RequestBody School school){

}

}

JSR303 入门

整合案例

引入 Maven 依赖

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>

<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>8.0.0.Final</version>
</dependency>

统一返回类型

为了统一返回给前端的结果格式,应该定义一个返回结果类。

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
/**
* 返回结果
*/
public class R extends HashMap<String, Object> {

private static final long serialVersionUID = 1L;

public R() {
put("code", 0);
put("msg", "success");
}

public static R error() {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "系统未知异常,请联系管理员");
}

public static R error(String msg) {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
}

public static R error(int code, String msg) {
R r = new R();
r.put("code", code);
r.put("msg", msg);
return r;
}

public static R ok(String msg) {
R r = new R();
r.put("msg", msg);
return r;
}

public static R ok(Map<String, Object> map) {
R r = new R();
r.putAll(map);
return r;
}

public static R ok() {
return new R();
}

public R put(String key, Object value) {
super.put(key, value);
return this;
}

}

添加约束注解

在 JavaBean(例如 Entity、VO、DTO)类的成员属性上添加约束注解,表明某个成员属性的校验规则是什么。

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
@Data
public class BrandVo implements Serializable {

/**
* 品牌id
*/
private Long brandId;

/**
* 品牌名
*/
@NotBlank(message = "can't be empty")
private String name;

/**
* 品牌logo地址
*/
@URL(message = "must be an url address")
@NotBlank(message = "can't be empty")
private String logo;

/**
* 介绍
*/
@NotBlank(message = "can't be empty")
private String descript;

/**
* 显示状态[0-不显示;1-显示]
*/
@NotNull(message = "can't be null")
private Integer showStatus;

/**
* 检索首字母
*/
@Pattern(regexp = "^[a-zA-Z]$", message = "must be a letter")
@NotBlank(message = "can't be empty")
private String firstLetter;

/**
* 排序
*/
@Min(value = 0, message = "must be greater than or equal to zero")
@NotNull(message = "can't be null")
private Integer sort;

}

添加校验注解

在 Controller 类的方法参数前面添加 @Valid 校验注解,表明某个方法的接口调用需要检验参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@RequestMapping("/brand")
public class BrandController {

@Autowired
private BrandService brandService;

/**
* 保存
*/
@RequestMapping("/save")
public R save(@Valid @RequestBody BrandVo brand) {
brandService.save(brand);
return R.ok();
}

}

当前端提交过来的参数不符合校验规则,服务端会自动返回 400 的 HTTP 状态码给前端。

获取校验结果

在 Controller 类的方法参数列表里添加一个 BindingResult 参数(Spring MVC 会自动注入对应的值),这样就可以获取到参数的校验结果,同时也很方便进一步返回友好的错误提示信息给前端。

注意

一般情况下,不建议使用以下的方式来单独处理参数校验结果,因为这样会出现很多冗余代码,且后期不容易维护。建议采用后面介绍的 全局异常处理 方案,这样可以统一处理校验结果。

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
@RestController
@RequestMapping("/brand")
public class BrandController {

@Autowired
private BrandService brandService;

/**
* 保存
*/
@RequestMapping("/save")
public R save(@Valid @RequestBody BrandVo brand, BindingResult validResult) {
// 手动处理校验结果
if (validResult.hasErrors()) {
Map<String, String> map = new HashMap<>();
validResult.getFieldErrors().forEach(item -> {
String errorMsg = item.getDefaultMessage();
String fieldName = item.getField();
map.put(fieldName, errorMsg);
});
return R.error(400, "提交的数据不合法").put("data", map);
}

brandService.save(brand);
return R.ok();
}

}

若参数校验不通过,最终的返回结果如下:

1
2
3
4
5
6
7
{
"msg": "提交的数据不合法",
"code": 400,
"data": {
"name": "can't be empty"
}
}

全局异常处理案例

统一错误码

为了方便标识不同的异常信息,建议使用枚举类型来统一存储不同的错误码和错误信息。

  • 错误码建议定义为 5 位数字
  • 前两位表示业务场景,例如:10 表示通用业务,11 表示商品业务,12 表示订单业务
  • 最后三位表示错误码,例如 000 表示系统未知异常,001 表示参数校验异常
  • 完整的错误码,例如:10000,其中的 10 表示通用,000 表示系统未知异常
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
/**
* 错误码
*/
public enum BizCodeEnum {

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

private int code;

private String msg;

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

public int getCode() {
return code;
}

public String getMsg() {
return msg;
}

}

统一返回类型

为了统一返回给前端的结果格式,应该定义一个返回结果类。

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
/**
* 返回结果
*/
public class R extends HashMap<String, Object> {

private static final long serialVersionUID = 1L;

public R() {
put("code", 0);
put("msg", "success");
}

public static R error() {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "系统未知异常,请联系管理员");
}

public static R error(String msg) {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
}

public static R error(int code, String msg) {
R r = new R();
r.put("code", code);
r.put("msg", msg);
return r;
}

public static R ok(String msg) {
R r = new R();
r.put("msg", msg);
return r;
}

public static R ok(Map<String, Object> map) {
R r = new R();
r.putAll(map);
return r;
}

public static R ok() {
return new R();
}

public R put(String key, Object value) {
super.put(key, value);
return this;
}

}

全局异常处理

使用 @ControllerAdvice@ExceptionHandler 注解实现全局的异常处理。

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
/**
* 全局异常处理
*/
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

/**
* 处理参数校验异常
*
* @param e 异常
* @return 处理结果
*/
@ResponseBody
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public R handleValidException(MethodArgumentNotValidException e) {
log.error("发生参数校验异常:{}", e.getMessage());
BindingResult validResult = e.getBindingResult();
StringBuffer messages = new StringBuffer();
if (validResult.hasErrors()) {
validResult.getFieldErrors().forEach(item -> {
String errorMsg = item.getDefaultMessage();
String fieldName = item.getField();
messages.append(fieldName + ": " + errorMsg + "\n");
});
}
return R.error(BizCodeEnum.VALID_EXCEPTION.getCode(), BizCodeEnum.VALID_EXCEPTION.getMsg()).put("details", messages.toString());
}

/**
* 处理系统未知异常
*
* @param throwable 异常
* @return 处理结果
*/
@ResponseBody
@ExceptionHandler(value = Throwable.class)
public R handleException(Throwable throwable) {
log.error("发生系统未知异常:{}", throwable.getMessage());
return R.error(BizCodeEnum.UNKNOW_EXCEPTION.getCode(), BizCodeEnum.UNKNOW_EXCEPTION.getMsg());
}

}

添加约束注解

在 JavaBean(例如 Entity、VO、DTO)类的成员属性上添加约束注解,表明某个成员属性的校验规则是什么。

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
@Data
public class BrandVo implements Serializable {

/**
* 品牌id
*/
private Long brandId;

/**
* 品牌名
*/
@NotBlank(message = "can't be empty")
private String name;

/**
* 品牌logo地址
*/
@URL(message = "must be an url address")
@NotBlank(message = "can't be empty")
private String logo;

/**
* 介绍
*/
@NotBlank(message = "can't be empty")
private String descript;

/**
* 显示状态[0-不显示;1-显示]
*/
@NotNull(message = "can't be null")
private Integer showStatus;

/**
* 检索首字母
*/
@Pattern(regexp = "^[a-zA-Z]$", message = "must be a letter")
@NotBlank(message = "can't be empty")
private String firstLetter;

/**
* 排序
*/
@Min(value = 0, message = "must be greater than or equal to zero")
@NotNull(message = "can't be null")
private Integer sort;

}

添加校验注解

在 Controller 类的方法参数前面添加 @Valid 校验注解,表明某个方法的接口调用需要检验参数。由于实现了全局异常处理,这里不再需要在 Controller 类的方法的参数列表里添加一个 BindingResult 参数来单独处理校验结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@RequestMapping("/brand")
public class BrandController {

@Autowired
private BrandService brandService;

/**
* 保存
*/
@RequestMapping("/save")
public R save(@Valid @RequestBody BrandVo brand) {
brandService.save(brand);
return R.ok();
}

}

若参数校验不通过,最终的返回结果如下:

1
2
3
4
5
{
"msg": "参数格式校验失败",
"code": 10001,
"details": "sort: must be greater than or equal to zero\n name: can't be empty\n"
}

自定义校验器案例

自定义校验器

实现 ConstraintValidator 接口,编写自定义的校验器。下述的校验器,用于校验前端提交的参数值(Integer 类型)是否在指定的值内。

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
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/**
* 自定义校验器
*/
public class ListValueValidatorForInteger implements ConstraintValidator<ListValue, Integer> {

private final Set<Integer> set = new HashSet<>();

/**
* 初始化方法
*/
@Override
public void initialize(ListValue constraintAnnotation) {
int[] values = constraintAnnotation.values();
for (int value : values) {
set.add(value);
}
}

/**
* 判断是否校验成功
*
* @param value 需要校验的值
* @param context
* @return
*/
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
if (value != null) {
return set.contains(value);
}
return false;
}

}

自定义约束注解

自定义约束注解,通过 @Constraint 注解的 validatedBy 属性来指定自定义的校验器,详细的写法建议参考 @NotBlank 注解的源码实现。值得一提的是,validatedBy 属性可以指定多个自定义校验器,Spring MVC 会根据参数的类型(例如 IntegerDoubleString 类型)来自动选择合适的校验器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import javax.validation.Constraint;
import javax.validation.Payload;

/**
* 自定义约束注解
*/
@Documented
@Constraint(validatedBy = {ListValueValidatorForInteger.class})
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
public @interface ListValue {

// 该属性的值默认会去 ValidationMessages.properties 配置文件中取
String message() default "{com.shop.common.validator.constraints.ListValue.message}";

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

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

int[] values() default {};

}

自定义提示信息

在项目(模块)的 src/main/resources 目录下创建 ValidationMessages.properties 配置文件,用于存放校验结果的提示信息。

1
com.shop.common.validator.constraints.ListValue.message = the specified value must be submitted

添加约束注解

在 JavaBean(例如 Entity、VO、DTO)类的成员属性上添加约束注解,包括自定义的约束注解 @ListValue,表明某个成员属性的校验规则是什么。

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
@Data
public class BrandVo implements Serializable {

/**
* 品牌id
*/
private Long brandId;

/**
* 品牌名
*/
@NotBlank(message = "can't be empty")
private String name;

/**
* 品牌logo地址
*/
@URL(message = "must be an url address")
@NotBlank(message = "can't be empty")
private String logo;

/**
* 介绍
*/
@NotBlank(message = "can't be empty")
private String descript;

/**
* 显示状态[0-不显示;1-显示]
*/
@NotNull(message = "can't be null")
@ListValue(values = {0, 1})
private Integer showStatus;

/**
* 检索首字母
*/
@Pattern(regexp = "^[a-zA-Z]$", message = "must be a letter")
@NotBlank(message = "can't be empty")
private String firstLetter;

/**
* 排序
*/
@Min(value = 0, message = "must be greater than or equal to zero")
@NotNull(message = "can't be null")
private Integer sort;

}

添加校验注解

在 Controller 类的方法参数前面添加 @Valid 校验注解,表明某个方法的接口调用需要检验参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@RequestMapping("/brand")
public class BrandController {

@Autowired
private BrandService brandService;

/**
* 保存
*/
@RequestMapping("/save")
public R save(@Valid @RequestBody BrandVo brand) {
brandService.save(brand);
return R.ok();
}

}

若自定义的校验器校验不通过,则最终的返回结果如下:

1
2
3
4
5
{
"msg": "参数格式校验失败",
"code": 10001,
"details": "showStatus: the specified value must be submitted\n"
}

分组校验案例

在做参数校验的时候,通常会遇到同一个实体类的新增和修改操作,它们的参数校验规则是不同的;例如新增时 id 允许为空,修改时则不允许 id 为空。为了解决这种业务场景,可以使用分组校验,这样可以少建一个冗余的实体类。

添加分组接口

创建校验用的分组接口,该接口只用于标识不同业务场景的参数校验。

1
2
3
4
5
6
/**
* 新增分组
*/
public interface AddGroup {

}
1
2
3
4
5
6
/**
* 更新分组
*/
public interface UpdateGroup {

}

添加约束注解

在 JavaBean(例如 Entity、VO、DTO)类的成员属性上添加约束注解,同时还需要指定对应的分组,表明某个成员属性在某个分组下的校验规则是什么。

特别注意

默认没有指定分组的约束注解,在使用分组校验的情况下是不会生效的,只会在没有使用分组校验的情况下才生效(例如使用 @Valid 注解)。

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
@Data
public class BrandVo implements Serializable {

/**
* 品牌id
*/
@Null(message = "must be null", groups = {AddGroup.class})
@NotNull(message = "can't be null", groups = {UpdateGroup.class})
private Long brandId;

/**
* 品牌名
*/
@NotBlank(message = "can't be empty", groups = {AddGroup.class, UpdateGroup.class})
private String name;

/**
* 品牌logo地址
*/
@URL(message = "must be an url address", groups = {AddGroup.class, UpdateGroup.class})
@NotBlank(message = "can't be empty", groups = {AddGroup.class, UpdateGroup.class})
private String logo;

/**
* 介绍
*/
@NotBlank(message = "can't be empty", groups = {AddGroup.class, UpdateGroup.class})
private String descript;

/**
* 显示状态[0-不显示;1-显示]
*/
@NotNull(message = "can't be null", groups = {AddGroup.class, UpdateGroup.class})
private Integer showStatus;

/**
* 检索首字母
*/
@Pattern(regexp = "^[a-zA-Z]$", message = "must be a letter", groups = {AddGroup.class, UpdateGroup.class})
@NotBlank(message = "can't be empty", groups = {AddGroup.class, UpdateGroup.class})
private String firstLetter;

/**
* 排序
*/
@Min(value = 0, message = "must be greater than or equal to zero", groups = {AddGroup.class, UpdateGroup.class})
@NotNull(message = "can't be null", groups = {AddGroup.class, UpdateGroup.class})
private Integer sort;

}

添加校验注解

在 Controller 类的方法参数前面添加 @Validated 校验注解,同时指定对应的分组。值得一提的是,这里不能使用 @Valid 注解,因为该注解不支持分组校验。

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
@RestController
@RequestMapping("/brand")
public class BrandController {

@Autowired
private BrandService brandService;

/**
* 保存
*/
@RequestMapping("/save")
public R save(@Validated(AddGroup.class) @RequestBody BrandVo brand) {
brandService.save(brand);
return R.ok();
}

/**
* 修改
*/
@RequestMapping("/update")
public R update(@Validated(UpdateGroup.class) @RequestBody BrandVo brand) {
brandService.updateById(brand);

return R.ok();
}

}

若调用新增接口时,指定了 id 参数,则最终的返回结果如下:

1
2
3
4
5
{
"msg": "参数格式校验失败",
"code": 10001,
"details": "brandId: must be null\n"
}

参数校验工具类

若希望手动校验参数是否合法,可以参考以下代码。

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
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import java.util.Set;

public class ValidatorUtils {

private static final Validator VALIDATOR;

static {
VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
}

/**
* Validate parameter
* @param object
* @param groups
* @throws GlobalException
*/
public static void validateParameter(Object object, Class<?>... groups) throws GlobalException {
Set<ConstraintViolation<Object>> constraintViolations = VALIDATOR.validate(object, groups);
if (!constraintViolations.isEmpty()) {
StringBuilder message = new StringBuilder();
for (ConstraintViolation<Object> constraint : constraintViolations) {
message.append(constraint.getMessage()).append("\n");
}
ErrorCode errorCode = ErrorCode.PARAMETER_ERROR;
errorCode.setDescription(message.toString());
throw new GlobalException(errorCode);
}
}

}