本节目标:在请求进入服务层之前,把缺失、超长、越界的字段拦下来,并给出稳定的字段级错误。
适用版本: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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。