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 

 

 

 

 

posted on 2019-11-09 15:57  四维胖次  阅读(1805)  评论(1编辑  收藏  举报