Spring|Spring Boot demo系列(四)(Spring Web+Validation)

1 概述 本文主要讲述了如何使用Hibernate Validator以及@Valid/@Validate注解。
2 校验 对于一个普通的Spring Boot应用,经常可以在业务层看到以下类似的操作:

if(id == null) {...} if(username == null) {...} if(password == null) {...}

这是很正常的,但是会显得代码很繁琐,一个更好的做法就是使用Hibernate Validator
3 Hibernate Validator JSRJava Specification Requests的缩写,意思是Java规范提案JSR-303Java EE 6的一项子规范,叫作Bean ValidationHibernate ValidatorBean Validator的参考实现。其中JSR-303内置constraint如下:
  • @Null:被注解元素必须为null
  • @NotNull:必须不为null
  • @AssertTrue/@AssertFalse:必须为true/false
  • @Min(value)/@Max(value):指定最小值/最大值(可以相等)
  • @DecimalMin(value)/DecimalMax(value):指定最小值/最大值(不能相等)
  • @Size(min,max):大小在给定范围
  • @Digits(integer,fraction):将字符串转为浮点数,并且规定整数位数最大integer位,小数位数最大fraction
  • @Past:必须是一个过去日期
  • @Future:必须是将来日期
  • @Pattern:必须符合正则表达式
其中Hibernate Validator添加的constraint如下:
  • @Email:必须符合邮箱格式
  • @Length(min,max):字符串长度范围
  • @Range:数字在指定范围
而在Spring中,对Hibernate Validator进行了二次封装,添加了自动校验并且可以把校验信息封装进特定的BindingResult中。
4 基本使用 注解直接在实体类的对应字段加上即可:
@Setter @Getter public class User { @NotBlack(message = "邮箱不能为空") @Email(message = "邮箱非法") private String email; @NotBlack(message = "电话不能为空") private String phone; }

控制层:
@CrossOrigin(value = "http://localhost:3000") @RestController public class TestController { @PostMapping("/test") public boolean test(@RequestBody @Valid User user) { return true; } }

测试:
Spring|Spring Boot demo系列(四)(Spring Web+Validation)
文章图片
在这里插入图片描述 Spring|Spring Boot demo系列(四)(Spring Web+Validation)
文章图片
在这里插入图片描述 Spring|Spring Boot demo系列(四)(Spring Web+Validation)
文章图片
在这里插入图片描述 可以看到把phone字段留空或者使用非法邮箱格式时直接抛出异常。
5 异常处理 前面说过校验出错会把异常放进BindingResult中,具体的处理方法就是加上对应参数即可,控制层修改如下:
@PostMapping("/test") public boolean test(@RequestBody @Valid User user, BindingResult result) { if(result.hasErrors()) result.getAllErrors().forEach(System.out::println); return true; }

可以通过getAllErrors获取所有的错误,这样就可以对具体错误进行处理了。
6 快速失败模式 Hibernate Validator有两种校验模式:
  • 普通模式:默认,检验所有属性,然后返回所有验证失败信息
  • 快速失败模式:只要有一个验证失败便返回
使用快速失败模式需要通过HiberateValidateConfiguration以及ValidateFactory创建Validator,并且使用Validator.validate手动校验,首先可以添加一个生成Validator的类:
import org.hibernate.validator.HibernateValidator; import org.springframework.context.annotation.Configuration; import java.util.Set; import javax.validation.ConstraintViolation; import javax.validation.Validation; import javax.validation.Validator; @Configuration public class FailFastValidator { private final Validator validator; public FailFastValidator() { validator = Validation .byProvider(HibernateValidator.class) .configure() .failFast(true) .buildValidatorFactory() .getValidator(); }public Set> validate(User user) { return validator.validate(user); } }

接着修改控制层,去掉User上的@Valid,同时注入validator进行手动校验:
import com.example.demo.entity.User; import com.example.demo.failfast.FailFastValidator; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import javax.validation.ConstraintViolation; import java.util.Set; @CrossOrigin(value = "http://localhost:3000") @RestController @RequiredArgsConstructor(onConstructor = @__(@Autowired)) public class TestController { private final FailFastValidator validator; @PostMapping("/test") public boolean test(@RequestBody User user) { Set> message = validator.validate(user); message.forEach(System.out::println); return true; } }

这样一旦校验失败便会返回,而不是校验完所有的字段记录所有错误信息再返回。
7 @Valid@Validated @Valid位于javax.validation下,而@Validated位于org.springframework.validation.annotation下,是@Valid的一次封装,在@Valid的基础上,增加了分组以及组序列的功能,下面分别进行介绍。
7.1 分组 当不同的情况下需要不同的校验方式时,可以使用分组功能,比如在某种情况下需要注册时不需要校验邮箱,而修改信息的时候需要校验邮箱,则实体类可以如下设计:
@Setter @Getter public class User { @NotBlank(message = "邮箱不能为空",groups = GroupB.class) @Email(message = "邮箱非法",groups = GroupB.class) private String email; @NotBlank(message = "电话不能为空",groups = {GroupA.class,GroupB.class}) private String phone; public interface GroupA{} public interface GroupB{} }

接着修改控制层,并使用@Validate代替原来的@Valid
public class TestController { @PostMapping("/test") public boolean test(@RequestBody @Validated(User.GroupA.class) User user) { return true; } }

GroupA的情况下,只校验电话,测试如下:
Spring|Spring Boot demo系列(四)(Spring Web+Validation)
文章图片
在这里插入图片描述 而如果修改为GroupB
public boolean test(@RequestBody @Validated(User.GroupB.class) User user)

这样就邮箱与电话都校验:
Spring|Spring Boot demo系列(四)(Spring Web+Validation)
文章图片
在这里插入图片描述 7.2 组序列 默认情况下,校验是无序的,也就是说,对于下面的实体类:
public class User { @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱非法") private String email; @NotBlank(message = "电话不能为空") private String phone; }

先校验哪一个并没有固定顺序,修改控制层如下,返回错误信息:
@PostMapping("/test") public String test(@RequestBody @Validated User user, BindingResult result) { for (ObjectError allError : result.getAllErrors()) { return allError.getDefaultMessage(); } return "true"; }

可以看到两次测试的结果不同:
Spring|Spring Boot demo系列(四)(Spring Web+Validation)
文章图片
在这里插入图片描述 Spring|Spring Boot demo系列(四)(Spring Web+Validation)
文章图片
在这里插入图片描述 因为顺序不固定,而如果指定了顺序:
public class User { @NotBlank(message = "邮箱不能为空",groups = First.class) @Email(message = "邮箱非法",groups = First.class) private String email; @NotBlank(message = "电话不能为空",groups = Third.class) private String phone; public interface First{} public interface Second{} public interface Third{} @GroupSequence({First.class,Second.class,Third.class}) public interface Group{} }

同时控制层指定顺序:
public String test(@RequestBody @Validated(User.Group.class) User user, BindingResult result)

这样就一定会先校验First,也就是先校验邮箱是否为空。
8 自定义注解 尽管使用上面的各种注解已经能解决很多情况了,但是对于一些特定的情况,需要一些特别的校验,而自带的注解不能满足,这时就需要自定义注解了,比如上面的电话字段,国内的是11位的,而且需要符合某些条件(比如默认区号+86等),下面就自定义一个专门用于手机号码的注解:
@Documented @Constraint(validatedBy = PhoneValidator.class) @Target({ElementType.FIELD,ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface Phone { String message() default "请使用合法的手机号码"; Class [] groups() default {}; Class [] payload() default {}; }

同时定义一个验证类:
public class PhoneValidator implements ConstraintValidator { @Override public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { if(s.length() != 11) return false; return Pattern.matches("^((17[0-9])|(14[0-9])|(13[0-9])|(15[^4,\\D])|(18[0,5-9]))\\d{8}$",s); } }

接着修改实体类,加上注解即可:
@Phone @NotBlank(message = "电话不能为空") private String phone;

测试如下,可以看到虽然是11位了,但是格式非法,因此返回相应信息:
Spring|Spring Boot demo系列(四)(Spring Web+Validation)
文章图片
在这里插入图片描述 9 来点AOP 默认情况下Hibernate Validator不是快速失败模式的,但是如果配成快速失败模式就不能用@Validate了,需要手动实例化一个Validator,这是一种很麻烦的操作,虽然说可以利用组序列“伪装”成一个快速失败模式,但是有没有更好的解决办法呢?
有!
就是。。。
自己动手使用AOP实现校验。
9.1 依赖 AOP这种高级的东西当然是用别人的轮子啊:
org.springframework.boot spring-boot-starter-aop

9.2 验证注解 首先自定义一个验证注解,这个注解的作用类似@Validate
public @interface UserValidate {}

9.3 字段验证 自定义一些类似@NotEmpty等的注解:
@Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface MyEmail { String message() default "邮箱不能为空,且需要一个合法的邮箱"; int order(); }@Documented @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface MyPhone { String message() default "电话不能为空,且需要一个合法的电话"; int order(); }

9.4 定义验证器
@Aspect @Component public class UserValidator { @Pointcut("@annotation(com.example.demo.aop.UserValidate)") public void userValidate(){}@Before("userValidate()") public void validate(JoinPoint point) throws EmailException, PhoneException, IllegalAccessException { User user = (User)point.getArgs()[0]; TreeMap treeMap = new TreeMap<>(); HashMap allFields = new HashMap<>(); for (Field field : user.getClass().getDeclaredFields()) { field.setAccessible(true); for (Annotation annotation : field.getAnnotations()) { if(annotation.annotationType() == MyEmail.class) { treeMap.put(((MyEmail)annotation).order(),annotation); allFields.put(((MyEmail)annotation).order(),field.get(user)); } else if(annotation.annotationType() == MyPhone.class) { treeMap.put(((MyPhone)annotation).order(),annotation); allFields.put(((MyPhone)annotation).order(),field.get(user)); } } } for (Map.Entry entry : treeMap.entrySet()) { Class type = entry.getValue().annotationType(); if(type == MyEmail.class) { validateEmail((String)allFields.get(entry.getKey())); } else if(type == MyPhone.class) { validatePhone((String)allFields.get(entry.getKey())); } } }private static void validateEmail(String s) throws EmailException { throw new EmailException(); }private static void validatePhone(String s) throws PhoneException { throw new PhoneException(); } }

这个是实现校验的核心,首先定义一个切点:
@Pointcut("@annotation(com.example.demo.aop.UserValidate)") public void userValidate(){}

该切点应用在注解@UserValidate上,接着定义验证方法validate,首先通过切点获取其中的参数以及参数中的注解,并且模拟了组序列,先使用TreeMap进行排序,最后针对遍历该TreeMap,对不同的注解分别调用不同的方法校验。
实体类简单定义顺序即可:
public class User { @MyEmail(order = 2) private String email; @MyPhone(order = 1) private String phone; }

控制类中的注解定义在方法上:
@PostMapping("/test") @UserValidate public String test(@RequestBody User user) { return "true"; }

这样就自定义实现了一个简单的JSR-303了。
【Spring|Spring Boot demo系列(四)(Spring Web+Validation)】当然该方法还有很多的不足,比如需要配合全局异常处理,不然的话会直接抛出异常:
在这里插入图片描述 前端也是直接返回异常:
Spring|Spring Boot demo系列(四)(Spring Web+Validation)
文章图片
在这里插入图片描述 一般情况下还是推荐使用Hibernate Validator,应对常规情况足够了。
10 参考源码 Java版:
  • Github
  • 码云
Kotlin版:
  • Github
  • 码云

    推荐阅读