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