Spring 参数校验最佳实践

Bean校验是日常开发中的常见需求,本文介绍如何通过Spring validation stater 进行Bean校验,完整代码可以从这里下载。

引入Spring Boot Validation Starter

引入spring-boot-starter-validation,来快速的加载校验的相关依赖。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
复制代码

基础知识

Bean Validation 的基本工作原理是通过对类的 字段(Field) 使用某些 Annotation 进行注解,来定义对类的字段的约束。

常用validation注解

以下是常用的校验注解,更全面的注解可参看这里。

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

下面是一个代码示例。

public class Input {

  @NotBlank
  private String id;

  @Email
  private String email;

  @Min(1)
  @Max(91)
  private int age;

  @Pattern(regexp = "^[a-zA-Z0-9]{8,16}$",message = "用户名只能是长度在8至16"
      + "之间的包含数字和大小写字母的字符串")
  private String userName;
  ...
}
复制代码

验证器对象Validator

验证器对象 Validator 用来校验一个对象是否是合法的,通过将对象传递给 Validator 从而检查约束条件是否可以被满足。

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

@Validated and @Valid

在Spring中,我们不需要自己创建验证器对象,而是通过@Validated和@Valid注解,告诉Spring我们想要验证某个对象,Spring会自动帮助我们进行验证。

  • @Validated 是一个类级注解,我们使用它来告诉Spring验证传递到添加了该注释类的方法中的参数。
  • @Valid 注解添加在方法参数和字段上,告诉Spring我们希望验证方法参数或字段。

验证Spring MVC控制器的输入

假设我们已经实现了一个Spring REST控制器,并希望验证输入。对于任何传入的HTTP请求,我们可以验证三种请求数据:

  • 请求体,@RequestBody
  • 路径变量,如:/order/{orderId}
  • 查询参数,GET 请求的参数

请求体验证

在POST或PUT请求中,通常会在请求体中传递JSON数据。Spring会自动将传入的JSON映射到Java对象,我们要检查传入的Java对象是否满足我们的需求。

假设下面就是我们的传入对象。

public class Input {

  // id不允许为空
  @NotBlank
  private String id;

  // 一个电子邮件地址
  @Email
  private String email;

  // 年龄最小为1,最大为91
  @Min(1)
  @Max(91)
  private int age;

  //用户名长度为8-16,只能右数字和大小写字母组成
  @Pattern(regexp = "^[a-zA-Z0-9]{8,16}$",message = "用户名只能是长度在8至16"
      + "之间的包含数字和大小写字母的字符串")
  private String userName;
  ...
}
复制代码

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

@RestController
public class ValidateRequestBodyController {

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

我们只是在输入参数中添加了@Valid注释,该参数也用@RequestBody注释,以标记它应该从请求体读取。这样Spring在执行任何其他操作之前将对象传递给验证器进行验证。 如果验证失败,它将触发 MethodArgumentNotValidException 。默认情况下,Spring会将此异常转换为HTTP状态400(错误请求)。

可通过运行代码中的测试用例 com.example.springbootdemo.controller.ValidateRequestBodyControllerTest 来查看效果。

路径变量和查询参数验证

验证路径变量和请求参数的工作方式略有不同。

这里我们不验证复杂的Java对象,而是将注解(本例中为@Min)直接添加到Spring控制器中的方法参数,因为路径变量和请求参数是int之类的基本类型。

@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");
  }
}
复制代码

:::caution 注意 必须在类上添加Spring的@Validated注解,从而让Spring执行该类中的方法参数上的约束注解。 :::

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

如果我们想返回HTTP状态400(这是有意义的,因为客户端提供了一个无效的参数,使其成为一个错误的请求),我们可以向我们的 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);
  }

}
复制代码

可通过运行代码中的测试用例 com.example.springbootdemo.controller.ValidateParametersControllerTest 来查看效果。

:::info 在后面内容,我们将了解如何返回结构化错误响应,该响应包含所有失败验证的详细信息,供客户端检查。 :::

验证Spring @Service方法的输入

我们也可以验证任何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);
    });
  }

}
复制代码

Spring Boot 自定义验证器

如果默认的约束注解不足以满足我们的需求,我们可以己创建一个约束注释,并自定义一个验证器,来满足我们的需求。

比如我们有一个名为 gender 和 ipAddress的字段,gender的取值只能是'F' 或 'M'这两个字符串,ip必须符合ip的规则。

首先来看gender的注解和验证器:

@Documented
@Constraint(validatedBy = {EnumStringValidator.class}) //指定由EnumStringValidator去验证
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface EnumString {
  String message() default "value not invalid";

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

  Class<? extends Payload>[] payload() default { };
  // 用于设置可取值范围,验证器会使用
  String[] values();
}
复制代码

自定义约束注解需要满足以下所有条件:

  • 参数消息 message(),用于在发生冲突时发出提示消息,
  • 参数组 groups(),允许定义在何种情况下触发该验证(我们稍后将讨论验证组),
  • 参数payload,使用很少,这里不讨论你
  • 指向ConstraintValidator接口实现的@Constraint注释。

接下来看验证器。

public class EnumStringValidator implements
    ConstraintValidator<EnumString, String> {
  
  private List<String> list;
  // 根据@EnumString中设置的values初始化
  @Override
  public void initialize(EnumString constraintAnnotation) {
    list = Arrays.asList(constraintAnnotation.values());
  }
  // 判断是否满足配置的条件
  @Override
  public boolean isValid(String value,
      ConstraintValidatorContext constraintValidatorContext) {
    if (value == null) {
      return false;
    }
    return list.contains(value);
  }
}
复制代码

@IpAddress 和 IpAddressValidator 请查看代码。

接下来,我们就可以像使用任何其他约束注解一样,使用 @EnumString 和 @IpAddress 注解了。

使用groups对不同用例的对象进行不同的验证方案

在开发中,同一个类会在多个场景中出现,以典型的CRUD操作为例:“创建”用例和“更新”用例很可能都采用相同的对象类型作为输入。但是,在不同的场景下,对实例中字段的约束条件也不完全相同,我们可以为 校验注解 设置 groups(),给同一个类的设置不同情况下的校验规则。

注意到,所有约束注解都必须有一个groups字段。这可以用来传递任何类,每个类都定义了应该触发的特定验证组。

对于CRUD示例,我们只需定义两个标记接口OnCreate和OnUpdate:

interface OnCreate {}

interface OnUpdate {}
复制代码

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

public class InputWithCustomValidator {

  @EnumString(values = {"F", "M"}, groups = OnCreate.class)
  private String gender;

  @IpAddress(groups = OnUpdate.class)
  private String ipAddress;

  ...
复制代码

在 InputWithCustomValidator 类中,gender属性只在 OnCreate 组中被校验,ipAddress 只在 OnUpdate 组中被校验。

Spring 通过 @Validated 注解来支持校验组。

@Service
@Validated
class ValidatingServiceWithGroups {

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

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

}
复制代码

:::caution 请注意,@Validated注释必须用于整个类。要定义哪个验证组应处于活动状态,还必须在方法级别应用它。 :::

通过执行测试用例 com.example.springbootdemo.service.ValidatingServiceWithGroupsTest ,来观看效果。

处理验证错误

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

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

public class ValidationErrorResponse {

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

  // ...
}

public class Violation {

  private final String fieldName;

  private final String message;

  // ...
}
复制代码

然后,我们创建一个全局ControllerAdvice,它处理所有出现在Controller级别的ConstraintViolationException。为了捕获请求主体的验证错误,我们还将处理MethodArgumentNotValidException。

@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数据结构。

:::caution 注意@ControllerAdvice注释,它使异常处理程序方法对应用程序上下文中的所有控制器全局可用。 :::

总结

在本教程中,我们介绍了使用如何使用Spring Boot进行校验。相关代码可从这里下载。

おすすめ

転載: juejin.im/post/7066352669228531726