Bean Validation Spring参数校验

设置依赖

spring boot的bean validation 由validation start支持,maven依赖如下:

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

这里可以找到最新的版本。但是如果引用了spring-boot-starter-web,就不要需要引用validation-starter。

基础

本质上来说,validation的工作原理是通过特定的注解修饰对类的字段定义约束。
然后,把类传递给验证器对象,校验字段约束是否满足。
我们将会看到更多的细节通过下面这些例子。

验证 Spring MVC Controller的输入

假设已经实现一个Spring REST 服务,并且想要验证客户端传入的参数,我们可以验证任意HTTP请求的3个部分:

  • request body
  • path variable (e.g. /user/{id})
  • query parameters
    具体看下每个部分的详细
验证request body

在post和get请求中,通用的做法是在request body里面传入一个json串。spring自动把json串映射为一个java对象。现在,我们想要检查这个java对象是否满足需求。
输入的Java对象:

class Input {

  @Min(1)
  @Max(10)
  private int numberBetweenOneAndTen;

  @Pattern(regexp = "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$")
  private String ipAddress;
  
  // ...
}

对象拥有一个取值范围在1-10之间的int类型字段,除此之外,还有一个包含ip地址的字符串类型字段。
从request body中接受参数对象并且验证:

@RestController
class ValidateRequestBodyController {

  @PostMapping("/validateBody")
  ResponseEntity<String> validateBody(@Valid @RequestBody Input input) {
    return ResponseEntity.ok("valid");
  }

}

简单的加个@Valid注解修饰输入的参数,同时用@RequestBody标记应该从request body中解析参数。通过这个注解,我们告诉spring在做其他任何操作之前先把参数对象传递给Validator。
注意:如果待校验对象的某个字段也是需要校验的复杂类型(组合语法),这个字段也需要用@Valid修饰:

@Valid
private ContactInfo contactInfo;

如果校验失败,会触发MethodArgumentNotValidException异常,Spring默认会把这个一场转为400(Bad Request)。
通过集成测试验证下:

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = ValidateRequestBodyController.class)
class ValidateRequestBodyControllerTest {

  @Autowired
  private MockMvc mvc;

  @Autowired
  private ObjectMapper objectMapper;

  @Test
  void whenInputIsInvalid_thenReturnsStatus400() throws Exception {
    Input input = invalidInput();
    String body = objectMapper.writeValueAsString(input);

    mvc.perform(post("/validateBody")
            .contentType("application/json")
            .content(body))
            .andExpect(status().isBadRequest());
  }
}
校验path变量和query参数

验证path变量和query参数有一些细微差别。因为路径变量和请求参数是基本类型例如int 或者他们的包装类型Integer或者String。
直接在Controller方法参数上加注解约束:

@RestController
@Validated
class ValidateParametersController {

  @GetMapping("/validatePathVariable/{id}")
  ResponseEntity<String> validatePathVariable(
      @PathVariable("id") @Min(5) int id) {
    return ResponseEntity.ok("valid");
  }
  
  @GetMapping("/validateRequestParameter")
  ResponseEntity<String> validateRequestParameter(
      @RequestParam("param") @Min(5) int param) { 
    return ResponseEntity.ok("valid");
  }
}

注意,同时必须在类级别机上@Validated注解告诉Spring需要校验方法参数上的约束。
在这种场景里@Validated注解只能修饰类级别,但是,它也允许被用在方法上(允许用在方法级别为了解决validation group。)
校验失败会触发ConstraintViolationException 异常,但是spring没有提供默认处理这个一场的handler,所以会报500(Internal Server Error)。
如果我们想要返回400 替代500,可以在controller中增加以自定义的异常处理。

@RestController
@Validated
class ValidateParametersController {

  // request mapping method omitted
  
  @ExceptionHandler(ConstraintViolationException.class)
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  ResponseEntity<String> handleConstraintViolationException(ConstraintViolationException e) {
    return new ResponseEntity<>("not valid due to validation error: " + e.getMessage(), HttpStatus.BAD_REQUEST);
  }

}

校验Spring service层的参数

前面都是校验controller级别,同时也支持校验任何层级的参数。只需要组合使用@Validated 和 @Valid:

@Service
@Validated
class ValidatingService{

    void validateInput(@Valid Input input){
      // do something
    }

}

同样的,@Validated注解只能作用于类级别,不要放在方法上。测试:

@ExtendWith(SpringExtension.class)
@SpringBootTest
class ValidatingServiceTest {

  @Autowired
  private ValidatingService service;

  @Test
  void whenInputIsInvalid_thenThrowsException(){
    Input input = invalidInput();

    assertThrows(ConstraintViolationException.class, () -> {
      service.validateInput(input);
    });
  }

}

实现自定义的校验器

如果提供的注解约束没有满足使用场景,也可以自己实现一个。
在上面Input类中,我们使用正则表达式来检验字符串字段是否为有效的IP地址,但是这个正则表达式不够完整,他允许每一段超过255.
实现一个IP校验器替代正则表达式。
首先:新建一个IpAddress注解类

@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = IpAddressValidator.class)
@Documented
public @interface IpAddress {

  String message() default "{IpAddress.invalid}";

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

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

}

一个自定义注解约束需要下面这些:

  • message指向ValidationMessages.properties文件中一个参数key。
  • groups允许在不同校验器情况下有不同的校验约束
  • payload 允许一些额外参数传递给校验器
  • @Constraint注解指向实现了ConstraintValidator 接口的Validator。
    Validator的实现像这样:
class IpAddressValidator implements ConstraintValidator<IpAddress, String> {

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    Pattern pattern = 
      Pattern.compile("^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$");
    Matcher matcher = pattern.matcher(value);
    try {
      if (!matcher.matches()) {
        return false;
      } else {
        for (int i = 1; i <= 4; i++) {
          int octet = Integer.valueOf(matcher.group(i));
          if (octet > 255) {
            return false;
          }
        }
        return true;
      }
    } catch (Exception e) {
      return false;
    }
  }
}

现在,就可以使用@IpAddress注解想其他注解约束一样:

class InputWithCustomValidator {

  @IpAddress
  private String ipAddress;
  
  // ...

}

通过编程的方式校验

有一些场景,我想通过程序来调用校验器而不是依赖Spring的支持。
在这种情况下我们可以手动创建一个Validator然后触发校验。

class ProgrammaticallyValidatingService {
  
  void validateInput(Input input) {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();
    Set<ConstraintViolation<Input>> violations = validator.validate(input);
    if (!violations.isEmpty()) {
      throw new ConstraintViolationException(violations);
    }
  }
  
}

不需要Spring支持。
但是,Spring提供了与配置的验证其实例,我们可以直接注入到service中而不是手动去创建它:

@Service
class ProgrammaticallyValidatingService {

  private Validator validator;

  ProgrammaticallyValidatingService(Validator validator) {
    this.validator = validator;
  }

  void validateInputWithInjectedValidator(Input input) {
    Set<ConstraintViolation<Input>> violations = validator.validate(input);
    if (!violations.isEmpty()) {
      throw new ConstraintViolationException(violations);
    }
  }
}

测试:

@ExtendWith(SpringExtension.class)
@SpringBootTest
class ProgrammaticallyValidatingServiceTest {

  @Autowired
  private ProgrammaticallyValidatingService service;

  @Test
  void whenInputIsInvalid_thenThrowsException(){
    Input input = invalidInput();

    assertThrows(ConstraintViolationException.class, () -> {
      service.validateInput(input);
    });
  }

  @Test
  void givenInjectedValidator_whenInputIsInvalid_thenThrowsException(){
    Input input = invalidInput();

    assertThrows(ConstraintViolationException.class, () -> {
      service.validateInputWithInjectedValidator(input);
    });
  }

}

使用不同的验证组验证不同用例下的同一个对象

经常会有两个相同的Service使用同一个领域对象。
比如在实现CRUD操作时,创建操作和更新操作很可能使用用一个对象作为参数,但是在两种情况下可能会触发不同的验证:

  • 创建情况
  • 更新情况
  • 两者同时存在
    Validation Groups允许使用不同的规则来验证。
    刚刚已经看到一个约束注解必须有一个groups字段。他可以被传递到任何一个定义了验证组的Validator里面。
class InputWithGroups {

  @Null(groups = OnCreate.class)
  @NotNull(groups = OnUpdate.class)
  private Long id;
  
  // ...
  
}

会确保ID在创建操作中是空的,而在更新操作中一定不为空。
Spring通过@Validated注解修饰验证组:

@Service
@Validated
class ValidatingServiceWithGroups {

    @Validated(OnCreate.class)
    void validateForCreate(@Valid InputWithGroups input){
      // do something
    }

    @Validated(OnUpdate.class)
    void validateForUpdate(@Valid InputWithGroups input){
      // do something
    }

}

注意:@Validated类再次被用到了类级别,这是因为在告诉Spring需要启动方法上的约束注解(@Min),同时为了激活验证组group,必须把它作用在方法上。

返回结构化的响应

当校验失败时需要返回有意义的错误信息给客户端。为了能让客户端展示错误信息,我们需要返回一个数据结构,其中包含每个错误验证信息。
首先,定义一个返回体:

public class ValidationErrorResponse {

  private List<Violation> violations = new ArrayList<>();

  // ...
}

public class Violation {

  private final String fieldName;

  private final String message;

  // ...
}

然后,定义一个全局的切面处理Controller级别的ConstraintViolationExceptions 异常和Request body级别的MethodArgumentNotValidExceptions异常。

@ControllerAdvice
class ErrorHandlingControllerAdvice {

  @ExceptionHandler(ConstraintViolationException.class)
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  @ResponseBody
  ValidationErrorResponse onConstraintValidationException(
      ConstraintViolationException e) {
    ValidationErrorResponse error = new ValidationErrorResponse();
    for (ConstraintViolation violation : e.getConstraintViolations()) {
      error.getViolations().add(
        new Violation(violation.getPropertyPath().toString(), violation.getMessage()));
    }
    return error;
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  @ResponseBody
  ValidationErrorResponse onMethodArgumentNotValidException(
      MethodArgumentNotValidException e) {
    ValidationErrorResponse error = new ValidationErrorResponse();
    for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
      error.getViolations().add(
        new Violation(fieldError.getField(), fieldError.getDefaultMessage()));
    }
    return error;
  }

}

通过捕获异常并且转换为结构的错误信息返回。

总结

我们已经完成了使用Spring Boot构建应用过程中可能需要所有的校验特性。
当然,复杂的业务规则,建议大家使用Spring或者Guava里面Assert类来判断,比如这种复杂的业务规则判断:

  • column A value is > 10.
  • column B value > 10
  • column A +column B > 30.
    所以,Bean Validation只使用与参数的校验,不要让它参与业务逻辑。
    文章翻译自这里。建议看完原文后,看看下面的讨论,你有疑惑的,美国的工程师也有疑惑。所以文章下面会有很多看文章时不太明白的解答。
    自己整理的记忆思路(约定默认的注解称之为约束注解:@Null,@Min,@NotNull...):
  • Controller类中,校验Dto: @Valid + Dto 约束注解
  • Controller类中,校验路径变量或者参数变量:Class @Validated + Method 约束注解
  • Service层,校验Dto:Class @Validated + Method @Valid + Dto 约束注解
  • Service层,校验普通变量: TODO
    //TODO记忆思路一定不能依赖,最好的记忆办法是去研究源码,理解透彻再回来填这个坑。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335