在 Spring Boot 使用Bean Validation 完全指南

欧阳楚
2023-12-01

1 前言

Bean Validation是 Java 生态圏中实现Bean校验规范的事实上的标准。 它与 Spring 和 Spring Boot 能很好地集成在一起。

但是,也存在一些问题。 本教程详细介绍了所有主要的校验用例和每个用例的代码示例。

代码示例
他的文章附有 GitHub 上的工作代码示例。

2 使用 Spring Boot Validation Starter

Spring Boot 的 Bean Validation 支持起步依赖starter,我们可以将其包含到我们的项目中(在Gradle项目构建工具中):

implementation('org.springframework.boot:spring-boot-starter-validation')

这没有必要添加版本号,因为 Spring Dependency Management Gradle 插件会为我们加上父依赖中统一管理的版本号。 如果您不使用该插件,可以在此处找到最新版本。

但是,如果我们的项目中已经包含了 web starter,那么validation starter 会自动包含在其中,而不需要再额外引入validation starter

implementation('org.springframework.boot:spring-boot-starter-web')

请注意,validation starter只是向兼容版本的Hibernate Validator添加依赖(这是最被广泛使用的Bean Validation 规范实现库)

3 Bean Validation 基础

大体上,Bean Validation 的工作原理是通过使用某些注解标记类的字段来定义对类字段的约束。

1) 常用的Validation注解

一些最常见的验证注解如下:

  • @NotNull: 标记字段不能为 null
  • @NotEmpty: 标记集合字段不为空(至少要有一个元素)
  • @NotBlank: 标记字段串字段不能是空字符串(即它必须至少有一个字符)
  • @Min / @Max: 标记数字类型字段必须大于/小于指定的值
  • @Pattern: 标记字符串字段必须匹配指定的正则表达式
  • @Email: 标记字符串字段必须是有效的电子邮件地址

请看如下一个类字段约束的示例

class Customer {

  @Email
  private String email;

  @NotBlank
  private String name;
  
  // ...
}

2) 校验器Validator

为了验证一个对象是否有效,我们可以将它传递给一个 Validator 对象来检查是否满足约束:

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

更多以编程方式使用Validator请打开链接https://reflectoring.io/bean-validation-with-spring-boot/#validating-programmatically。

3) @Validated@Valid

在许多情况下,Spring 会自动为我们提供校验能力,我们几乎不需要自己创建验证器对象。并且,我们可以让 Spring 知道我们想要校验某个对象。 这主要是通过使用@Validated@Valid 注解来实现的。

@Validated 注解是一个类级别的注解,我们可以使用它来告诉 Spring 某方法的参数需要校验。 接下我们将在Controller层中的路径变量和简单请求参数使用它,并了解其更多的用法

我们可以在方法参数和字段上加上@Valid 注解来告诉 Spring 我们想要对一个方法参数或字段被验证。 我们将在Controlle层中的请求实体中使用它,并了解其更多的用法。

4 在Spring MVC Controller中校验入参

假设我们已经实现了一个 Spring REST Controller并且想要验证客户端传入的入参。 对于任何传入的 HTTP 请求,我们可以校验三种参数:

  • 请求实体(request body)
  • 路径变量 (如 /foos/{id} 中的id参数)
  • 查询参数 (query parameters)

接下让我们更详细地了解如何校验这三种参数

1) 校验请求实体(request body)

在 POST 和 PUT 请求中,通常在请求实体中传递 JSON 数据。 Spring 自动将传入的 JSON 映射到 Java 对象参数上。 现在,我们要校验传入的 Java 对象是否满足我们预先定义的约束条件。

这是我们将要传入的Http请求实体类:

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;
  
  // ...
}

我们有一个 int类型 字段,它的值必须介于 1 和 10 之间,如@Min@Max 注解所定义的那样。 我们还有一个 String 类型字段,它必须是一个 IP 地址,正如@Pattern 注解中的正则表达式所定义的那样(正则表达式实际上仍然允许大于 255 的无效 IP 地址,但在我们创建自定义验证器时,我们将在本教程的后面修复这个BUG)。

为了校验传入 HTTP 请求的请求实体,我们在 REST 控制器中使用 @Valid 注解对请求实体进行标记:

@RestController
class ValidateRequestBodyController {

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

}

我们只是在 入参中添加了 @Valid 注解,该参数也用 @RequestBody注解标记过,使其从HTTP请求体(requet body)中映射各个字段。 这样,我们就告诉了 Spring 框架在执行任何其他操作之前先将此入参对象传递给 Validator校验器。

在复合类型上使用 @Valid
如果 入参类还包含待校验的另一种复杂类型的字段,则该字段也需要使用 @Valid注解进行标记。

如果校证失败,则会触发 MethodArgumentNotValidException异常。 默认情况下,Spring 会将此异常转换为 HTTP 状态 码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());
  }
}

可以在 @WebMvcTest 注解文章中找到有关测试 Spring MVC 控制器的更多详细信息

2) 校验路径变量和查询参数

校验路径变量、查询参数方式与查验请求实体略有不同。

在这种情况下,我们不会验证复杂的 Java 对象,因为路径变量和请求参数是原始类型(如 int)或其包装类型(如 IntegerString)。

我们没有像上面那样去标记类字段,而是直接向 Spring 控制器中的方法参数添加校验注解(在本例中使用 @Min):

@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");
  }
}

请注意,我们必须在类级别将 Spring 的 @Validated 注解添加到Conroller类上,以此通知 Spring 要注意方法参数上的校验注解。

在这种情况下,@Validated 注解仅在类级别上进行校验处理,即使它允许用于方法级别(稍后讨论分组校验时,我们将了解为什么允许在方法级别上使用它)。

与请求体验证相反,失败的验证将触发 ConstraintViolationException 异常而不是 MethodArgumentNotValidException异常。 Spring 不会为此异常注册默认异常处理器,因此在默认情况下它会导致 HTTP 状态码为 500(Internal Server Error)的响应。

如果我们想返回一个 HTTP 状态码为 400(这样处理是有道理的,因为客户端输入了一个无效的参数,并使它成为了一个bad request),我们可以向我们的控制器添加一个自定义异常处理器:

@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);
  }

}

在本教程的后面,我们将研究如何返回统一的错误响应结构,其中包含所有验证失败的详细信息,供客户端排查错误原因。

我们可以通过集成测试框架来验证结果:

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = ValidateParametersController.class)
class ValidateParametersControllerTest {

  @Autowired
  private MockMvc mvc;

  @Test
  void whenPathVariableIsInvalid_thenReturnsStatus400() throws Exception {
    mvc.perform(get("/validatePathVariable/3"))
            .andExpect(status().isBadRequest());
  }

  @Test
  void whenRequestParameterIsInvalid_thenReturnsStatus400() throws Exception {
    mvc.perform(get("/validateRequestParameter")
            .param("param", "3"))
            .andExpect(status().isBadRequest());
  }

}

5 校验Spring Service 方法入参

除了在Controller层校验入参之外,我们还可以校验任何 Spring Bean 的入参。 为此,我们可结合使用 @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);
    });
  }

}

6 校验JPA实体类

通常我们校验的最后一道防线是持久层。 默认情况下,Spring JPA在底层使用 Hibernate,它支持开箱即用的 Bean 校验。

在持久层中校验合适吗?
我们通常不希望在持久层中进行校验,因为这意味着上层的业务代码已经引入了可能导致无法预料的错误或潜在无效参数。 在我关于反 Bean Validation 模式的文章中详细介绍了这个主题。

假设要将 Input 类的对象存储到数据库中。 首先,我们添加必要的 JPA 注解 @Entity 并添加一个 ID 字段:

@Entity
public class Input {

  @Id
  @GeneratedValue
  private Long id;

  @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;
  
  // ...
  
}

然后,我们创建一个 Spring Data Repository,它为我们提供了持久化和查询 Input 对象的方法:

public interface ValidatingRepository extends CrudRepository<Input, Long> {
    
}

默认情况下,每当我们使用违反校验注解的 Input 对象时,这里都会产生一个 ConstraintViolationException 异常,正如这个测试所演示的那样:

@ExtendWith(SpringExtension.class)
@DataJpaTest
class ValidatingRepositoryTest {

  @Autowired
  private ValidatingRepository repository;

  @Autowired
  private EntityManager entityManager;

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

    assertThrows(ConstraintViolationException.class, () -> {
      repository.save(input);
      entityManager.flush();
    });
  }

}

您可以在 @DataJpaTest 注解文章中找到有关测试 Spring Data Repository的更多详细信息

请注意,Bean 校验仅在 EntityManager 刷新后由 Hibernate 触发。 在某些情况下,Hibernate 会自动刷新 EntityManager,但在我们使用集成测试框架时,我们必须手动执行此操作。

如果出于某些原因我们想在我们的 Spring Data Repository中禁用 Bean Validation,我们可以将 Spring Boot 属性 spring.jpa.properties.javax.persistence.validation.mode 设置为 none

7 在Spring Boot中使用自定义校验器

如果官方提供可用的校验注解不能满足我们的需要,我们自己可以定义一个自定义校验器。

在上面的 Input 类中,我们使用正则表达式来验证字符串是否是有效的 IP 地址。 然而,正则表达式并不完善:它允许值大于 255 的数字(即“111.111.111.333”将被视为有效)。

让我们通自定义校验器来解决这个问题,该验证器使用 Java 代码而不是使用正则表达式来实现此校验逻辑(是的,我知道我们可以使用更复杂的正则表达式来达到相同的效果,但我们喜欢在 Java 中实现验证)。

首先,我们创建自定义校验注解 @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 文件中的属性键,用于在校验失败时解析提示消息,
  • 参数 groups, 允许定义在何种情况下触发此校验(稍后我们将讨分组校验)
  • 参数payload, 允许定义要通过此校验传递的Payload(因为这是一个很少使用的功能,我们不会在本教程中介绍它)
  • 一个 @Constraint 注解, 指定实现 ConstraintValidator 接口的校验逻辑类。

校验器的实现如下所示:

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;
  
  // ...

}

8 以编程方式校验

在某些情况下,我们希望以编程方式调用校验而不是依赖 Spring 的内置 Bean校验。 在这种情况下,我们可以直接使用 Bean Validation API。

我们手动创建一个 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 Boot 为我们提供了一个预配置的 Validator 实例。 我们可以不用手动去创建它,我们可以将这个实例注入到我们的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);
    }
  }
}

当这个Service被 Spring 实例化时,它会自动将一个 Validator 实例注入到构造函数中。

以面的单元测试表明上述两种方法都按预期正常运行:

@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);
    });
  }

}

9 使用分组校验针对不同情况以不同方式校验对象

通常会在不同的情况之间共用同一个类模型。

让我们以典型的 CRUD 操作为例:“创建”和“更新”很可能都采用相同的类模型作为入参。 但是,可能存在不同场景下触发不同的校验逻辑:

  • 仅在“创建”时执行的校验逻辑

  • 仅在“更新”时执行的校验逻辑

  • 或者两者都要执行的校验逻辑

允许我们实现这样的校验规则的 Bean 校验功能称为“分组校验”

我们已经看到所有校验注解都必须有一个group字段。 这可使用任何类类型,每个类都定义了应该触发的某个校验组。

对于我们的 CRUD 示例,我们简单地定义了两个标记型接口 OnCreateOnUpdate

interface OnCreate {}

interface OnUpdate {}

然后我们可以将这些标记型接口与任何校验注解一起使用,如下所示:

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 注解必须再次放在类上。而要定义哪个校验组当前是有效可用的,@Validated还必须放在方法上并指定校验组。

为了确保上述功能按预期运行,我们可以写一个单元测试:

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

  @Autowired
  private ValidatingServiceWithGroups service;

  @Test
  void whenInputIsInvalidForCreate_thenThrowsException() {
    InputWithGroups input = validInput();
    input.setId(42L);
    
    assertThrows(ConstraintViolationException.class, () -> {
      service.validateForCreate(input);
    });
  }

  @Test
  void whenInputIsInvalidForUpdate_thenThrowsException() {
    InputWithGroups input = validInput();
    input.setId(null);
    
    assertThrows(ConstraintViolationException.class, () -> {
      service.validateForUpdate(input);
    });
  }

}

请注意校验组
使用校验组很容易成为一种反模式,因为它混淆了关注点。 对于校验组,需要校验的实体必须知道它所使用的所有场景的校验规则。在我关于 Bean Validation反模式的文章中,有关此主题的更多信息。

10 处理校验错误

当校验失败时,我们通常希望向客户端返回一条有意义的错误消息。 为了使客户端能够显示有用的错误消息,我们应该返回一个统一的数据结构,其中包含每个校验失败的错误消息。

首先,我们需要定义该数据结构。 我们将其命名为 ValidationErrorResponse,它包含一个 Violation 对象列表:

public class ValidationErrorResponse {

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

  // ...
}

public class Violation {

  private final String fieldName;

  private final String message;

  // ...
}

然后,我们创建一个全局参数校验异常处理器 ControllerAdvice 来冒泡处理所有到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;
  }

}

我们在这里所做的只是从异常中读取有关校验失败信息并将它们转换到我们的 ValidationErrorResponse 数据结构中。

请注意@ControllerAdvice 注解,它使得上述类型异常的异常处理机制对所有Controller全局可用。

11 总结

在本教程中,我们已经介绍了使用 Spring Boot 构建程序时可能需要的所有主要的校验功能。

如果您想深入了解示例代码,请查看 github 仓库。

 类似资料: