前言
最近在开发一个相对基础数据服务的时候,遇到了一个问题,需要对接口的参数进行严格的校验。 一开始我很自然地使用了 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 元素类型,如METHOD
、FIELD
、ANNOTATION_TYPE
、CONSTRUCTOR
、PARAMETER
、TYPE_USE
。@Retention
:表示注解的保留策略,对于如RUNTIME
、CLASS
、SOURCE
。@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
:被注解的元素必须小于等于指定的最大值,注意不支持double
和float
类型的数值。@Min
:被注解的元素必须大于等于指定的最小值,注意不支持double
和float
类型的数值。@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 构建工具。
添加 Spring Web 和 Validation 依赖,同时建议添加 DevTools 和 Lombok 依赖,方便开发。
添加之后,工程的 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>
示例
以下示例的完整源码可以在 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
属性,用于指定枚举类,同时定义了 message
、groups
和 payload
属性,用于指定错误消息、校验组和扩展信息。
同时通过 @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>
接口,重写了 initialize
和 isValid
方法。
在 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 工具发送请求,我们先测试一个创建账户的接口,请求响应如下:
再测试一个参数校验通过的创建账户的接口,请求响应如下:
再测试一下更新账户的接口,请求响应如下:
增加 accountId 参数后,再测试一下更新账户的接口,请求响应如下:
@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