一起来学SpringBoot(十七)优雅的参数校验

参数校验

在开发中经常需要写一些字段校验的代码,比如字段非空,字段长度限制,邮箱格式验证等等,写这些与业务逻辑关系不大的代码个人感觉有两个麻烦:

  • 验证代码繁琐,重复劳动
  • 方法内代码显得冗长
  • 每次要看哪些参数验证是否完整,需要去翻阅验证逻辑代码

你看这样?我感觉不行 ~有啥好办法不

public String test1(String name) {
if (name == null) {
throw new NullPointerException("name 不能为空");
}
if (name.length() < 2 || name.length() > 10) {
throw new RuntimeException("name 长度必须在 2 - 10 之间");
}
return "success";
}

使用hibernate-validator

spring-boot-starter-web包里面有hibernate-validator包,不需要引用hibernate validator依赖。在 pom.xml 中添加上 spring-boot-starter-web 的依赖即可

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

创建如下实体

@Data
public class Book {
private Integer id;
@NotBlank(message = "name 不允许为空")
@Length(min = 2, max = 10, message = "name 长度必须在 {min} - {max} 之间")
private String name;
}

实体校验

然后呢在 controller 中这样写即可验证

验证加@RequestBody 的参数

@RequestMapping("/test")
public String test(@Validated @RequestBody Book book) {
return "success";
}

这时候呢会出现MethodArgumentNotValidException异常可以在ControllerAdvice 做全局异常处理

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(Exception.class)
public ResponseEntity<ModelMap> ex(Exception e) {
log.error("请求参数不合法。", e);
ModelMap modelMap = new ModelMap();
if (e instanceof MethodArgumentNotValidException) {
modelMap.put("message", getErrors(((MethodArgumentNotValidException) e).getBindingResult()));
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(modelMap);
}

private Map<String, String> getErrors(BindingResult result) {
Map<String, String> map = new HashMap<>();
List<FieldError> list = result.getFieldErrors();
for (FieldError error : list) {
map.put(error.getField(), error.getDefaultMessage());
}
return map;
}

如果不加呢?

@RequestMapping("/test")
public String test(@Validated Book book) {
return "success";
}

则会出BindException 异常,则又可以在ControllerAdvice 中加入判断

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(Exception.class)
public ResponseEntity<ModelMap> ex(Exception e) {
log.error("请求参数不合法。", e);
ModelMap modelMap = new ModelMap();
if (e instanceof BindException) {
modelMap.put("message", getErrors(((BindException) e).getBindingResult()));
} else if (e instanceof MethodArgumentNotValidException) {
modelMap.put("message", getErrors(((MethodArgumentNotValidException) e).getBindingResult()));
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(modelMap);
}
private Map<String, String> getErrors(BindingResult result) {
Map<String, String> map = new HashMap<>();
List<FieldError> list = result.getFieldErrors();
for (FieldError error : list) {
map.put(error.getField(), error.getDefaultMessage());
}
return map;
}

验证参数

如果是get请求参数呢?

@RequestMapping("/test")
public String test(@Validated @NotBlank(message = "name 不允许为空") String name) {
System.out.println("111");
return "success";
}

我们发现这样根本不好使,其实呢这种需要在类上加入

@Validated
@RestController
public class TestController {
@RequestMapping("/test")
public String test(@NotBlank(message = "name 不允许为空") String name) {
System.out.println("111");
return "success";
}
}

这样才可以生效,此时呢返回ConstraintViolationException 异常可以在全局异常中这样处理

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(Exception.class)
public ResponseEntity<ModelMap> ex(Exception e) {
log.error("请求参数不合法。", e);
ModelMap modelMap = new ModelMap();
if (e instanceof HttpMediaTypeException) {
modelMap.put("message", "请求体不对");
} else if (e instanceof ConstraintViolationException) {
ConstraintViolationException exs = (ConstraintViolationException) e;
Set<ConstraintViolation<?>> violations = exs.getConstraintViolations();
modelMap.put("message", getErrors(violations));
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(modelMap);
}
private Map<String, String> getErrors(Set<ConstraintViolation<?>> violations) {
Map<String, String> map = new HashMap<>();
for (ConstraintViolation<?> item : violations) {
map.put(item.getPropertyPath().toString(), item.getMessage());
}
return map;
}

Model校验

如过不是想验证传参呢?就是想验证一个实体怎么玩呢?

这样就可以解决了

@RestController
public class TestController {
@Autowired
private Validator validator;
@RequestMapping("/test")
public Map<String, String> test() {
Book book = new Book();
book.setId(1).setName("");
Set<ConstraintViolation<Book>> violationSet = validator.validate(book);
return getErrors(violationSet);
}
private <T> Map<String, String> getErrors(Set<ConstraintViolation<T>> violations) {
Map<String, String> map = new HashMap<>();
for (ConstraintViolation<?> item : violations) {
map.put(item.getPropertyPath().toString(), item.getMessage());
}
return map;
}
}

对象级联校验

在比如book那个实体中加入了一个具有对象这时候改怎么办呢?

只需要在实体上加入@Valid 即可

@Data
@Accessors(chain = true)
public class Book {

private Integer id;
@NotBlank(message = "name 不允许为空")
@Length(min = 2, max = 10, message = "name 长度必须在 {min} - {max} 之间")
private String name;
@Valid
private Author author;

@Data
@Accessors(chain = true)
public static class Author {
@NotBlank(message = "Author.name 不允许为空")
private String name;
}
}

hibernate-validator 的校验模式

这时候要说点东西了

上面例子中一次性返回了所有验证不通过的集合,通常按顺序验证到第一个字段不符合验证要求时,就可以直接拒绝请求了。Hibernate Validator有以下两种验证模式

  1. 普通模式(默认是这个模式)

普通模式(会校验完所有的属性,然后返回所有的验证失败信息)

  1. 快速失败返回模式

快速失败返回模式(只要有一个验证失败,则返回)

`true` 快速失败返回模式    `false`普通模式
ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
.configure()
.failFast( true )
.buildValidatorFactory();
Validator validator = validatorFactory.getValidator();

或者这样配

ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
.configure()
.addProperty( "hibernate.validator.fail_fast", "true" )
.buildValidatorFactory();
Validator validator = validatorFactory.getValidator();

这样配置就行了

@Bean
public MethodValidationPostProcessor methodValidationPostProcessor() {
MethodValidationPostProcessor postProcessor = new MethodValidationPostProcessor();
/**设置validator模式为快速失败返回*/
postProcessor.setValidator(validator());
return postProcessor;
}

@Bean
public Validator validator(){
ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
.configure()
.addProperty( "hibernate.validator.fail_fast", "true" )
.buildValidatorFactory();
Validator validator = validatorFactory.getValidator();
return validator;
}

分组校验

分组顺序校验时,按指定的分组先后顺序进行验证,前面的验证不通过,后面的分组就不行验证。

有这样一种场景,新增的时候,不需要验证Id(因为系统生成);修改的时候需要验证Id,这时候可用用户到validator的分组验证功能。

设置validator为普通验证模式("hibernate.validator.fail_fast", "false"),用到的验证GroupA、GroupB和实体:

GroupA、GroupB
public interface GroupA {
}
public interface GroupB {
}

然后改造一下Book实体

@Data
@Accessors(chain = true)
public class Book {
@NotBlank
@Range(min = 1, max = Integer.MAX_VALUE, message = "必须大于0", groups = {GroupA.class})
private Integer id;
@NotBlank(message = "name 不允许为空")
@Length(min = 4, max = 20, message = "name 长度必须在 {min} - {max} 之间", groups = {GroupB.class})
private String name;
@NotBlank
@Range(min = 0, max = 100, message = "年龄必须在[0,100]", groups = {Default.class})
private Integer age;
}
  • GroupA验证字段id;
  • GroupB验证字段name;
  • Default验证字段age(Default是Validator自带的默认分组)

这样去验证

@RequestMapping("/test")
public void test() {
Book book = new Book();
/**GroupA验证不通过*/
book.setId(-10086);
/**GroupA验证通过*/
//book.setId(10010);
book.setName("a");
book.setAge(110);
Set<ConstraintViolation<Book>> validate = validator.validate(book, GroupA.class, GroupB.class);
for (ConstraintViolation<Book> item : validate) {
System.out.println(item);
}
}

或者这样

@RequestMapping("/test")
public void test(@Validated({GroupA.class, GroupB.class}) Book book, BindingResult result) {
if (result.hasErrors()) {
List<ObjectError> allErrors = result.getAllErrors();
for (ObjectError error : allErrors) {
System.out.println(error);
}
}
}

当然这样验证务必要给 组一个序列,不然不行的还是无法实现

@GroupSequence({GroupA.class, GroupB.class, Default.class})
public interface GroupOrder {
}

这样就好了然后这样玩

Set<ConstraintViolation<Book>> validate = validator.validate(book, GroupOrder.class);
@Validated({GroupOrder.class})Book book, BindingResult result

注意项

如果不想全局拦截异常想看到直观的错误可以在方法参数中加入BindingResult result

单一的可以这样玩

public void test()(@Validated DemoModel demo, BindingResult result)

验证多个的话可以这样玩

public void test()(@Validated DemoModel demo, BindingResult result,@Validated DemoModel demo2, BindingResult result2)

自定义验证器

一般情况,自定义验证可以解决很多问题。但也有无法满足情况的时候,此时,我们可以实现validator的接口,自定义自己需要的验证器。

首先呢定义个注解,在注解上加入注解@Constraint 绑定验证类

@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Constraint(validatedBy = DateTimeValidator.class)
public @interface DateTime {

String message() default "格式错误";

String format() default "yyyy-MM-dd";

Class<?>[] groups() default {};

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

然后看验证类 实现ConstraintValidator<A extends Annotation, T>即可 A是注解 T是标注的参数

public class DateTimeValidator implements ConstraintValidator<DateTime, String> {

private DateTime dateTime;

@Override
public void initialize(DateTime dateTime) {
this.dateTime = dateTime;
}

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 如果 value 为空则不进行格式验证,为空验证可以使用 @NotBlank @NotNull @NotEmpty 等注解来进行控制,职责分离
if (value == null) {
return true;
}
String format = dateTime.format();
if (value.length() != format.length()) {
return false;
}
SimpleDateFormat simpleDateFormat = new SimpleDateFormat(format);
try {
simpleDateFormat.parse(value);
} catch (ParseException e) {
return false;
}
return true;
}
}

然后这样用就行啦

@Validated
@RestController
public class ValidateController {
@GetMapping("/test")
public String test(@DateTime(message = "您输入的格式错误,正确的格式为:{format}", format = "yyyy-MM-dd HH:mm") String date) {
return "success";
}
}

JSR-303 注释介绍

hibernate-validator均实现了 JSR-303 这里只列举了 javax.validation 包下的注解,同理在 spring-boot-starter-web 包中也存在 hibernate-validator 验证包,里面包含了一些 javax.validation 没有的注解,有兴趣的可以看看

注解 说明
@NotNull 限制必须不为null
@NotEmpty 验证注解的元素值不为 null 且不为空(字符串长度不为0、集合大小不为0)
@NotBlank 验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的空格
@Pattern(value) 限制必须符合指定的正则表达式
@Size(max,min) 限制字符长度必须在 min 到 max 之间(也可以用在集合上)
@Email 验证注解的元素值是Email,也可以通过正则表达式和flag指定自定义的email格式
@Max(value) 限制必须为一个不大于指定值的数字
@Min(value) 限制必须为一个不小于指定值的数字
@DecimalMax(value) 限制必须为一个不大于指定值的数字
@DecimalMin(value) 限制必须为一个不小于指定值的数字
@Null 限制只能为null(很少用)
@AssertFalse 限制必须为false (很少用)
@AssertTrue 限制必须为true (很少用)
@Past 限制必须是一个过去的日期
@Future 限制必须是一个将来的日期
@Digits(integer,fraction) 限制必须为一个小数,且整数部分的位数不能超过 integer,小数部分的位数不能超过 fraction (很少用)

哦对,这些校验不仅能在controller层用 在任何地方都可以的

本博文是基于springboot2.x 如果有什么不对的请在下方留言。