使用 Hibernate Validator 校验

本指南介绍如何使用 Hibernate Validator/Bean Validation,校验 REST 服务的输入输出,以及业务服务方法的参数和返回值。

前提条件

完成指南需要约 15 分钟、一个 IDE、安装 JDK 17 或更高版本并正确配置 JAVA_HOME,以及 Apache Maven 3.9.16。也可以选择使用 Quarkus CLI。若要构建原生可执行文件,可安装并配置 Mandrel 或 GraalVM,或使用 Docker 进行原生容器构建。

架构

应用很简单:用户在网页填写表单,页面通过 Ajax 将内容作为 JSON 发送给 BookResource。它校验用户输入,再以 JSON 返回结果。

完整示例

建议按照下文逐步创建应用,也可以直接查看完整示例。克隆 git clone https://github.com/quarkusio/quarkus-quickstarts.git,或下载归档。完整实现位于 validation-quickstart。

创建 Maven 项目

先创建一个新项目。CLI 命令:

quarkus create app org.acme:validation-quickstart \
    --extension='rest-jackson,hibernate-validator' \
    --no-code
cd validation-quickstart

创建 Gradle 项目时,添加 --gradle 或 --gradle-kotlin-dsl。CLI 安装和用法见 Quarkus CLI 指南。

Maven 命令:

mvn io.quarkus.platform:quarkus-maven-plugin:3.40.1:create \
    -DprojectGroupId=org.acme \
    -DprojectArtifactId=validation-quickstart \
    -Dextensions='rest-jackson,hibernate-validator' \
    -DnoCode
cd validation-quickstart

创建 Gradle 项目时,添加 -DbuildTool=gradle 或 -DbuildTool=gradle-kotlin-dsl。Windows 的 cmd 不使用反斜线续行,应把全部参数放在同一行;PowerShell 的 -D 参数应使用双引号,例如 "-DprojectArtifactId=validation-quickstart"。

命令生成 Maven 项目结构,引入 Quarkus REST(原 RESTEasy Reactive)/Jakarta REST、Jackson 和 Hibernate Validator/Bean Validation 扩展。

已有 Quarkus 项目时,可以在项目根目录添加 hibernate-validator 扩展。CLI:

quarkus extension add hibernate-validator

Maven:

./mvnw quarkus:add-extension -Dextensions='hibernate-validator'

Gradle:

./gradlew addExtension --extensions='hibernate-validator'

这会在构建文件中添加以下内容。pom.xml:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-hibernate-validator</artifactId>
</dependency>

build.gradle:

implementation("io.quarkus:quarkus-hibernate-validator")

约束

这个应用只校验一个简单对象,但也支持复杂约束与对象图校验。创建 org.acme.validation.Book:

package org.acme.validation;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Min;

public class Book {

    @NotBlank(message="Title may not be blank")
    public String title;

    @NotBlank(message="Author may not be blank")
    public String author;

    @Min(message="Author has been very lazy", value=1)
    public double pages;
}

字段添加约束后,校验对象时就会检查这些值。getter/setter 方法也用于 JSON 映射。

JSON 映射与校验

创建 REST 资源 org.acme.validation.BookResource:

package org.acme.validation;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/books")
public class BookResource {

    @Inject
    Validator validator; 

    @Path("/manual-validation")
    @POST
    public Result tryMeManualValidation(Book book) {
        Set<ConstraintViolation<Book>> violations = validator.validate(book);
        if (violations.isEmpty()) {
            return new Result("Book is valid! It was validated by manual validation.");
        } else {
            return new Result(violations);
        }
    }
}

这里通过 CDI 注入 Validator。此时代码还不能编译,因为尚缺少 Result,马上就会补上。参数 book 自动从 JSON 请求体创建。方法用 Validator 检查请求数据,得到约束违反集合。集合为空表示对象有效;失败时,把错误消息连接起来返回浏览器。

接着,把 Result 作为内部类添加:

public static class Result {

    Result(String message) {
        this.success = true;
        this.message = message;
    }

    Result(Set<? extends ConstraintViolation<?>> violations) {
        this.success = false;
        this.message = violations.stream()
             .map(cv -> cv.getMessage())
             .collect(Collectors.joining(", "));
    }

    private String message;
    private boolean success;

    public String getMessage() {
        return message;
    }

    public boolean isSuccess() {
        return success;
    }

}

它只有两个字段和相应访问方法。因为资源声明输出 JSON,系统自动完成 JSON 映射。

REST 端点校验

手动使用 Validator 适合某些高级场景。如果只是校验 REST 端点参数或返回值,可以直接加 @NotNull、@Digits 等约束,或用 @Valid 将校验级联到 bean。创建校验请求中 Book 的端点:

@Path("/end-point-method-validation")
@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Result tryMeEndPointMethodValidation(@Valid Book book) {
    return new Result("Book is valid! It was validated by end point method validation.");
}

现在不需要手动校验。出现错误时,系统生成约束违反报告,并按端点的 JSON 输出格式序列化。前端可以提取报告并显示合适错误消息。例如原文给出的响应:

{
    "title": "Constraint Violation",
    "status": 400,
    "violations": [
        {
            "field": "tryMeEndPointMethodValidation.book.title",
            "message": "Title cannot be blank"
        }
    ]
}

Quarkus 的内置 jakarta.ws.rs.ext.ExceptionMapper 生成这个响应。如果应用要自定义处理 ValidationException,可提供自己的 mapper:

import jakarta.validation.ValidationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;

@Provider
public class ResteasyReactiveViolationExceptionMapper implements ExceptionMapper<ValidationException> {

    @Override
    public Response toResponse(ValidationException exception) {
        // TODO: implement
    }
}

服务方法校验

端点层声明所有规则并非总是方便,因为可能重复业务校验。可以在业务服务方法上加约束,本例用 @Valid:

package org.acme.validation;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.validation.Valid;

@ApplicationScoped
public class BookService {

    public void validateBook(@Valid Book book) {
        // your business logic here
    }
}

从 REST 端点等位置调用这个服务方法,会自动触发 Book 校验:

@Inject BookService bookService;

@Path("/service-method-validation")
@POST
public Result tryMeServiceMethodValidation(Book book) {
    try {
        bookService.validateBook(book);
        return new Result("Book is valid! It was validated by service method validation.");
    } catch (ConstraintViolationException e) {
        return new Result(e.getConstraintViolations());
    }
}

如果要将服务校验错误传给前端,必须自行捕获异常并传递信息;它不会自动成为 JSON 校验报告,而会按其他内部服务器错误处理。通常不应向公众暴露服务内部信息,尤其不要暴露违反约束对象中的被校验值。

默认只有 REST 端点参数约束校验失败,才返回带校验报告的错误请求响应。服务方法参数或返回值、REST 端点返回值等其他位置的失败,默认会导致 5xx 内部错误,除非应用明确捕获处理 ConstraintViolationException,或实现自定义 ExceptionMapper。这是因为只有端点参数视为用户输入,可归为 4xx;其他位置的违反约束通常来自应用逻辑执行,因此视为内部错误。

前端

添加一个简单页面与 BookResource 交互。Quarkus 自动提供 META-INF/resources 中的静态资源。在 src/main/resources/META-INF/resources 中,把 index.html 替换为完整示例的 index.html。

运行应用

原文给出三种开发模式命令。CLI:

quarkus dev

Maven:

./mvnw quarkus:dev

Gradle:

./gradlew --console=plain quarkusDev

然后在浏览器打开 http://localhost:8080/,输入有效或无效的图书信息,点击 Try me 按钮,分别检查上述方法如何校验数据。

打包应用,CLI:

quarkus build

Maven:

./mvnw install

Gradle:

./gradlew build

打包后用 java -jar target/quarkus-app/quarkus-run.jar 执行。也可以构建原生可执行文件。CLI:

quarkus build --native

Maven:

./mvnw install -Dnative

Gradle:

./gradlew build -Dquarkus.native.enabled=true

进一步了解

Hibernate Validator 扩展与 CDI

扩展与 CDI 紧密集成。

配置 ValidatorFactory

有时需要改变 ValidatorFactory 的行为,例如指定 ParameterNameProvider。虽然工厂由 Quarkus 创建,但只要声明替代 bean,就可以注入配置,轻松调整行为。以下类型的 bean 会自动注入工厂配置,无需手动连接:

  • jakarta.validation.ClockProvider
  • jakarta.validation.ConstraintValidator
  • jakarta.validation.ConstraintValidatorFactory
  • jakarta.validation.MessageInterpolator
  • jakarta.validation.ParameterNameProvider
  • jakarta.validation.TraversableResolver
  • org.hibernate.validator.spi.properties.GetterPropertySelectionStrategy
  • org.hibernate.validator.spi.nodenameprovider.PropertyNodeNameProvider
  • org.hibernate.validator.spi.scripting.ScriptEvaluatorFactory

每个类型只能声明一个 bean。多数应为 @ApplicationScoped。但若 ConstraintValidator 依赖约束注解的属性,通常会实现 initialize(A constraintAnnotation),应使用 @Dependent,确保每个注解上下文有独立实例。

配置属性与这些 CDI bean 仍不足时,可注册 ValidatorFactoryCustomizer 进一步定制。例如覆盖内置 @Email 校验器,改用 MyEmailValidator:

@ApplicationScoped
public class MyEmailValidatorFactoryCustomizer implements ValidatorFactoryCustomizer {

    @Override
    public void customize(BaseHibernateValidatorConfiguration<?> configuration) {
        ConstraintMapping constraintMapping = configuration.createConstraintMapping();

        constraintMapping
                .constraintDefinition(Email.class)
                .includeExistingValidators(false)
                .validatedBy(MyEmailValidator.class);

        configuration.addMapping(constraintMapping);
    }
}

所有实现该接口的 bean 都会应用,可以有多个。需要顺序时,使用 @jakarta.annotation.Priority,优先级较高的先应用。

把约束校验器声明为 bean

约束校验器也可以成为 CDI bean:

@ApplicationScoped
public class MyConstraintValidator implements ConstraintValidator<MyConstraint, String> {

    @Inject
    MyService service;

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        }

        return service.validate(value);
    }
}

初始化给定类型校验器时,Quarkus 检查是否已有这个类型的 bean;存在时直接使用,而不是另行创建 ConstraintValidator。因此可以在校验器里充分使用注入。

作用域很重要:同一实例可供全应用使用时,选择 @ApplicationScoped;实现 initialize 并依赖约束注解状态时,选择 @Dependent,使每个上下文获得独立且正确配置的实例。

注入依赖运行时配置的 bean 时,使用 @Inject Instance<..>。约束在构建时初始化,那时尚无运行时信息,所以不能在 initialize(..) 中完成全部配置。该方法仍可读取注解参数,完成不依赖运行时配置的工作。建议把繁重配置放入注入 bean 的初始化中,并让 ConstraintValidator#isValid(..) 调用的方法尽可能快:

@ApplicationScoped
public class MyConstraintValidator implements ConstraintValidator<MyConstraint, String> {

    @Inject
    Instance<MyService> service;

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        }

        return service.get().validate(value);
    }
}

@ApplicationScoped
public class MyService {

    private final Predicate<String> validationFunction;

    @Inject
    public MyService(MyRuntimeConfig config) {
        // perform all possible "initialization" work, e.g.:
        if (config.complexValidationEnabled()) {
            validationFunction = s -> ...
        } else {
            validationFunction = String::isBlank;
        }
    }

    public boolean validate(String value) {
        // perform the validation
        return validationFunction.test(value);
    }
}

校验与本地化

默认违反约束的消息使用构建系统的 locale。可在 application.properties 中设置:

# The default locale to use
quarkus.default-locale=fr-FR

使用 Quarkus REST 或 RESTEasy Classic 时,Jakarta REST 端点上下文里的 Hibernate Validator 可根据 Accept-Language 请求头自动选择最佳语言,但必须正确列出支持语言:

# The list of all the supported locales
quarkus.locales=en-US,es-ES,fr-FR

也可以设置 all,使原生可执行文件包含全部可用语言,但体积会显著增加;原文指出,比只包含两三个语言至少多 23MB。基于 quarkus-smallrye-graphql 的 GraphQL 服务也有类似机制。

默认机制不够时,可增加 org.hibernate.validator.spi.messageinterpolation.LocaleResolver:实现该接口的 CDI bean 都会参与;按 @Priority 从高到低查询;无法解析时返回 null 并忽略;第一个非 null 返回值就是最终 locale。

REST 端点与服务方法的校验组

同一个类传给不同方法时,有时需要启用不同约束。例如 POST 的 Book 标识应为 null,因为会生成新标识;PUT 则应非 null,因为需要确定更新对象。校验组就是加到约束上的标记,用来按需启用或禁用。

先定义两个普通 Java 接口 Post 与 Put:

public interface ValidationGroups {
    interface Post extends Default { 
    }
    interface Put extends Default { 
    }
}

自定义组继承 Default,意味着启用它们时也启用默认组。POST/PUT 都需要校验的字段,可以用默认组,例如下面的 title。

在 Book 中把约束分配给相应组:

public class Book {

    @Null(groups = ValidationGroups.Post.class)
    @NotNull(groups = ValidationGroups.Put.class)
    public Long id;

    @NotBlank
    public String title;

}

最后,在方法的 @Valid 旁增加 @ConvertGroup:

@Path("/")
@POST
@Consumes(MediaType.APPLICATION_JSON)
public void post(@Valid @ConvertGroup(to = ValidationGroups.Post.class) Book book) { 
    // ...
}

@Path("/")
@PUT
@Consumes(MediaType.APPLICATION_JSON)
public void put(@Valid @ConvertGroup(to = ValidationGroups.Put.class) Book book) { 
    // ...
}

POST 启用 Post 与 Default,因此 Book.id 必须为 null,Book.title 必须非空白;PUT 启用 Put 与 Default,因此 id 必须非 null,title 必须非空白。

限制

META-INF/validation.xml

Quarkus 不支持通过 META-INF/validation.xml 配置 ValidatorFactory。目前 Hibernate Validator 没有提供提取该文件信息的 API,以供 Quarkus 注册反射所需类。应使用公开配置属性和 CDI 集成。

不能通过 XML 配置工厂,但可以声明约束:Quarkus 接受类上注解或 validation.xml 中定义的约束。

ValidatorFactory 与原生可执行文件

Quarkus 提供默认工厂,可通过配置属性定制。它使用专属于 Quarkus 的启动过程初始化,以支持原生程序。原生程序不支持自行创建工厂,否则运行时可能出现 jakarta.validation.NoProviderFoundException: Unable to create a Configuration, because no Jakarta Bean Validation provider could be found. Add a provider like Hibernate Validator (RI) to your classpath.。

因此,应始终通过 CDI 注入 Quarkus 管理的 ValidatorFactory 或直接注入 Validator。为了兼容使用默认启动过程创建工厂的外部库,调用 Validation.buildDefaultValidatorFactory() 时,Quarkus 会返回自己管理的工厂。

Hibernate Validator 配置参考

原页面标记了构建时固定的属性,其余属性可在运行时覆盖。

属性 类型 默认值 说明
quarkus.hibernate-validator.fail-fast boolean false 启用快速失败,首次违反约束就停止。环境变量 QUARKUS_HIBERNATE_VALIDATOR_FAIL_FAST。
quarkus.hibernate-validator.method-validation.allow-overriding-parameter-constraints boolean false 是否允许覆盖方法中的参数约束,默认不允许,否则抛出 ConstraintDefinitionException。JSR 380 §4.5.5 规定,子类或接口实现的覆盖方法不得添加参数约束或级联校验,以免加强调用者必须满足的前置条件。环境变量 QUARKUS_HIBERNATE_VALIDATOR_METHOD_VALIDATION_ALLOW_OVERRIDING_PARAMETER_CONSTRAINTS。
quarkus.hibernate-validator.method-validation.allow-parameter-constraints-on-parallel-methods boolean false 是否允许并行类型层级的方法定义参数约束,默认不允许,否则抛出 ConstraintDefinitionException。同一方法来自互不继承的多个接口,或来自类及其未实现接口时,不得声明参数约束或级联校验,以免意外加强前置条件。环境变量 QUARKUS_HIBERNATE_VALIDATOR_METHOD_VALIDATION_ALLOW_PARAMETER_CONSTRAINTS_ON_PARALLEL_METHODS。
quarkus.hibernate-validator.method-validation.allow-multiple-cascaded-validation-on-return-values boolean false 是否允许同一返回值在类型继承链中多次标为级联校验,默认不允许,否则抛出 ConstraintDefinitionException。若父类型的方法返回值已标记,覆盖方法不可再次标记。环境变量 QUARKUS_HIBERNATE_VALIDATOR_METHOD_VALIDATION_ALLOW_MULTIPLE_CASCADED_VALIDATION_ON_RETURN_VALUES。
quarkus.hibernate-validator.expression-language.constraint-expression-feature-level default、none、variables、bean-properties、bean-methods bean-properties 配置约束消息插值可用的表达式语言特性。只影响约束注解 message 中静态消息,不影响校验器程序化创建的自定义违反约束消息;后者须在校验器实现中直接配置。环境变量 QUARKUS_HIBERNATE_VALIDATOR_EXPRESSION_LANGUAGE_CONSTRAINT_EXPRESSION_FEATURE_LEVEL。

原作:Validation with Hibernate Validator,Quarkus 文档贡献者。依据页面标示的 CC BY 3.0转为中文,代码来自同页 HTML,已去除用于解释的可视化编号,程序文本保持原样。文中运行步骤是原文操作说明,本稿未创建、编译或测试项目,也未生成真实应用截图。示例是分步教学片段,部分省略导入或实现,不应把单段直接当作完整可编译项目;校验约束应按业务完善。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容