1 简述
Spring Boot 支持 JSR-303、Bean 验证框架,默认实现使用 Hibernate validator。只要在需要验证的参数上加上 @Validated 注解,Spring Boot 便会对参数进行验证,并把验证结果放在 BindingResult 中。
本文目的:
对 JSR-303 规定的验证注解进行简单了解使用;
开发定制的 Field 验证注解与对应的验证器;
编写类注解完成用户注册时保证两个密码一致的需求;
2 JSR-303
JSR-303 是 Java 的标准验证框架,已有的实现为 Hibernate validator。JSR-303 定义了一系列注解用来验证 Bean 的属性。
2.1 空检查
- @Null 验证对象是否为空
- @NotNull 验证对象不为空
- @NotBlank 验证字符串不为空或不是空字符串
- @NotEmpty 验证对象不为Null,或者集合不为空
2.2 长度检查
- @Size 验证对象长度,支持字符串,集合
- @Length 验证字符串大小(于 org.hibernate.validator.constraints 包中)
2.3 数值检查
- @Min 验证数字是否大于等于指定值
- @Max 验证数字是否小于等于指定值
- @Digits 验证数字是否符合指定的格式
- @Range 验证数字是否在指定的范围内
- @Negative 验证数字是否为负数
- @NegativeOrZero 验证数字是否小于等于0
- @Positive 验证数字是否为正数
- @PositiveOrZero验证数字是否大于等于0
- @DecimalMin 验证数字是否大于指定值
- @DecimalMax 验证数字是否小于等于指定值
注:@DecimalMax 跟 @Max 有相同的功能,区别在于 @DecimalMax 能接受 CharSequence 作为额外验证目标,这意味着它能处理大于 Long.MAX_VALUE 的字符串形式的目标,但这一特性也给编码埋下了隐患,如抛出 NumberFormatException 或者 IllegalArgumentException(xx does not represent a valid BigDecimal format);@DecimalMin 跟 @Min 的异同同上。
2.4 时间检查
- @Future 检查时间是否晚于现在
- @FutureOrPresent 检查时间是否非早于现在
- @Past 检查时间是否早于现在
- @PastOrPresent 检查时间是否非晚于现在
2.5 其他
- @Email 检查是否一个合法的邮箱地址
- @Pattern 检查是否符合指定的正则规则
3 在 MVC 中使用 @Validated
3.1 简单入门
3.1.1 编写
以下是一个包含了验证注解的 JavaBean
1 public class UserForm {
2 @NotNull
3 Long id;
4 @Size(min = 3, max = 8)
5 String name;
6 @Email
7 String email;
8 }
通常,一个 JavaBean 在不同的业务逻辑会有不同的验证逻辑,对于更新的时候,id 必须不为 null,但增加的时候,id 必须是 null。
JSR-303 定义了 group 的概念,每个校验注解都必须支持。校验注解可以指定一个或者多个 group ,当Spring Boot校验对象的时候,需要指定上下文(interface),只有 group 匹配的时候,校验注解才能生效,因此上边的内容可以改为
1 public class UserForm {
2 public interface Add {
3 }
4
5 public interface Update {
6 }
7
8 @Null(groups = Add.class)
9 @NotNull(groups = Update.class)
10 Long id;
11
12 @Size(min = 3, max = 8, groups = {Add.class, Update.class})
13 String name;
14
15 @Email(groups = {Add.class, Update.class})
16 String email;
17 }
上边的注解注解表示:
当上下文为 Add.class 时,@Null,@Length,@Email 生效;
当上下文为 Update.class时,@NotNull,@Length,@Email 生效;
在 Controller 中使用验证:
1 /**
2 * @author pancc
3 * @version 1.0
4 * @date 2019/11/2 16:52
5 */
6 @Controller
7 public class UserFormController {
8 private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
9
10 @ResponseBody
11 @PostMapping("/add")
12 public String add(@Validated(UserForm.Add.class) UserForm userForm, BindingResult result) {
13 Map<String, String> errors = new HashMap<>(1);
14 if (result.hasErrors()) {
15 result.getAllErrors().forEach(objectError -> {
16 if (objectError instanceof FieldError) {
17 FieldError fieldError = ((FieldError) objectError);
18 errors.putIfAbsent(fieldError.getField(), fieldError.getDefaultMessage());
19 }
20 });
21 try {
22 return OBJECT_MAPPER.writeValueAsString(errors);
23 } catch (JsonProcessingException e) {
24 return "server errors";
25 }
26 } else {
27 return "success";
28 }
29 }
30 }
上边的 Controller 将 HTTP 参数映射到 UserForm 中,并指定上下文为 Add.class,该参数使用了 @Validated 注解,将触发 Spring 的校验并将校验结果放在 BindingResult 对象中;
并且该方法在校验失败时将错误的 Field 名跟提示信息放在 Map 中返回;
3.1.2 测试
对3.1.1 中的代码进行测试,
测试例子1
测试例子2
测试例子3
3.1.3 完整的代码
1 /**
2 * @author pancc
3 * @version 1.0
4 * @date 2019/11/2 16:52
5 */
6 @Controller
7 public class UserFormController {
8 private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
9
10 @ResponseBody
11 @PostMapping("/add")
12 public String add(@Validated(UserForm.Add.class) UserForm userForm, BindingResult result) {
13 Map<String, String> errors = collectErrors(result);
14 if (errors.size() != 0) {
15 try {
16 return OBJECT_MAPPER.writeValueAsString(errors);
17 } catch (JsonProcessingException e) {
18 return "server error";
19 }
20 }
21 return "success";
22 }
23
24 @ResponseBody
25 @PostMapping("/update")
26 public String update(@Validated(UserForm.Update.class) UserForm userForm, BindingResult result) {
27 Map<String, String> errors = collectErrors(result);
28 if (errors.size() != 0) {
29 try {
30 return OBJECT_MAPPER.writeValueAsString(errors);
31 } catch (JsonProcessingException e) {
32 return "server error";
33 }
34 }
35 return "success";
36 }
37
38
39
40
41 private Map<String, String> collectErrors(BindingResult result) {
42 Map<String, String> errors = new HashMap<>(1);
43 if (result.hasErrors()) {
44 result.getAllErrors().forEach(objectError -> {
45 if (objectError instanceof FieldError) {
46 FieldError fieldError = ((FieldError) objectError);
47 errors.putIfAbsent(fieldError.getField(), fieldError.getDefaultMessage());
48 }
49 });
50 }
51 return errors;
52 }
53 }
3.2 自定义验证
当 JSR-303 提供的校验注解不足时,就需要我们手动作参数验证或者自定义一个校验注解,比如我们需要禁止使用特定的字符串;
需要编写 @NotSpecificStringValue
1 /**
2 * @author pancc
3 * @date 2019/11/1 17:10
4 */
5
6 @Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
7 @Retention(RetentionPolicy.RUNTIME)
8 @Constraint(validatedBy = {NotSpecificStringValueValidator.class})
9 @Documented
10 public @interface NotSpecificStringValue {
11 String message() default "{constraint.notSpecificStringValue.message}";
12
13 Class<?>[] groups() default {};
14
15 Class<? extends Payload>[] payload() default {};
16
17 String value();
18
19 @Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
20 @Retention(RetentionPolicy.RUNTIME)
21 @Documented
22 @interface List {
23 NotSpecificStringValue[] value();
24 }
25 }
与其验证类 NotSpecificStringValueValidator
1 /**
2 * @author pancc
3 * @version 1.0
4 * @date 2019/11/2 17:11
5 */
6 public class NotSpecificStringValueValidator implements ConstraintValidator<NotSpecificStringValue, String> {
7
8 /**
9 * 禁止使用的字符串
10 */
11 private String forbiddenValue;
12
13 @Override
14 public void initialize(NotSpecificStringValue constraintAnnotation) {
15 forbiddenValue = constraintAnnotation.value().toLowerCase();
16 }
17
18 @Override
19 public boolean isValid(String target, ConstraintValidatorContext context) {
20 return forbiddenValue != null && !forbiddenValue.contentEquals(target.toLowerCase());
21 }
22 }
自此,我们可以修改原先的测试实体定义:
1 @NotSpecificStringValue(value = "admin",message = "不允许使用用户名:{value} ", groups = {Add.class, Update.class})
2 @Length(min = 3, max = 8, groups = {Add.class, Update.class})
3 String name;
并做测试:
3.3 探索与发现
在使用中,发现 @Size 的 默认信息定义为 :
String message() default "{javax.validation.constraints.Size.message}";
在 hibernate-validator-6.0.17.Final.jar 包中,我们发现了配置文件 ValidationMessages_zh_CN.properties,javax.validation.constraints.Size.message 对应的值为 \u4e2a\u6570\u5fc5\u987b\u5728{min}\u548c{max}\u4e4b\u95f4 经,编码发现值为 长度需要在{min}和{max}之间,查阅 @Size 源码,发现 {min} 对应源码属性 min 的值,{max} 对应源码属性 max的值。
因此在 3.2 中,我们自定义信息块为:
message = "不允许使用用户名:{value} "
从而能够动态的打印提示信息(禁止的用户名);
此外,我们可以在 Validationmessages.properties 或者 Validationmessages_zh_cn.properties 文件(_zh_cn 代表中国本地化),并添加一行:constraint.notSpecificStringValue.message = 不允许使用用户名:{value},并需改测试实体类定义:
1 @NotSpecificStringValue(value = "admin", groups = {Add.class, Update.class})
2 @Length(min = 3, max = 8, groups = {Add.class, Update.class})
3 String name;
效果与 3.2 中测试一致;
同样的,我们也可以设置 javax.validation.constraints.Size.message 的值,这样子就会覆盖Hibernate validator 默认的值;
另,参见 org.hibernate.validator.messageinterpolation.AbstractMessageInterpolator#DEFAULT_VALIDATION_MESSAGES
3.3 类层次校验注解
3.3.1 需求及编写
回到我们的主题上,我们最终需要编写一个验证注解,实现功能:检查用户注册时使用的密码跟确认密码是否一致。
在前面,我们使用和编写的都是 Filed 层次的验证注解,然而却很难实现跨字段验证。
一个最高效的解决方法是:实现一个 Class 层次的注解,并且依靠反射获得对应的两个字段的值。依照这个思路,我们编写的代码如下:
注解类 @FieldMatch
1 /**
2 * 验证两个字段的值是否相等,常见于注册时输入两个密码
3 *
4 * @author pancc
5 * @version 1.0
6 * @date 2019/11/2 17:40
7 */
8 @Target({TYPE, ANNOTATION_TYPE})
9 @Retention(RUNTIME)
10 @Constraint(validatedBy = FieldMatchValidator.class)
11 @Documented
12 public @interface FieldMatch {
13 String message() default "{constraints.fieldmatch.message}";
14
15 Class<?>[] groups() default {};
16
17 Class<? extends Payload>[] payload() default {};
18
19 /**
20 * 需要验证的第一字段的字段名<code>String password</code> 中的 <code>password</code>
21 *
22 * @return 第一字段的字段名
23 */
24 String first();
25 /**
26 * 需要验证的第二字段的字段名<code>String confirmPassword</code> 中的 <code>confirmPassword</code>
27 *
28 * @return 第一字段的字段名
29 */
30 String second();
31
32
33 @Target({TYPE, ANNOTATION_TYPE})
34 @Retention(RUNTIME)
35 @Documented
36 @interface List {
37 FieldMatch[] value();
38 }
39 }
验证器 FieldMatchValidator
1 /**
2 * {@link FieldMatch} 验证器
3 *
4 * @author pancc
5 * @version 1.0
6 * @date 2019/11/2 14:50
7 */
8 public class FieldMatchValidator implements ConstraintValidator<FieldMatch, Object> {
9
10 private String firstFieldName;
11 private String secondFieldName;
12
13 @Override
14 public void initialize(final FieldMatch constraintAnnotation) {
15 firstFieldName = constraintAnnotation.first();
16 secondFieldName = constraintAnnotation.second();
17 }
18
19 @Override
20 public boolean isValid(final Object src, final ConstraintValidatorContext context) {
21 BeanWrapperImpl wrapper = new BeanWrapperImpl(src);
22 Object firstObj = wrapper.getPropertyValue(firstFieldName);
23 System.out.println("firstObj = " + firstObj);
24 Object secondObj = wrapper.getPropertyValue(secondFieldName);
25 System.out.println("secondObj = " + secondObj);
26
27 return firstObj != null && firstObj.equals(secondObj);
28 }
29 }
3.3.2 测试
3.3.2.1 单注解测试
我们的测试类需要做一些修改,这次,验证注解在类上
1 /**
2 * @author pancc
3 * @version 1.0
4 * @date 2019/11/2 14:49
5 */
6 @FieldMatch(first = "password", second = "confirmPassword", groups = UserForm.Add.class)
7 @Data
8 public class UserForm {
9 public interface Add {
10 }
11
12 public interface Update {
13 }
14
15 @Null(groups = Add.class)
16 @NotNull(groups = Update.class)
17 Long id;
18
19 @NotSpecificStringValue(value = "admin", groups = {Add.class, Update.class})
20 @Size(min = 3, max = 8, groups = {Add.class, Update.class})
21 String name;
22
23 @Email(groups = {Add.class, Update.class})
24 String email;
25
26
27 String password;
28
29 String confirmPassword;
30
31
32 }
需要注意的是,在 3.1.3 的基础上,我们的 collectErrors 方法需要一些改进,让它能够收集类上的错误。改进后的方法:
1 private Map<String, String> collectErrors(BindingResult result) {
2 Map<String, String> errors = new HashMap<>(1);
3 if (result.hasErrors()) {
4 result.getAllErrors().forEach(objectError -> {
5 if (objectError instanceof FieldError) {
6 //Field 上的 FieldError 类型错误
7 FieldError fieldError = ((FieldError) objectError);
8 errors.putIfAbsent(fieldError.getField(), fieldError.getDefaultMessage());
9 } else {
10 //Class 上的 ViolationFieldError 类型错误
11 errors.putIfAbsent(objectError.getObjectName(), objectError.getDefaultMessage());
12 }
13 });
14 }
15 return errors;
16 }
现在进行可以进行测试了(不要忘记按照 3.3 在 properties 文件添加键值对):
这里有一点需要额外注意的是,该类层次错误在 map 中的 key 为驼峰化实体名,而 Field 类注解可以获得 Field 名,因此一个完整的错误示例应该是这样的:
3.3.2.2 多注解(List)测试与坑
你可能注意到我们的定制验证注解有如下代码:
1 @Target({TYPE, ANNOTATION_TYPE})
2 @Retention(RUNTIME)
3 @Documented
4 @interface List {
5 FieldMatch[] value();
6 }
这意味着我们可以同时使用多个 @FieldMatch 注解,这时我们对测试类修改如下
1 /**
2 * @author pancc
3 * @version 1.0
4 * @date 2019/11/2 14:49
5 */
6 @FieldMatch.List({
7 @FieldMatch(first = "password", second = "confirmPassword",
8 message = "两次输入的密码必须相同",
9 groups = UserForm.Add.class),
10 @FieldMatch(first = "address", second = "confirmAddress",
11 message = "两次输入的地址必须相同",
12 groups = UserForm.Add.class)
13 })
14 @Data
15 public class UserForm {
16 public interface Add {
17 }
18
19 public interface Update {
20 }
21
22 @Null(groups = Add.class)
23 @NotNull(groups = Update.class)
24 Long id;
25
26 @NotSpecificStringValue(value = "admin", groups = {Add.class, Update.class})
27 @Size(min = 3, max = 8, groups = {Add.class, Update.class})
28 String name;
29
30 @Email(groups = {Add.class, Update.class})
31 String email;
32
33 String password;
34 String confirmPassword;
35
36 String address;
37 String confirmAddress;
38 }
有个坑,在 3.3.2.1 或者之前的代码中,我们使用了 Map 对象来存放 键-值对错误,然而在这里如果 List 中的两个 @FieldMatch 都发生验证错误时,只会存在一种错误,由于验证的执行顺序不定,结果是只有任意一个错误能映射到 Map 中;因此我们将使用能够存放多个值的 MultiMap,一种可靠的实现存在于 guava 包中,因此我们将引入 guava 的 MVN 依赖:
在 pom.xml 中引入 guava
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.1-jre</version>
</dependency>
修改 Controller 中的逻辑代码,使用 MultiMap 代替 HashMap
1 /**
2 * @author pancc
3 * @version 1.0
4 * @date 2019/11/2 16:52
5 */
6 @Controller
7 public class UserFormController {
8 private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
9
10 @ResponseBody
11 @PostMapping("/add")
12 public String add(@Validated(UserForm.Add.class) UserForm form, BindingResult result) {
13 ListMultimap<String, String> errors = collectErrors(result);
14 if (errors.size() != 0) {
15 /* return errors.toString();*/
16 try {
17 return OBJECT_MAPPER.writeValueAsString(errors.asMap());
18 } catch (JsonProcessingException e) {
19 return "server error";
20 }
21
22 }
23 return "success";
24 }
25
26 @ResponseBody
27 @PostMapping("/update")
28 public String update(@Validated(UserForm.Update.class) UserForm form, BindingResult result) {
29 ListMultimap<String, String> errors = collectErrors(result);
30 if (errors.size() != 0) {
31 /* return errors.toString();*/
32 try {
33 return OBJECT_MAPPER.writeValueAsString(errors.asMap());
34 } catch (JsonProcessingException e) {
35 return "server error";
36 }
37
38 }
39 return "success";
40 }
41
42
43 private ListMultimap<String, String> collectErrors(BindingResult result) {
44 /*Map<String, String> errors = new HashMap<>(1);*/
45 ListMultimap<String, String> errors = LinkedListMultimap.create();
46
47 if (result.hasErrors()) {
48 result.getAllErrors().forEach(objectError -> {
49 if (objectError instanceof FieldError) {
50 //Field 上的 FieldError 类型错误
51 FieldError fieldError = ((FieldError) objectError);
52 errors.put(fieldError.getField(), fieldError.getDefaultMessage());
53 } else {
54 //Class 上的 ViolationFieldError 类型错误
55 errors.put(objectError.getObjectName(), objectError.getDefaultMessage());
56 }
57 });
58 }
59 return errors;
60 }
61 }
这时候进行测试,便可以完整地返回错误信息:
3.3.3 类注解的改进与使用
在上边的测试中,我们在每次使用时都需要手动写 Message,现在让我们改造 @FieldMatch 注解。
第一步,添加 keyWord
第二步,在 FieldMatch.class 或者 properties 文件中,改变 message 的 default 值
修改之后我们的注解类应该是这样子的:
1 /**
2 * 验证两个字段的值是否相等,常见于注册时输入两个密码
3 *
4 * @author pancc
5 * @version 1.0
6 * @date 2019/11/2 17:40
7 */
8 @Target({TYPE, ANNOTATION_TYPE})
9 @Retention(RUNTIME)
10 @Constraint(validatedBy = FieldMatchValidator.class)
11 @Documented
12 public @interface FieldMatch {
13 String message() default " 两次输入的{keyWord}必须相同";
14
15 Class<?>[] groups() default {};
16
17 Class<? extends Payload>[] payload() default {};
18
19 /**
20 * 需要验证的第一字段的字段名<code>String password</code> 中的 <code>password</code>
21 *
22 * @return 第一字段的字段名
23 */
24 String first();
25
26 /**
27 * 需要验证的第二字段的字段名<code>String confirmPassword</code> 中的 <code>confirmPassword</code>
28 *
29 * @return 第一字段的字段名
30 */
31 String second();
32
33 /**
34 * 在 message 中的 keyWord 占位字符串,默认为 "数值"
35 *
36 * @return keyWord 占位字符串
37 */
38 String keyWord() default "数值";
39
40
41 @Target({TYPE, ANNOTATION_TYPE})
42 @Retention(RUNTIME)
43 @Documented
44 @interface List {
45 FieldMatch[] value();
46 }
47 }
现在,当我们进行 3.3.2.2 的测试时,使用的验证注解应该是这样子的:
@FieldMatch.List({
@FieldMatch(first = "password", second = "confirmPassword", keyWord = "密码",
groups = UserForm.Add.class),
@FieldMatch(first = "address", second = "confirmAddress", keyWord = "地址",
groups = UserForm.Add.class)})
4 补充
在上边的内容中,为了简洁我们没有对 Controller 返回的内容格式进行限定,如果你检查 header 的话,会发现 content-type: text/plain,因此,在使用中,别忘记在 @PostMapping 中加上 produces = "application/json",告诉浏览器你返回的是 json