Spring Boot 与 Jakarta Bean Validation

2022-12-20, 星期二, 21:32

Dev

Jakarta Bean Validation 几乎已经是 Spring 生态中事实上的 Bean 校验标准了,其与 Spring Boot 的集成简单到在 pom 中引入 org.springframework.boot:spring-boot-starter-validation 依赖即可,该 starter 主要负责引入 Hibernate 的 Validator 实现 —— org.hibernate.validator:hibernate-validator

快速上手

在一些 DDD 实践中,表单可以设计成一个 Request Bean。在一个用户注册流程中,接口会收到一个表单 DTO 用于创建新用户。我们可以设计一个 Record 类型 RegisterRequest 接收和处理信息,使用 jakarta.validation.constraints.* 注解修饰需要校验的属性,添加非空和长度要求等约束,通过 message 参数提供错误说明。

public record RegisterRequest(
    @NotBlank(message = "username is required")
    String username,
    @Email
    @NotBlank
    String email,
    @Size(min = 11, max = 11, message = "phone number must be 11 digits")
    @NotNull
    String phone,
    // ...

可参考 Jakarta Bean Validation 3.0 API 添加其他约束,例如应用于 Java Time API 类型的约束 @Future 可确保表单提供的时间值晚于当前时刻。也可以查看 org.hibernate.validator.constraints,这个包甚至提供了 ISBN 的校验。

在 Controller 层的方法为入参添加 jakarta.validation.Valid 注解:

RegisterResponse register(@RequestBody @Valid RegisterRequest request) {
    userService.register(request);
    // ...

发送一个表单,内含非法的电子邮件地址和手机号码:

curl --request POST  --url http://localhost:8080/user/register --data '{
  "username": "john",
  "email": "some#one.com",
  "phone": 184000011
}'

Bean 验证抛出了 org.springframework.web.bind.MethodArgumentNotValidException 异常,Spring 会自动将其处理为 400 Bad Request 返回。不过我们也可以稍作加工,把通过 exception.getBindingResult().getAllErrors() 获得的错误列表也添加到响应中。

HTTP/1.1 400 
Content-Type: application/problem+json

{
    "type": "about:blank",
    "title": "Validation Error",
    "status": 400,
    "detail": "Method Argument Not Valid",
    "instance": "/user/register",
    "explanations": [
        {
            "field": "phone",
            "message": "phone number must be 11 digits"
        },
        {
            "field": "email",
            "message": "must be a well-formed email address"
        }
    ]
}

此类校验还可应用在方法上,用于实现稍微复杂一些的自定义校验。比如我们要求用户注册时必须在手机号码和固定电话号码间至少填写一项,就可以提供一个私有校验方法。

@AssertTrue(message = "must provide mobile phone or a tel number")
private boolean mustProvideMobileOrTel() {
    // ...

嵌套类的校验也是 OK 的,只要在包裹的外层为这个属性添加 Valid 注解。

public record RegisterRequest(
    // ...
    @Valid
    @NotNull
    Address address,
    // ...

在普通方法中使用 Bean Validation

为方法所属的类添加 org.springframework.validation.annotation.Validated 注解,用 jakarta.validation.Valid 注解修饰方法入参,参数校验未通过就会抛出 jakarta.validation.ConstraintViolationException 异常。

在异常实例上调用 Set<ConstraintViolation<?>> getConstraintViolations() 方法可以获取错误详情。

对 RequestMapping 中的 PathVariable 和 RequestParam 进行校验

需要为 Controller 类添加 org.springframework.validation.annotation.Validated 注解,然后在方法入参上描述约束条件。

@Validated
@RestController
public class IndexController {
    @PostMapping("/ip-validate")
    boolean ipValidate(@RequestParam("ip") @HumanReadableIPv4Address String ip) {
        // ...

这种情况下需要用 ExceptionHandler 捕获 ConstraintViolationException,否则客户端只会收到 500 Internal Server Error

通过代码发起校验

Spring Boot 已经通过注解非常方便地支持了 Bean Validation,也提供了 Validator 实例供其他 Bean 注入。除此之外也可以使用 ValidatorFactory 构造 Validator。

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<?>> violations = validator.validate(beanNeedValidated);

自定义规则

要实现一个人类可读的 IPv4 地址校验(存储 4 个 8 位正整数在各种意义上都比存储一个字符串来得优秀,但这不在本文的讨论范围内),一种方法是通过 jakarta.validation.constraints.Pattern 和正则表达式实现,另一种方法则是编写自定义 Validator。

@Target({PARAMETER, FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = HumanReadableIPv4AddressValidator.class)
@Documented
public @interface HumanReadableIPv4Address {
    String message() default "must be a valid IPv4 address";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class HumanReadableIPv4AddressValidator implements ConstraintValidator<HumanReadableIPv4Address, String> {
    @Override
    public void initialize(HumanReadableIPv4Address constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        var addressSets = value.split("\\.");
        if (addressSets.length != 4) return false;
        try {
            return Arrays.stream(addressSets)
                    .mapToInt(Integer::parseInt)
                    .allMatch(i -> i >= 0 && i <= 255);
        } catch (NumberFormatException ignored) {
            return false;
        }
    }
}

这个自定义规则在上一章的示例中已经出现过了。

Groups 与规则组

有时候一个 Request Bean 可以被不同的服务复用,在不同的场景下需要应用的校验规则是不同的。

回到我们的用户注册,假设为系统添加 System Worker 的接口也使用了这个 Request Bean。与标准用户不同,System Worker 并不真实存在,只是供系统任务以他们的名义触发和执行。因此在“注册”这些用户时不需要校验他们的电子邮件和通讯地址。

可以新建接口类 AddNormalUserAddSystemWorker 区分,在 Request Bean 中为 email 属性的校验注解配置 groups[] 属性。

@Email(groups = AddNormalUser.class)
@NotBlank(groups = AddNormalUser.class)
String email,

在需要分组实施校验的方法上添加 @Validated(AddNormalUser.class)

@Validated(AddNormalUser.class)
boolean registerNormalUser(@RequestBody @Valid RegisterRequest request) {
    // ...

boolean addSystemWorker(@RequestBody @Valid RegisterRequest request) {
    // ...

分组校验的反模式

现在我们有了 Normal User 和 System Worker 分组,也许很快就会有成年分组和未成年分组等等其他分组,大量分组交织在一起,规则的维护很快就会成为一种心智负担。

建议只把简单的共性的属性校验交给 Bean Validation,其他交给诸如 isValidSystemUser 这样的方法并显式地调用它们。

Payload 与元数据

Payload 可以用来提供约束规则的元数据,例如在这个场景中表示问题的严重程度,异常处理代码可以通过 ConstraintViolation<?> 获得这一信息。

public class Severity {
    public static class Info implements Payload {};
    public static class Error implements Payload {};
}

public class Address {
    @NotNull(message="would be nice if we had one", payload=Severity.Info.class)
    public String getZipCode() { [...] }

    @NotNull(message="the city is mandatory", payload=Severity.Error.class)
    String getCity() { [...] }
}