《Java 编程实战》8.1 参数校验:Jakarta Validation 与手写规则

libraryd 的 bodyAsClass 只负责解析,不负责判断字段是否为空、超长或越界。本节用 Jakarta Validation 3.1.1 与 Hibernate Validator 9.1.5 把约束写在 record 上,把 ConstraintViolation 收成字段错误 Map,并用真实输出说明 Expressly 缺失、null 与空白、消息语言和领域校验的分工。

本节目标:在请求进入服务层之前,把缺失、超长、越界的字段拦下来,并给出稳定的字段级错误。
适用版本:JDK 25(Temurin 25.0.5)、Jakarta Validation 3.1.1、Hibernate Validator 9.1.5.Final、Expressly 6.0.0。

承接的问题

7.1 节的 parseBody 只做两件事:检查 Content-Type,把 JSON 反序列化成 record。它不判断内容是否合理。下面这些请求都能顺利通过 bodyAsClass:

{"isbn":"9787111213826","author":"Joshua Bloch","year":2018}
{"isbn":"9787111213826","title":"","author":"Joshua Bloch","year":2018}
{"isbn":"9787111213826","title":"Effective Java","author":"Joshua Bloch","year":999}
{"isbn":"这是一个远远超过二十个字符的 ISBN 值","title":"T","author":"A","year":2018}

第一条缺 title,Jackson 把它填成 null;第二条 title 是空字符串;第三条年份越界;第四条 ISBN 超过数据库列宽。它们会一路走到 LibraryService:

  • requireText 会抛 INVALID_COMMAND,但错误信息是 title must not be blank,没有字段名之外的上下文。
  • 年份越界也是 INVALID_COMMAND,和「请求体不是 JSON」共用同一个错误码。
  • ISBN 超长会在 JDBC 层撞上 value too long for type character varying(20),最后变成 INTERNAL 或 DB_UNAVAILABLE,客户端看到 500,日志里只有一条 SQL 异常。

这不是数据库的问题,是边界没有分层。一个请求要经过三道判断:

层次判断什么失败时的错误
反序列化JSON 语法、字段类型、未知字段INVALID_COMMAND(400)
参数校验必填、长度、范围、格式VALIDATION_FAILED(400)+ 字段 Map
领域规则书是否存在、是否已借出、续借次数各自的领域错误码

本节只做第二层。第三层仍然由 LibraryService 决定,8.3 节会把它们映射成 HTTP。

核心机制一:依赖与 Validator 的创建

Jakarta Validation 只定义 API,具体实现由 Hibernate Validator 提供。libraryd-web 需要三个依赖:

<dependency>
  <groupId>jakarta.validation</groupId>
  <artifactId>jakarta.validation-api</artifactId>
  <version>3.1.1</version>
</dependency>
<dependency>
  <groupId>org.hibernate.validator</groupId>
  <artifactId>hibernate-validator</artifactId>
  <version>9.1.5.Final</version>
</dependency>
<dependency>
  <groupId>org.glassfish.expressly</groupId>
  <artifactId>expressly</artifactId>
  <version>6.0.0</version>
</dependency>

前两个是 API 和实现。第三个 expressly 是 Jakarta EL 的实现,负责解析约束注解里的消息表达式,例如 @Size(max = 20, message = "长度不能超过 {max}") 里的 {max}。没有它,Validator 在创建阶段就会失败。

创建 Validator 的标准写法是:

import jakarta.validation.Validation;
import jakarta.validation.Validator;

Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

buildDefaultValidatorFactory() 会查找 classpath 上的 META-INF/validation.xml。libraryd 没有这个文件,所以使用 Hibernate Validator 的默认配置:普通消息插值器、非快速失败、收集全部违规。这些默认值正好符合需求。

ValidatorFactory 是线程安全的,Validator 也是。不要在每次请求里 buildDefaultValidatorFactory(),那会重复解析配置和初始化 EL。RequestValidator 在构造时创建一个,整个应用共享:

package com.plume.library.web;

import jakarta.validation.Validation;
import jakarta.validation.Validator;

public final class RequestValidator {

    private final Validator validator;

    public RequestValidator() {
        this(Validation.buildDefaultValidatorFactory().getValidator());
    }

    public RequestValidator(Validator validator) {
        this.validator = validator;
    }
}

第二个构造函数接收外部 Validator,测试可以传入固定配置的实现,不依赖全局状态。这个模式和 Clock 的注入是同一个思路。

核心机制二:把约束写在 DTO 上

约束直接标在 record 组件上:

package com.plume.library.web.dto;

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

public record CreateBookRequest(
        @NotBlank @Size(max = 20) String isbn,
        @NotBlank @Size(max = 200) String title,
        @NotBlank @Size(max = 120) String author,
        @Min(1000) @Max(2100) int year) {
}

public record UpdateBookRequest(
        @NotBlank @Size(max = 200) String title,
        @NotBlank @Size(max = 120) String author,
        @Min(1000) @Max(2100) int year) {
}

public record BorrowRequest(
        @NotBlank @Size(max = 20) String isbn,
        @NotBlank @Size(max = 40) String memberId) {
}

public record ReturnRequest(@NotBlank @Size(max = 20) String isbn) {
}

几个选择需要解释:

  • @NotBlank 而不是 @NotNull。@NotNull 只拒绝 null,空字符串和全空格都能通过;@NotBlank 三者都拒绝,适合书名、作者这类文本字段。
  • @Size 和 @NotBlank 要一起用。@Size 允许 null,单独用它挡不住缺失字段。
  • year 用原始类型 int,缺失时 Jackson 填 0,@Min(1000) 会失败。如果字段是包装类型 Integer,缺失时是 null,@Min 会认为 null 合法,必须再加 @NotNull。
  • UpdateBookRequest 没有 isbn。ISBN 是路径参数,也是主键,不允许通过请求体修改。创建和更新用两个 record,比在一个 record 上做分组校验更直接。

Hibernate Validator 对 record 的支持已经成熟。注解标在组件上,会传播到字段、构造参数和访问器;验证时同一个约束只产生一条违规,不会因为传播而重复。本机对 CreateBookRequest 做验证,isbn 传 21 个空格时得到两条违规:@NotBlank 和 @Size 各一条,这是两个不同的约束,不是重复。

年份的范围是 1000 到 2100,和数据库的 check (published_year between 1000 and 2100) 一致。1800 在这个范围内,是合法年份。不要因为「看起来太老」就把它判成非法;校验规则要和 schema 对齐,而不是凭直觉。

核心机制三:ConstraintViolation 到字段错误 Map

validator.validate(value) 返回 Set<ConstraintViolation<T>>。每个违规包含属性路径、消息和非法值。web 层需要的是「字段名 → 消息」的 Map,转换逻辑在 RequestValidator.validate:

public <T> T validate(T value) {
    Set<ConstraintViolation<T>> violations = validator.validate(value);
    if (violations.isEmpty()) {
        return value;
    }
    Map<String, String> errors = new LinkedHashMap<>();
    for (ConstraintViolation<T> violation : violations) {
        String field = violation.getPropertyPath().toString();
        errors.merge(field, violation.getMessage(), (left, right) -> left + "; " + right);
    }
    throw new ValidationFailure(errors);
}

三个细节:

  • violation.getPropertyPath().toString() 对顶层字段返回 title、year,对嵌套对象返回 address.city。前端可以按路径定位输入框。
  • 同一个字段可能有多条违规,例如 isbn 同时违反 @NotBlank 和 @Size。merge 用 "; " 拼接,避免后一条覆盖前一条。
  • LinkedHashMap 保留插入顺序,响应里的字段顺序稳定。但 Set 的迭代顺序本身没有保证,不同 JVM 或不同违规数量下顺序可能变化,客户端不应该依赖顺序。

违规被包装成 ValidationFailure:

package com.plume.library.web;

import java.util.Map;

public class ValidationFailure extends RuntimeException {

    private static final long serialVersionUID = 1L;

    private final transient Map<String, String> errors;

    public ValidationFailure(Map<String, String> errors) {
        super("request validation failed");
        this.errors = Map.copyOf(errors);
    }

    public Map<String, String> errors() {
        return errors;
    }
}

Map.copyOf 做了一次不可变拷贝,异常离开 web 层后,原始 Map 的修改不会影响错误内容。transient 是因为异常可能被序列化,而 Map 里的内容不一定可序列化;这个异常只在进程内传递,不需要持久化。

ValidationFailure 是运行时异常,由 7.2 节注册的处理器接住,映射成 VALIDATION_FAILED(400)。它和领域异常 LibraryException 是两条线:前者是字段级输入错误,后者是业务状态错误。

在 libraryd 里落地:校验与领域规则的分工

路由层在解析请求体之后、调用服务之前做校验:

private void createBook(Context context) {
    CreateBookRequest request = validator.validate(parseBody(context, CreateBookRequest.class));
    Book saved = service.addBook(new Book(request.isbn(), request.title(),
            request.author(), request.year()));
    context.status(201)
            .header("Location", "/api/books/" + saved.isbn())
            .json(BookDto.from(saved));
}

validator.validate 返回原对象,所以可以链式写在 parseBody 外面。校验失败时抛 ValidationFailure,后面的服务调用不会发生,事务也不会开启。这一点很重要:不要在事务里做参数校验,失败时还要回滚一次。

校验和领域规则的边界可以这样划:

检查放在哪例子
字段是否存在、是否空白DTO 约束title 缺失或 ""
长度是否超过列宽DTO 约束ISBN 超过 20 字符
数值是否在范围内DTO 约束year 小于 1000
ISBN 是否已存在领域服务DUPLICATE_ISBN
图书是否存在领域服务BOOK_NOT_FOUND
是否已有活动借阅领域服务BOOK_ALREADY_BORROWED
续借次数是否用完领域对象RENEWAL_NOT_ALLOWED

领域层仍然保留 requireText 和 requireYear。这不是重复劳动,而是因为 LibraryService 不只被 HTTP 调用:定时任务、集成测试、未来的 CLI 都可能直接调它。web 层校验负责「快速失败并给出字段错误」,领域层负责「无论谁调用都不破坏不变量」。两层都做,但错误码不同:web 层是 VALIDATION_FAILED,领域层是 INVALID_COMMAND 或更具体的业务错误。

不要让 DTO 依赖领域对象,也不要让领域对象依赖 jakarta.validation。领域模块不引入 web 依赖,这是 Maven 依赖方向的要求。

真实违规输出

用 Validation.buildDefaultValidatorFactory().getValidator() 对几个请求做验证,本机输出如下。JVM 默认 locale 是 zh_CN,所以消息是中文:

[blank-title] violations=1
  path=title msg=不能为空
[year-999] violations=1
  path=year msg=最小不能小于1000
[isbn-21-spaces] violations=2
  path=isbn msg=不能为空
  path=isbn msg=个数必须在0和20之间
[null-isbn] violations=1
  path=isbn msg=不能为空
[update-ok] violations=0

经过 RequestValidator 之后,字段错误被收成 Map:

[request-validator] {isbn=不能为空}
[two-fields] {year=最小不能小于1000, isbn=不能为空}

这些消息来自 Hibernate Validator 自带的中文资源包。它适合直接展示给中文用户,但不应该成为客户端的判断依据。客户端要按 errors 的 key(字段名)分支,而不是按消息文本匹配;消息会随 locale、版本和自定义资源包变化。

如果服务器部署在英文 locale 上,同样的违规会输出 must not be blank 和 must be greater than or equal to 1000。错误契约里的字段名稳定,消息只是给人看的。需要固定语言时,可以在创建 ValidatorFactory 时配置 MessageInterpolator 和 Locale,或者为每个约束写显式的 message。

常见错误与边界

错误一:只加 hibernate-validator,不加 EL 实现。 启动时可能不报错,第一次创建 Validator 就失败。本机去掉 expressly 后的真实异常是:

jakarta.validation.ValidationException: HV000183: Unable to initialize 'jakarta.el.ExpressionFactory'.
Check that you have the EL dependencies on the classpath, or use ParameterMessageInterpolator instead

要么加 org.glassfish.expressly:expressly,要么显式使用 ParameterMessageInterpolator。后者不解析 {max} 这类表达式,消息里只能写静态文本。

错误二:用 @Size 代替 @NotBlank。 @Size(max = 20) 对 null 返回合法,缺字段的请求会通过校验,最后在服务层报一个没有字段信息的错误。文本字段至少要有 @NotBlank。

错误三:包装类型只写 @Min。 Integer year 为 null 时 @Min 认为合法。包装类型要补 @NotNull,或者直接用原始类型让缺失值变成 0。两种都可以,但要在 DTO 上写清楚。

错误四:以为嵌套对象会自动校验。 默认只校验当前对象。OrderRequest(Customer customer) 里的 customer.name 不会因为 Customer.name 上有 @NotBlank 就被检查,除非在组件上加 @Valid。集合元素同理,需要 @Valid List<Item>。

错误五:依赖错误消息做客户端分支。 消息是给人看的,会随 locale 变化。客户端按字段名和错误码判断,消息只用于展示。

错误六:在事务里校验。 校验失败抛异常后,事务管理器还要回滚一次,白白占用连接。校验应该发生在 service 调用之前。

边界:消息插值和自定义资源包。 如果项目要支持多语言,需要提供 ValidationMessages_zh_CN.properties 或自定义 MessageInterpolator。没有自定义资源包时,Hibernate Validator 使用内置翻译;不是所有语言都覆盖。

边界:ValidatorFactory 的生命周期。 ValidatorFactory 实现了 AutoCloseable,应用关闭时应该调用 close()。libraryd 的 RequestValidator 目前持有默认工厂直到进程退出,规模小可以接受;如果频繁创建和销毁测试上下文,忘记关闭会留下线程和类加载器引用。

小结

  • Jakarta Validation 定义约束 API,Hibernate Validator 提供实现,Expressly 提供消息表达式所需的 EL。
  • ValidatorFactory 和 Validator 都是线程安全的,应用级共享一个,不要每个请求新建。
  • 约束写在请求 DTO 的 record 组件上;文本字段用 @NotBlank,长度用 @Size,数值范围用 @Min/@Max。
  • ConstraintViolation 转换成「字段名 → 消息」的 Map,同一个字段的多条违规用 "; " 合并。
  • 参数校验负责输入形状,领域服务负责业务状态;两层都保留,错误码不同。
  • 错误消息随 locale 变化,客户端应按字段名和错误码判断,不要匹配消息文本。

字段校验通过后,错误响应本身还没有统一格式。一个校验失败返回什么 JSON、Content-Type 是什么、type 字段怎么生成,下一节按 RFC 9457 把错误契约定下来。

阅读导航:上一节:7.3 优雅停机与连接管理 · 下一节:8.2 错误契约:RFC 9457 Problem Details 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发