别再傻乎乎的使用 if-else 对参数进行校验了【草稿】

前言

最近在开发一个相对基础数据服务的时候,遇到了一个问题,需要对接口的参数进行严格的校验。 一开始我很自然地使用了 if-else 来对参数进行校验,但是后来发现这种方式不大对劲,一个接口入参的校验几百行,可读性差且难以复用。 后来想起以前用过的 Java Bean Validation,于是就开始阅读规范和文档了解,发现它是一个非常优雅的解决方案。

什么是 Java Bean Validation

Java Bean Validation 是一个用于 Java 应用程序的校验框架,允许以声明式方式定义数据模型的约束条件。 这种方法简化了数据校验逻辑的编写,确保了数据的一致性和有效性,并提高了代码的可维护性。 Java Bean Validation 广泛应用于各种 Java 企业应用中,支持从简单的表单校验到复杂的业务模型校验。

Java Bean Validation 的版本演进

  • Java Bean Validation 1.0 是在 2009 年发布的,它是 JSR 303 的一部分,对应 Java EE 6 版本,提供了一种用于声明式校验 Java Bean 的方式,实现版本有 Hibernate Validator 4 和 Apache BVal 0.5。
  • Java Bean Validation 1.1 是在 2013 年发布的,它是 JSR 349 的一部分,对应 Java EE 7 版本,增加了对方法参数校验的支持,实现版本有 Hibernate Validator 5 和 Apache BVal 1.1。
  • Java Bean Validation 2.0 是在 2017 年发布的,它是 JSR 380 的一部分,对应 Java EE 8 版本,增加了对 Java 8 的新特性的支持,如日期和时间约束,还增加了对集合对象的校验支持,实现的版本是 Hibernate Validator 6。
  • 在 2017 年,Oracle 宣布将 Java EE 的开发和治理权转让给 Eclipse Foundation,这导致了 Eclipse Foundation 成为 Java EE 技术的新的管理者,同时为了避免商标和命名权的问题,Eclipse Foundation 选择了一个新的名称 “Jakarta EE”。
  • Jakarta Bean Validation 2.0 是在 2019 年发布的,它是 Jakarta EE 8 的一部分,这个版本没有新的特性,主要是做了更名,同时将 maven 的 GAV 变更为 jakarta.validation:jakarta.validation-api。
  • 目前最新的 Jakarta Bean Validation 3.0 是在 2020 年发布的,它是 Jakarta EE 9 和 Jakarta EE 10 的一部分,这个版本也只是换了包名,将原先 javax.validation 变更为 jakarta.validation,实现版本是 Hibernate Validator 7 和 Hibernate Validator 8。

所以现在我们通常使用的是 Jakarta Bean Validation 3.0,实现版本是 Hibernate Validator 7 或 8,只是还是习惯称之为 Java Bean Validation。

Java Bean Validation 的核心概念

根据 Jakarta Bean Validation specification 中的定义,Java Bean Validation 的核心概念有以下几个。

Constraint annotation(约束定义)

在 Java Bean Validation 中,JavaBean 的约束通过一个或多个注解来表示。如果注解的保留策略包含 RUNTIME 并且该注解本身用 jakarta.validation.Constraint 进行注解,则该注解被视为约束定义,如 @NotNull@Size@Pattern 等。

package jakarta.validation.constraints;
 
/**
 * The annotated element must not be {@code null}.
 * Accepts any type.
 *
 * @author Emmanuel Bernard
 */
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
@Documented
@Constraint(validatedBy = { })
public @interface NotNull {
 
	String message() default "{jakarta.validation.constraints.NotNull.message}";
 
	Class<?>[] groups() default { };
 
	Class<? extends Payload>[] payload() default { };
 
	/**
	 * Defines several {@link NotNull} annotations on the same element.
	 *
	 * @see jakarta.validation.constraints.NotNull
	 */
	@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
	@Retention(RUNTIME)
	@Documented
	@interface List {
 
		NotNull[] value();
	}
}

以上是系统内置的 @NotNull 注解的定义,它表示被注解的元素不能为 null。请关注以下几点:

  • @Target:表示注解可以应用的 Java 元素类型,如 METHODFIELDANNOTATION_TYPECONSTRUCTORPARAMETERTYPE_USE
  • @Retention:表示注解的保留策略,对于如 RUNTIMECLASSSOURCE
  • @Repeatable:表示注解可以重复使用在同一个元素上。
  • @Constraint:表示注解是一个约束定义,validatedBy 属性表示约束的实现类,这里实现类为空表示仅定义规范,在 hibernate-validator 具体是怎么绑定的后续再说。
  • message(): 默认错误消息键,通常由约束的完全限定类名加上 .message 组成,一些不考虑国际化或变量替换的也可以写死错误消息。
  • groups(): 允许用户自定义目标校验组,默认为一个空数组。
  • payload(): 用于扩展性目的,一个常见的场景用于定义该错误的级别,比如是 INFO、ERROR 或者是 CRITICAL。

Constraint composition(组合约束)

通过组合约束可以创建更高级别的约束,主要有两个好处:

  • 避免重复和促进复用:通过组合更基础的约束,可以避免重复并促进约束的重用。
  • 增强工具的感知能力:通过元数据API暴露基础约束作为组合约束的一部分,增强了工具的感知能力。

以下示例展示了如何通过注解来定义一个组合约束 @FrenchZipCode,它由 @Pattern@Size 注解组成,使用 @FrenchZipCode 注解一个元素等同于同时使用 @Pattern(regexp="[0-9]*")@Size(min=5, max=5) 注解,以及 @FrenchZipCode 本身。

@Pattern(regexp = "[0-9]*")
@Size(min = 5, max = 5)
@Constraint(validatedBy = FrenchZipCodeValidator.class)
@Documented
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
public @interface FrenchZipCode {
 
    String message() default "Wrong zip code";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
}

Constraint validation implementation(约束实现)

在 Java Bean Validation 中,约束校验实现是用来对给定类型的给定约束注解进行校验的。这些实现类通过装饰约束定义的 @Constraint 注解的 validatedBy 元素来指定。 validatedBy 元素的值是一个实现了 jakarta.validation.ConstraintValidator 接口的类,该接口的泛型参数是约束注解的类型和被校验的类型。

public interface ConstraintValidator<A extends Annotation, T> {
 
	/**
	 * Initializes the validator in preparation for
	 * {@link #isValid(Object, ConstraintValidatorContext)} calls.
	 * The constraint annotation for a given constraint declaration
	 * is passed.
	 * <p>
	 * This method is guaranteed to be called before any use of this instance for
	 * validation.
	 * <p>
	 * The default implementation is a no-op.
	 *
	 * @param constraintAnnotation annotation instance for a given constraint declaration
	 */
	default void initialize(A constraintAnnotation) {
	}
 
	/**
	 * Implements the validation logic.
	 * The state of {@code value} must not be altered.
	 * <p>
	 * This method can be accessed concurrently, thread-safety must be ensured
	 * by the implementation.
	 *
	 * @param value object to validate
	 * @param context context in which the constraint is evaluated
	 *
	 * @return {@code false} if {@code value} does not pass the constraint
	 */
	boolean isValid(T value, ConstraintValidatorContext context);
}
  • ConstraintValidator 接口定义了校验给定约束 A 对给定对象类型 T 的逻辑。
  • initialize 方法用于初始化校验器,通常用于获取约束注解的属性。
  • isValid 方法用于实现校验逻辑,返回 false 表示校验失败,true 表示校验成功。

下面是 @NotNull 注解的校验实现,它是一个简单的校验逻辑,只要对象不为 null 就返回 true

package org.hibernate.validator.internal.constraintvalidators.bv;
 
/**
 * Validate that the object is not {@code null}.
 *
 * @author Emmanuel Bernard
 */
public class NotNullValidator implements ConstraintValidator<NotNull, Object> {
 
	@Override
	public boolean isValid(Object object, ConstraintValidatorContext constraintValidatorContext) {
		return object != null;
	}
}

ConstraintValidatorFactory(约束校验器工厂)

ConstraintValidatorFactory 是用来创建约束校验实现实例的工厂。Java Bean Validation 提供者完全依赖于此工厂来管理 ConstraintValidator 实例的生命周期。它的职责主要包括:

  • 实例化:基于 ConstraintValidator 类实例化一个新的约束校验器实例。ConstraintValidatorFactory 不负责调用ConstraintValidator#initialize(java.lang.annotation.Annotation) 方法。
  • 释放实例:当约束校验器实例不再被 Java Bean Validation 提供者使用时,应通过 releaseInstance 方法通知 ConstraintValidatorFactory。

注意,默认的 ConstraintValidatorFactory 通过 ConstraintValidator 类的公共无参构造函数来提供实例。每个实例的状态可能会在 initialize() 方法中被改变,因此 ConstraintValidatorFactory 不应缓存实例。

内置约束注解

Java Bean Validation 3.0 内置约束注解

Java Bean Validation 提供了一些内置的约束注解,这些注解可以直接使用,也可以通过组合来创建更高级别的约束。

  • @AssertFalse:被注解的元素必须为 false
  • @AssertTrue:被注解的元素必须为 true
  • @DecimalMax:被注解的元素必须小于等于指定的最大值。
  • @DecimalMin:被注解的元素必须大于等于指定的最小值。
  • @Digits:被注解的元素必须是一个数字,其值必须在可接受的范围内。
  • @Email:被注解的元素必须是一个电子邮件地址。
  • @Future:被注解的元素必须是一个将来的日期。
  • @FutureOrPresent:被注解的元素必须是一个将来或现在的日期。
  • @Max:被注解的元素必须小于等于指定的最大值,注意不支持 doublefloat 类型的数值。
  • @Min:被注解的元素必须大于等于指定的最小值,注意不支持 doublefloat 类型的数值。
  • @Negative:被注解的元素必须是一个负数。
  • @NegativeOrZero:被注解的元素必须是一个负数或零。
  • @NotBlank:被注解的元素必须不为 null 且不为空。
  • @NotEmpty:被注解的元素必须不为 null 且不为空。
  • @NotNull:被注解的元素必须不为 null
  • @Null:被注解的元素必须为 null
  • @Past:被注解的元素必须是一个过去的日期。
  • @PastOrPresent:被注解的元素必须是一个过去或现在的日期。
  • @Pattern:被注解的元素必须符合指定的正则表达式。
  • @Positive:被注解的元素必须是一个正数。
  • @PositiveOrZero:被注解的元素必须是一个正数或零。
  • @Size:被注解的元素的大小必须在指定的范围内,支持字符串、集合、Map 等类型。

Hibernate Validator 7 内置约束注解

Hibernate Validator 7 是 Jakarta Bean Validation 3.0 的实现版本之一,它在 Java Bean Validation 3.0 的基础上增加了一些额外的约束注解。

  • @CreditCardNumber:被注解的元素必须是一个有效的信用卡号。
  • @Currency:被注解的元素必须是一个有效的货币。
  • @DurationMax:被注解的元素必须小于等于指定的最大持续时间。
  • @DurationMin:被注解的元素必须大于等于指定的最小持续时间。
  • @ISBN:被注解的元素必须是一个有效的 ISBN(International Standard Book Number)。
  • @Length :被注解的元素的长度必须在指定的范围内。
  • @Normalized: 被注解的元素必须是一个标准化的字符串。
  • @Range:被注解的元素的值必须在指定的范围内。
  • @ScriptAssert:被注解的元素必须满足指定的脚本表达式。
  • @UniqueElements:被注解的元素必须是一个唯一元素的集合。
  • @URL:被注解的元素必须是一个有效的 URL。
  • @UUID: 被注解的元素必须是一个有效的 UUID(Universally Unique Identifier)。

使用 Java Bean Validation

在 Spring Web 中使用 Java Bean Validation

创建项目

为了方便演示,我们创建一个 Spring Boot 项目,使用 Maven 构建工具。

create project

添加 Spring Web 和 Validation 依赖,同时建议添加 DevTools 和 Lombok 依赖,方便开发。

choose dependencies

添加之后,工程的 pom.xml 应该包含如下依赖,同时查看依赖关系可以看到 其中 spring-boot-starter-validation 依赖了 hibernate-validator 实现,而后者依赖了 jakarta.validation-api

<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
 
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

show dependencies

示例

以下示例的完整源码可以在 GitHub 上找到。

新建一个账户创建和更新的 Controller 类和接口

@Slf4j
@RestController
public class AccountController {
 
    /**
     * Create account
     *
     * @param accountRequest account request
     * @return response with account id
     */
    @PostMapping("/api/createAccount")
    public Response<String> createAccount(@Validated(value = {Default.class, Create.class}) @RequestBody AccountRequest accountRequest) {
        log.info("Create account: {}", accountRequest);
        // TODO: Create account
        return Response.success("123456");
    }
 
    @PostMapping("/api/updateAccount")
    public Response<String> updateAccount(@Validated(value = {Default.class, Update.class}) @RequestBody AccountRequest accountRequest) {
        log.info("Update account: {}", accountRequest);
        // TODO: Update account
        return Response.success(accountRequest.getAccountId());
    }
}

请注意,这里使用了 @Validated 注解,而不是 @Valid 注解,@Validated 注解是 Spring 提供的,它支持分组校验,而 @Valid 注解是 Java Bean Validation 提供的,它不支持分组校验。 另外在创建和更新的方法上,分别使用了 @Validated(value = {Default.class, Create.class})@Validated(value = {Default.class, Update.class}) 注解,这样就可以根据不同的场景使用不同的校验规则。

创建一个账户请求的 Java Bean 并增加校验注解

@Data
public class AccountRequest implements Serializable {
 
    /**
     * Account id, required for update
     */
    @NotNull(message = "Account Id is required for update", groups = {Update.class})
    private String accountId;
 
    /**
     * Account number, should be unique, length should be less than 32
     */
    @NotNull(message = "Account No is required")
    @Size(max = 32, message = "Account No length should be less than 32")
    @UniqueAccountNo(groups = {Create.class})
    private String accountNo;
 
    /**
     * Account name, should not be empty, length should be less than 100
     */
    @NotNull(message = "Account Name is required")
    @Size(max = 100, message = "Account Name length should be less than 100")
    private String accountName;
 
    /**
     * Account type, should be one of [SAVING, CURRENT, FIXED]
     */
    @NotNull(message = "Account Type is required")
    @InEnum(value = AccountTypeEnum.class, message = "Account Type should be one of [SAVING, CURRENT, FIXED]")
    private String accountType;
 
    /**
     * Account balance, should not be null, should be greater than 0
     */
    @NotNull(message = "Account Balance is required")
    @Range(min = 0, message = "Account Balance should be greater than 0")
    private BigDecimal balance;
 
    /**
     * Account expiration date, should be in the future
     */
    @NotNull(message = "Account Expiration Date is required")
    @Future(message = "Account Expiration Date should be in the future")
    private Date expireDate;
 
    /**
     * Address list, should not be empty
     */
    @Valid
    @NotNull(message = "Address List should not be empty")
    @Size(min = 1, message = "Address List should not be empty")
    private List<@NotNull AddressInfo> addressList;
}

这里我们定义了一个 AccountRequest 类,它包含了账户的基本信息,如账户号、账户名、账户类型、账户余额、账户到期日期和地址列表。 在 AccountRequest 类中,我们使用了一些内置常用的校验注解,如 @NotNull@Size@Range@Future。同时我们还定义了一个自定义的校验注解 @InEnum@UniqueAccountNo,用于校验枚举的有效值及账户号的唯一性。 其中 @Valid 注解用于校验嵌套对象,这里我们嵌套了一个 AddressInfo 类,它包含了地址的基本信息,如地址类型、地址、邮编和手机号码。

创建一个地址信息的 Java Bean 并增加校验注解

@Data
public class AddressInfo implements Serializable {
 
    /**
     * Address Type, should be one of [HOME, OFFICE]
     */
    @NotNull(message = "Address Type is required")
    @InEnum(value = AddressTypeEnum.class, message = "Address Type should be one of [HOME, OFFICE]")
    private String addressType;
 
    /**
     * Address, should not be empty, length should be less than 200
     */
    @NotNull(message = "Address is required")
    @Size(max = 200, message = "Address length should be less than 200")
    private String address;
 
    /**
     * Zip code, should not be empty, length should be 6 digits
     */
    @NotNull(message = "Zip Code is required")
    @Pattern(regexp = "^[0-9]{6}$", message = "Zip Code should be 6 digits")
    private String zipCode;
 
    /**
     * Mobile phone number, should not be empty, length should be 11
     */
    @NotNull(message = "Mobile Phone Number is required")
    @Pattern(regexp = "^1[0-9]{10}$", message = "Mobile Phone Number should be 11 digits")
    private String mobilePhoneNo;
}

AddressInfo 类中,我们同样使用了一些内置常用的校验注解,如 @NotNull@Size@Pattern。同时我们还定义了一个自定义的校验注解 @InEnum,用于校验枚举的有效值。

创建一个自定义的校验注解 @InEnum

@Target({FIELD, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = InEnumValidator.class)
public @interface InEnum {
 
    /**
     * Enum class
     *
     * @return enum class
     */
    Class<? extends BaseEnum> value();
 
    String message() default "accountNo must be unique";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
}

@InEnum 注解中,我们定义了一个 value 属性,用于指定枚举类,同时定义了 messagegroupspayload 属性,用于指定错误消息、校验组和扩展信息。 同时通过 @Constraint(validatedBy = InEnumValidator.class) 注解指定了校验实现类 InEnumValidator

创建一个自定义的校验实现类 InEnumValidator

public class InEnumValidator implements ConstraintValidator<InEnum, String> {
 
    private Class<? extends BaseEnum> enumClass;
 
    @Override
    public void initialize(InEnum constraintAnnotation) {
        enumClass = constraintAnnotation.value();
    }
 
    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        if (enumClass == null) {
            return false;
        }
 
        for (BaseEnum baseEnum : enumClass.getEnumConstants()) {
            if (baseEnum.getCode().equals(s)) {
                return true;
            }
        }
 
        return false;
    }
}

InEnumValidator 类中,我们实现了 ConstraintValidator<InEnum, String> 接口,重写了 initializeisValid 方法。 在 initialize 方法中,我们获取了 @InEnum 注解中的枚举类,用于后续校验。 在 isValid 方法中,我们校验了被注解的元素是否在指定的枚举类中。

添加全局异常处理

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
 
    public static final String ILLEGAL_PARAMETERS = "ILLEGAL_PARAMETERS";
 
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public <T> Response<T> handlerMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.error("MethodArgumentNotValidException occur!!!", e);
        return Response.fail(ILLEGAL_PARAMETERS,
                e.getBindingResult().getFieldErrors().stream()
                        .map(FieldError::getDefaultMessage).collect(Collectors.joining("; ")));
    }
}

GlobalExceptionHandler 类中,我们使用 @RestControllerAdvice 注解标记为全局异常处理类,同时使用 @ExceptionHandler 注解标记为处理 MethodArgumentNotValidException 异常的方法。 在 handlerMethodArgumentNotValidException 方法中,我们获取了校验失败的字段错误信息,并使用通用的 Response 对象封装返回给前端。

启动应用,测试效果

测试参数校验的效果,我们可以使用 Postman 工具发送请求,我们先测试一个创建账户的接口,请求响应如下:

create account request

再测试一个参数校验通过的创建账户的接口,请求响应如下:

create account request

再测试一下更新账户的接口,请求响应如下: update account request

增加 accountId 参数后,再测试一下更新账户的接口,请求响应如下: update account request

@Validated 与 @Valid 的区别

这里很容易搞混 @Valid(javax.validation) 和 @Validated (org.springframework.validation.annotation)注解。 两者的区别在于 @Validated 有 value 属性,支持分组校验,即根据不同的分组采用不同的校验机制,比如在上面创建和更新账户的时候,可以传入不同的分组,而 @Valid 主要的功能是添加在成员变量上,支持嵌套校验。 所以建议的使用方式就是:启动校验(即 Controller 层)时使用 @Validated 注解,嵌套校验时使用 @Valid 注解,这样就能同时使用分组校验和嵌套校验功能。

实现原理

在 Service 中使用 Java Bean Validation

在 RPC 中使用 Java Bean Validation

总结

// TODO