springboot 与数据验证

springboot 与数据验证

一. 简述

在项目开发中,验证参数也是最经常使用的业务需求了。通常在开发的时候都需要根据业务需求,对参数进行必要验证。 当然一堆的 if-else 的验证在日常开发中时常可见。这种方式非常不友好: 1. 代码太长导致阅读不友好,更改需求可能只是简单的修改但是却需要阅读几十到几百行的代码 2. 有时候业务只是一两句但是验证代码却占用了很长时间 JSR-303Java 的验证规范,早期是在 Hibernate 框架中实现的,后面被抽取到 Java 体系。Spring-Boot 使用了 hibernate-validator 验证器,所以也包含了 JSR-303 在里面。当需要进行参数验证的时候,只需要几个注解即可实现复杂的验证。
GitHub:https://github.com/WeidanLi/spring-boot-tutorial 项目示例:web-validate

二. 开发

1. 引入依赖

1
2
3
4
5
<!-- 引入 web-starter, 已经包含 hibernate-validator 验证器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

2. 使用注解开发控制器

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
@RestController
@Validated // 类上添加该注解,能够验证直接使用参数形式的借口
public class UserEndpoint {

/**
* 如果需要验证Dto类,只需要加上@Validated即可,如需分组,传入参数
* @param userDto
*/
@PostMapping
public void create(@Validated @RequestBody UserDto userDto) {
System.out.println("-----> add: " + userDto);
}

@GetMapping
public UserDto queryByName(@Length(min = 1, max = 8) @RequestParam(value = "name") String name) {
System.out.println(name);
UserDto userDto = new UserDto();
userDto.setId(100);
userDto.setUserName("狗蛋");
userDto.setAge(19);
return userDto;
}

}

PS: 1. 第一个接口使用 DTO 的形式进行验证,验证注解将会被使用在 DTO 上,DTO如下 2. 第二个接口直接在接口参数进行验证 UserDto:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 用户数据交换类
*/
public class UserDto {

private Integer id;

@NotBlank
private String userName;

@NotNull
private Integer age;
}

3. 请求两个接口

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
POST http://localhost:8080

HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 14 Dec 2018 01:41:46 GMT
Connection: close

{
"timestamp": "2018-12-14T01:41:46.244+0000",
"status": 400,
"error": "Bad Request",
"errors": [
... 这里不做详细演示因为下面要配合控制器监听把这个错误给修改掉
]
}

----------------------------------------------------------

GET http://localhost:8080?name=

HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 14 Dec 2018 01:42:55 GMT
Connection: close

{
"timestamp": "2018-12-14T01:42:55.654+0000",
"status": 500,
"error": "Internal Server Error",
"message": "queryByName.name: 长度需要在1和8之间",
"path": "/"
}

Response code: 500; Time: 29ms; Content length: 141 bytes

可以看到,Spring 已经对数据按照我们的需求进行了验证,但是返回的错误信息并不是很友好,我们可以使用前面提到的 controller-advice 进行拦截返回

4. 定制返回验证错误信息

从控制台可以看到,验证不通过的时候抛出的异常是 ConstraintViolationExceptionMethodArgumentNotValidException 异常,所以在监听器里只需要对这个异常进行监听,获取 message 属性进行返回即可得知错误的原因。

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
@ControllerAdvice
@RestController
public class EndpointAdvice {

@ExceptionHandler(ConstraintViolationException.class) // 参数验证异常
@ResponseStatus(HttpStatus.BAD_REQUEST) // 返回 400
public Map<String, String> errorParam(ConstraintViolationException e) {
Map<String, String> error = new HashMap<>(2);
error.put("message", e.getMessage());
return error;
}

@ExceptionHandler(MethodArgumentNotValidException.class) // DTO 验证异常
@ResponseStatus(HttpStatus.BAD_REQUEST) // 返回 400
public Map<String, String> errorDto(MethodArgumentNotValidException e) {
Map<String, String> error = new HashMap<>(2);
StringBuilder message = new StringBuilder();
for (ObjectError allError : e.getBindingResult().getAllErrors()) {
message.append(allError.getDefaultMessage()).append("; ");
}
error.put("message", message.toString());
return error;
}

}

重新请求:

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
POST http://localhost:8080

HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 14 Dec 2018 01:59:57 GMT
Connection: close

{
"message": "名字不允许为空; 年龄不允许为空; "
}

Response code: 400; Time: 166ms; Content length: 28 bytes

---------------------------------------------------------
GET http://localhost:8080?name=

HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 14 Dec 2018 01:48:23 GMT
Connection: close

{
"message": "queryByName.name: 长度需要在1和8之间"
}

Response code: 400; Time: 151ms; Content length: 42 bytes

5. 分组验证

DTO 如果验证不能自由的话,那么DTO 的复用性就变差了,总不能一个接口都要重新写属性相同但是验证不相同的 DTO 吧 所以,JSR-303 的每个验证注解上都有一个 groups 属性,传入 Class 数组(多组验证),用于定义分组验证的接口(但是这个接口没有实际实现,只是以 Java 类来做分组罢了)。 DTO 修改为更新的时候要求 id 不能为空,新增时名字和年龄不能为空。

1
2
3
4
5
6
7
8
9
10
11
public class UserDto {

@NotNull(groups = Update.class)
private Integer id;

@NotBlank(message = "名字不允许为空", groups = Add.class)
private String userName;

@NotNull(message = "年龄不允许为空", groups = Add.class)
private Integer age;
}

接口修改,验证器传入分组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
@Validated // 类上添加该注解,能够验证直接使用参数形式的借口
public class UserEndpoint {

/**
* 新增的时候验证 ADD 分组下需要验证的属性
* @param userDto
*/
@PostMapping
public void create(@Validated({Add.class}) @RequestBody UserDto userDto) {
System.out.println("-----> add: " + userDto);
}

/**
* 更新的时候验证 UPDATE 分组下需要验证的属性
* @param userDto
*/
@PutMapping
public void update(@Validated({Update.class}) @RequestBody UserDto userDto) {
System.out.println("-----> update: " + userDto);
}

}

重新请求接口:

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
POST http://localhost:8080

HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 14 Dec 2018 02:11:14 GMT
Connection: close

{
"message": "年龄不允许为空; 名字不允许为空; "
}

Response code: 400; Time: 11ms; Content length: 32 bytes

---------------------------------------------------------

PUT http://localhost:8080

HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 14 Dec 2018 02:12:06 GMT
Connection: close

{
"message": "不能为null; "
}

Response code: 400; Time: 194ms; Content length: 23 bytes

6. 注解使用说明

每个注解都拥有两个属性:

  1. groups 用于定义这个注解要应用于哪些分组的
  2. message 用于定义不符合要求时要输出的内容
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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
注解

说明

@Null

被注释的元素必须为

@NotNull

被注释的元素必须不为null

@AssertTrue

被注释的元素必须为true

@AssertFalse

被注释的元素必须为false

@Min(value)

被注释的元素必须是一个数字,其值必须大于等于指定的最小值

@Max(value)

被注释的元素必须是一个数字,其值必须小于等于指定的最大值

@DecimalMin(value)

被注释的元素必须是一个数字,其值必须大于等于指定的最小值

@DecimalMax(value)

被注释的元素必须是一个数字,其值必须小于等于指定的最大值

@Size(max=,min=)

被注释的元素的大小必须在指定的范围内

@Digits

被注释的元素必须是一个数字,其值必须在可接受的范围内

@Past

被注释的元素必须是一个过去的日期

@Future

被注释的元素必须是一个将来的日期

@Pattern(regex=,flag=)

被注释的元素必须符合指定的正则表达式

Hibernate

Validator

@NotBlank

验证字符串非null,且长度必须大于0

@Email

被注释的元素必须是电子邮箱地址

@Length(min=,max=)

被注释的字符串的大小必须在指定的范围内

@NotEmpty

被注释的字符串的必须非空

@Range(min=,max=,message=)

被注释的元素必须在合适的范围内

7. 开发自定义校验器

很多时候,框架自带的功能并不能或者说不方便实现我们想要的功能的时候,我们就可以开始考虑自己造轮子,开发属于我们所在领域的校验器。 简单来说,开发流程就是以下两个步骤:

  1. 编写约束注解,填充需要的属性以及官方必须要求的属性;
  2. 编写校验器,对目标值配合注解上的属性进行验证。

7.1 定义校验注解

为了示范,我定义一个验证目标字符串需要纯中文字符的校验注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 验证纯中文的注解
*/
@Constraint(validatedBy = { ChsValidator.class }) // 定义注解的校验器
@Target(ElementType.FIELD) // 定义该注解使用在类属性上
@Retention(RUNTIME) // 运行时有效
@Documented
public @interface Chs {

/** 可以自定义属性,这个属性暂时没什么用 */
String chineseName();

/** 以下是注解必须的三个属性,分别是定义组别、错误等级以及提示信息 */
Class<?>[] groups() default { };

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

String message() default "该字段需要纯中文";

}

7.2 定义校验器

校验器需要去实现 ConstraintValidator 接口,传入两个泛型,第一个泛型是注解类型,第二个是目标值的类型。实现两个方法,校验器调用的校验在 isValid 中进行,如果校验通过返回 True 不同过则返回 False

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ChsValidator implements ConstraintValidator<Chs, String> { // 实现ConstraintValidator接口,第一个泛型是注解类型,第二个是目标值的类型

private String reg = "^[\\u0391-\\uFFE5]+[\\w*[\\u0391-\\uFFE5]*]*";

@Override
public void initialize(Chs constraintAnnotation) {

}

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (null == value) {
return false;
}
return value.matches(reg);
}

}

7.3 测试自定义校验器

现在需要一个控制器接口来测试

1
2
3
4
@PostMapping("testChsValidator")
public void testChsValidator(@Chs @RequestParam String chsName) {

}

请求访问测试

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
POST http://localhost:8080/testChsValidator?chsName=888

HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 18 Dec 2018 01:39:40 GMT
Connection: close

{
"message": "testChsValidator.chsName: 该字段需要纯中文"
}

Response code: 400; Time: 239ms; Content length: 48 bytes

--------------------------------------------------------

POST http://localhost:8080/testChsValidator?chsName=你好

HTTP/1.1 200
Content-Length: 0
Date: Tue, 18 Dec 2018 01:40:11 GMT

<Response body is empty>

Response code: 200; Time: 44ms; Content length: 0 bytes

三. 总结

OK,验证器的使用到这里基本完结,一般来说基础的可以适用大部分需求,但是我建议如果某个校验规则用的不多的话,就不大需要定义自定义校验器,如果用的比较多而且是通用的话,那么自定义的注解校验无疑会带来很多便捷。校验器适用的实现是 Hibernate Validate 所以如若需要查询资料可直接查询Hibernate Validate 相关的即可。 Hibernate-Validate