Loading

Spring Boot 参数验证

原文地址

Bean Validation 是在 Java 生态系统中实现验证逻辑的事实标准。 它与 Spring 和 Spring Boot 很好地集成在一起。本教程介绍了所有主要的验证用例和每个用例的运动代码示例。本文随附 GitHub 上的工作代码示例

使用 Starte

Spring Boot 的 Bean Validation 支持带有验证启动器,我们可以将其包含到我们的项目中(Gradle 表示法):

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

没有必要添加版本号,因为 Spring Dependency Management Gradle 插件为我们做了这件事。如果您没有使用该插件,可以在此处找到最新版本,但是,如果我们还包含了 web starter,则需要添加如下依赖:

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

请注意,spring-boot-starter-validation 只是将依赖项添加到兼容版本的 hibernate validator(Bean Validation 规范的最广泛使用的实现)。

基础知识

基本上,Bean Validation 的工作原理是通过使用某些注释对类的字段进行注释来定义对它们的约束。

常用注解

一些最常见的验证注释是:

  • @NotNull:表示一个字段不能为空。
  • @NotEmpty:表示列表字段不能为空。
  • @NotBlank:表示字符串字段不能为空字符串(即它必须至少有一个字符)。
  • @Min 和 @Max:表示一个数值字段只有在其值高于或低于某个值时才有效。
  • @Pattern:表示一个字符串字段只有在匹配某个正则表达式时才有效。
  • @Email:表示字符串字段必须是有效的电子邮件地址。

此类的示例如下所示:

class Customer {

  @Email
  private String email;

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

验证器

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

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

@Validated and @Valid

在许多情况下,Spring 会为我们进行验证。 我们甚至不需要自己创建验证器对象。我们可以让 Spring 知道我们想要验证某个对象。 这通过使用 @Validated 和 @Valid 注释来工作。

@Validated 注解是一个类级别的注解,我们可以使用它来告诉 Spring 验证传递给被注解类的方法的参数。

我们可以将 @Valid 注解放在方法参数和字段上,以告诉 Spring 我们希望对方法参数或字段进行验证。

验证 Spring MVC 控制器的输入

假设我们已经实现了一个 Spring REST 控制器,并且想要验证客户端传入的输入。 我们可以为任何传入的 HTTP 请求验证三件事:

  • 请求体
  • 路径中的变量(例如 /foos/{id} 中的 id)
  • 查询参数

详细示例如下:

请求体

在 POST 和 PUT 请求中,通常在请求正文中传递 JSON 负载。 Spring 自动将传入的 JSON 映射到 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;
  
  // ...
}

我们有一个 int 字段,它的值必须介于 1 和 10 之间(含 10 和 10),如 @Min 和 @Max 注释所定义的。 我们还有一个必须包含 IP 地址的字符串字段,正如 @Pattern 注释中的正则表达式所定义的那样(正则表达式实际上仍然允许八位字节大于 255 的无效 IP 地址,但我们将在本教程的后面构建自定义验证器时修复该问题 )。

为了验证传入 HTTP 请求的请求主体,我们在 REST 控制器中使用 @Valid 注释对请求主体进行注释:

@RestController
class ValidateRequestBodyController {

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

}

我们只是简单地在 Input 参数上添加了 @Valid 注解,它也用 @RequestBody 注解来标记它应该从请求体中读取。 通过这样做,我们告诉 Spring 在执行任何其他操作之前将对象传递给验证器。

注意:在复杂类型上使用@Valid :如果 Input 类包含一个字段,该字段具有另一个需要验证的复杂类型,则该字段也需要使用 @Valid 进行注释。

如果验证失败,将触发 MethodArgumentNotValidException。默认情况下,Spring 会将此异常转换为 HTTP 状态 400(错误请求)。 我们可以通过集成测试来验证此行为:

@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 控制器的更多详细信息。

路径中的变量

验证路径变量和请求参数的工作方式略有不同。在这种情况下,我们不验证复杂的 Java 对象,因为路径变量和请求参数是原始类型,如 int 或它们的对应对象,如 Integer 或 String。我们没有像上面那样注释类字段,而是直接向 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 注释,以告诉 Spring 评估方法参数上的约束注释。

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

与请求正文验证相反,失败的验证将触发 ConstraintViolationException 而不是 MethodArgumentNotValidException。 Spring 没有为此异常注册默认的异常处理程序,因此默认情况下会导致 HTTP 状态为 500(内部服务器错误)的响应。

如果我们想返回一个 HTTP 状态 400(这是有道理的,因为客户端提供了一个无效参数,使它成为一个错误的请求),我们可以向我们的控制器添加一个自定义异常处理程序:

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

}

查询参数

我们还可以验证对任何 Spring 组件的输入,而不是在控制器级别验证输入。为此,我们结合使用 @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);
    });
  }

}

验证 JPA 实体

验证的最后一道防线是持久层。默认情况下,Spring Data 在底层使用 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 存储库,它为我们提供了持久化和查询 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 存储库的更多详细信息。

请注意,只有在刷新 EntityManager 后,Bean 验证才会由 Hibernate 触发。 Hibernate 在某些情况下会自动刷新这些 EntityManager,但在我们的集成测试中,我们必须手动执行此操作。

如果出于任何原因我们想在我们的 Spring 数据存储库中禁用 Bean 验证,我们可以将 Spring Boot 属性 spring.jpa.properties.javax.persistence.validation.mode 设置为 none。

自定义验证器

如果可用的约束注释不能满足我们的用例,我们可能想自己创建一个。

在上面的 Input 类中,我们使用正则表达式来验证 String 是否是有效的 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 中的属性键,用于在发生违规时解析消息,
  • 参数组,允许定义在什么情况下触发此验证(稍后我们将讨论验证组),
  • 参数 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;
  
  // ...

}

编程方式验证

在某些情况下,我们希望以编程方式调用验证而不是依赖 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
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);
    }
  }
}

当此服务被 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);
    });
  }

}

使用验证组

通常,某些对象在不同用例之间共享。让我们以典型的 CRUD 操作为例:“创建”用例和“更新”用例很可能都采用相同的对象类型作为输入。 但是,在不同情况下可能会触发验证:

  • 仅在“创建”用例中
  • 仅在“更新”用例中
  • 或在这两种用例中。

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

我们已经看到所有的约束注解都必须有一个 groups 字段。 这可用于传递任何定义应触发的特定验证组的类。对于我们的 CRUD 示例,我们简单地定义了两个标记接口 OnCreate 和 OnUpdate:

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 注释必须再次应用于整个类。 要定义哪个验证组应该处于活动状态,还必须在方法级别应用它。

为确保以上内容按预期工作,我们可以实施单元测试:

@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 验证反模式的文章。

处理验证错误

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

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

public class ValidationErrorResponse {

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

  // ...
}

public class Violation {

  private final String fieldName;

  private final String message;

  // ...
}

然后,我们创建一个全局 ControllerAdvice 来处理所有冒泡到控制器级别的 ConstraintViolationExceptions。为了捕获请求主体的验证错误,我们还将处理 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 注释,它使异常处理程序方法对应用程序上下文中的所有控制器全局可用。

结论

在本教程中,我们介绍了使用 Spring Boot 构建应用程序时可能需要的所有主要验证功能。 如果您想亲身体验示例代码,请查看 github 存储库

posted @ 2023-01-09 14:20  weey  阅读(112)  评论(0编辑  收藏  举报