秒懂SpringBoot之参数验证全解析(@Validated与@Valid)

时间:2020-11-29 01:25:29

[版权申明] 非商业目的注明出处可*转载
出自:shusheng007

概述

在构建 Web 应用程序时,确保进入应用程序的数据有效并满足您的业务需求非常重要。 实现此目的的一种方法是在服务器端验证输入数据。 在这篇博客中,我们将探讨如何在 Spring Boot 应用程序中进行输入数据验证,善用的话可以写出健壮而优美的代码。

让我们从一个实例开始吧

实例

邻家有女初长成,大名唤作牛翠华,家里催翠花找对象,无奈翠花深受互联网女拳师的影响,搞得翠花对另一半的要求非常高…

择偶标准

  • 非王思聪类型不嫁
  • 年龄大于30不嫁
  • 身高矮于185cm不嫁
  • 体重高于85kg不嫁
  • 没有大别墅不嫁
  • 没有大奔驰不嫁
  • 父母建在且没有城市养老金不嫁

假如我们要写一个产生符合其要求的男朋友的API,如何来写呢?

@Slf4j
@RestController
@RequestMapping("/validation")
public class ValidateController {

    @Autowired
    private ValidationService validationService;

    @PostMapping("/boy-friends")
    public ResponseEntity<BoyFriend> createBoyFriend(@RequestBody BoyFriend boy) {
        log.info("create:{}", boy);
        return ResponseEntity.ok(boy);
    }
 }

于是我们今天的主角就登场了。

SpringBoot 验证概述

Spring Boot 使用 Jakarta Bean Validation API 为输入数据验证提供内置支持,Java Bean Validation API 是用于验证 Java 对象的标准 API。 此 API 允许您使用注释定义 Java 类属性的约束,并根据这些约束验证输入数据。

目前一般使用2.0版本,由JSR 380提出。Java提出了这个标准,却没有给出实现,我们使用的都是Hibernate 的实现版本: Hibernate Validator .

引入依赖

要使用Hibernate Validator,所以要引入其依赖。SpringBoot2.3以后必须手动引入如下依赖(2.3以前在web的依赖包中包含了)。

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

使用相关注解标记

jakarta.validation-api模块中提供了很多添加约束的注解,包括

@NotNull @NotEmpty @NotBlank @Min @Max @Email @Size @Pattern  ...

使用时可以查阅源码,都很简单。

public class BoyFriend {
    @Max(30)
    private Integer age;
    ...
}

使用@Valid标记

使用@Valid 标记参数即可。

    @PostMapping("/boy-friends")
    public ResponseEntity<BoyFriend> createBoyFriend(@Valid @RequestBody BoyFriend boy) {
        log.info("create:{}", boy);
        return ResponseEntity.ok(boy);
    }

完成以上三步其实已经可以了,不过在验证失败时会抛出MethodArgumentNotValidException,对前端不友好,实际项目中我们都会对异常做统一处理,然后包装成统一的返回格式。

例如下面这样的格式:

{
    "code": 10000,
    "message": "错误消息",
    "data": xxx
}

统一处理异常

@RestControllerAdvice
public class ExceptionHandlers {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public BaseResponse<String> onMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        String errMsg = e.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + ":" + f.getDefaultMessage())
                .collect(Collectors.joining("| "));
        return new BaseResponse<>(10000, MessageFormat.format("Invalid param: [{0}]", errMsg), null);
    }
    
}

高级用法

一般情况下,上面的方法已经可以应对,但是有些场景需要更多的努力

复杂对象参数验证

有时入参比较复杂,例如BoyFriend里面的house属性也是一个object,我们也需要对House这个对象属性进行验证。我们只需要在house属性申明处添加上@Valid注解即可。

public class BoyFriend {
    @Valid
    @NotNull
    private House house;
}

public class House {
    @AssertTrue
    private Boolean isVilla;
    
    @DecimalMin("100000000")
    private Integer price;
}

基本类型参数验证

有时我们的入参是基本类型,例如使用@RequestParam@PathVariable 标记的参数,这个怎么验证呢?例如下面的入参name。

@Validated
@RestController
@RequestMapping("/validation")
public class ValidateController {
      ...
    @GetMapping("/boy-friends")
    public ResponseEntity<BoyFriend> updateBoyFriend(@NotBlank @RequestParam("name") String name) {
        ...
    }
}

我们只需要给参数添加上相应的约束注解,例如@NotBlank,然后再给Controller类添加上@Validated即可。 @Validated是Spring提供的一个注解。

需要注意的是,这会抛出的是ConstraintViolationException异常,不是MethodArgumentNotValidException,所以需要将此异常也统一处理了,具体看文后源码。

Service方法参数验证

一般情况下我们都是在controller里就把参数验证做了,但是如果我们也想在Service里面的方法使用这套验证机制可以吗?答案是肯定的

  • 使用@Validated标记Service类
  • 使用@Valid注解标记方法入参,如果是基本类型的话只使用约束注解即可,与controller一样。
@Validated
@Service
public class ValidationService {
    public BoyFriend queryBoyFriendByName(@Size(min = 1,max = 3) String name) {
        return BoyFriend.builder().name(name).build();
    }
}

手动验证

有时我们需要手动触发验证而不依赖框架,这也是可以的。

  • 注入Validator的实例
  • 调用其validate方法并处理结果
@RestController
@RequestMapping("/validation")
public class ValidateController {
    @Autowired
    private Validator validator;

    @PatchMapping("/boy-friends")
    public ResponseEntity<BoyFriend> updateBoyFriend(@Valid  @RequestBody BoyFriend boy) {
            Set<ConstraintViolation<House>> validateResults = validator.validate(boy.getHouse());
            
            String errMsg = validateResults.stream().map(e -> e.getPropertyPath() + ":" + e.getMessage()).collect(Collectors.joining("| "));

            throw new BusinessException(errMsg,10002);
         ...
    }
 }

分组验证

有时一个类被多个方法使用,而每个方法对入参的要求却不一样,这种情况怎么办呢?例如我们的BoyFriend,里面有一个体重的属性,创建时要求不能高于85kg,由于条件苛刻,于是修改的时候要求不能高于100kg。

Spring提供了一种解决方法,那就是使用分组。每个注解里面都可以设置其属于哪些分组,在验证的时候只验证属于自己分组的那些约束。

例如我们这里设置两个分组:创建和更新,当调用创建方法的时候就只验证属于创建分组的约束,不高于85kg…

public class BoyFriend {
    @Max(groups = BoyFriendCreate.class, value = 85)
    @Max(groups = BoyFriendUpdate.class, value = 100)
    private Integer weight;
}

如何实现呢?

  • 定义分组

分组必须是接口,例如我们这里定义了两个分组

public interface BoyFriendCreate {
}

public interface BoyFriendUpdate {
}
  • 给约束添加相应的分组
public class BoyFriend {
    @Max(groups = BoyFriendCreate.class, value = 85)
    @Max(groups = BoyFriendUpdate.class, value = 100)
    private Integer weight;
}
  • 给Controller方法添加分组

先给Controller类添加@Validated,然后给方法添加带有分组信息的@Validated

    @Validated(BoyFriendUpdate.class)
    @PatchMapping("/boy-friends")
    public ResponseEntity<BoyFriend> updateBoyFriend(@Valid @RequestBody BoyFriend boy) {
        return ResponseEntity.ok(boy);
    }

注意,这种方法被认为是反模式的,因为其将代码耦合在了一起。本来创建和更新应该是两个不同的类,现在我们却将其耦合在了一起,通过一个分组的信息来区分

自定义约束注解

这是最后一招拉,每当框架提供的不能满足我们的需求时,我们就需要按照自己的需求自定义了,每个优秀的框架和类库都提供了这种能力

假设牛翠华对男朋友要求非常之高,要求必须是指定的某些人,例如王思聪,马化腾…,当然这个需求可以使用@Pattern然后写正则表达式来实现,不过我们这里为了演示就写一个自定义的约束注解来实现。

  • 定义一个约束注解

我们自定义一个注解@Target,前三个属性都是必须的,最后一个value是我们自己的,我们用它来保存目标的名称。

@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TargetManValidator.class)
public @interface TargetMan {
    String message() default "错误的人";

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

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

    String[] value() default {};
}

可以看到我们使用了@Constraint(validatedBy = TargetManValidator.class)来标记@TargerMan注解,所以我们还需要实现一个TargetManValidator

  • 实现一个TargetManValidator

具体的验证逻辑就是在这个类里面的。

public class TargetManValidator implements ConstraintValidator<TargetMan,String> {
    private List<String> values = new ArrayList<>();
    
    @Override
    public void initialize(TargetMan constraintAnnotation) {
       //从TargetMan 注解中获取用户设置的姓名列表
       values = Arrays.stream(constraintAnnotation.value()).collect(Collectors.toList());
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return values.contains(value);
    }
}

我们的验证逻辑就在isValid方法中,如果用户输入的姓名不在设置的姓名集合之中就返回FALSE。

  • 使用
public class BoyFriend {
    @NotBlank
    @TargetMan({"马化腾", "王思聪", "王二狗"})
    private String name;
}

JPA Entity验证

你自己加几个约束注解试试…

概述

今天就到这啦,希望小朋友们都学头秃了… 对了,最后牛翠华嫁给了王二狗,至少都姓王,O(∩_∩)O哈哈~ 。

我在此为牛翠华正名:说翠花打拳只是为行文方便,翠花是一个温婉贤良,上孝下敬的好女子…

源码

一如既往,你可以在首发找到本文源码:秒懂SpringBoot参数验证全解析(@Validated与@Valid),小星星点一点,需要的时候方便找的到。

参考文章:Validation with Spring Boot - the Complete Guide

题外话

最近小区物业群里警察叔叔经常发送辖区被电信诈骗的案例。我从头到尾只有两个感觉,一个是这些人怎么这么有钱?二是这些人怎么这么傻?基本上老人妇女死于刷单理财,壮男死于约炮…。我觉得要是骗我估计有点困难,不是我聪明主要人到中年一没钱,而没啥兴趣,骗子都绕着走啊,o( ̄︶ ̄)o…