本节目标:学会自己写一个约束注解并绑定校验逻辑,理解 message / groups / payload 的作用,掌握中文校验消息的国际化配置,并能在 Service 层手动触发校验。
适用版本:Spring Boot 4.1.x(Java 21)
9.2 自定义校验器
9.1 里我们用 @Size(min = 10, max = 17) 粗粗挡了一下 ISBN 的长度。但 ISBN 不是「长度对就行」的——它有一套校验位算法:ISBN-10 的第 10 位是模 11 校验位,ISBN-13 的第 13 位是模 10 校验位。"1234567890" 长度合法却是个无效 ISBN。这一节我们就写一个真正会算校验位的 @Isbn 注解。
9.2.1 什么时候该自定义
不是所有规则都值得自定义。先用一个判断清单:
| 场景 | 推荐做法 |
|---|---|
能用 @Pattern 正则表达 | 用 @Pattern,别自定义 |
| 单一字段、逻辑简单、可复用 | 自定义约束注解(本节做法) |
| 涉及多个字段之间的关系(如结束日期晚于开始日期) | 类级约束(约束注解标在类上) |
| 需要查数据库/调外部服务 | 不要放校验器里,放 Service 业务逻辑 |
关键判据是「这条规则是否纯粹依赖字段自身的值」。ISBN 校验位只依赖字符串本身,适合自定义校验器;「书名是否与库里已有书重名」依赖外部状态,不属于 Bean Validation 的职责。
9.2.2 自定义约束注解的定义
一个约束注解由三部分组成:注解本身、绑定到它的 ConstraintValidator、默认错误消息。先写注解:
package com.example.library.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Constraint(validatedBy = IsbnValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface Isbn {
String message() default "{com.example.library.validation.Isbn.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
逐项解释:
@Constraint(validatedBy = IsbnValidator.class):把注解与校验器绑定,这是最关键的一行。一个注解可以绑定多个校验器(用于不同数据类型)。@Target:说明注解能标在哪。FIELD用于 DTO 字段,PARAMETER用于方法参数(Service 层手动校验时用得上)。@Retention(RUNTIME):必须运行时保留,否则框架读不到。- 三个成员方法
message/groups/payload:规范强制要求存在,缺一个在启动或校验时会报错。
9.2.3 三个成员的作用
| 成员 | 作用 | 入门阶段怎么用 |
|---|---|---|
message | 校验失败时的提示文本,支持 {key} 引用消息文件 | 给默认值,允许使用处覆盖 |
groups | 指定该约束属于哪些校验分组 | 默认空数组 = Default 分组;9.3 会用到 |
payload | 携带自定义元数据,供框架或工具读取 | 99% 的项目留空即可 |
message 的默认值写成了 {com.example.library.validation.Isbn.message},这是消息键而非字面文本。它会在 ValidationMessages.properties 里查表,从而支持国际化——这正是下一小节的主题。
payload 常见于「给严重级别分类」这类框架级需求(例如某校验失败要打审计日志),业务代码里几乎不用。但它的声明不能省,规范要求这三个成员都存在。
9.2.4 实现 ConstraintValidator
校验逻辑写在一个实现 ConstraintValidator<注解, 字段类型> 的类里:
package com.example.library.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class IsbnValidator implements ConstraintValidator<Isbn, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 空值交给 @NotBlank 处理,校验器只负责「非空时的格式」
if (value == null || value.isBlank()) {
return true;
}
String normalized = value.replace("-", "").replace(" ", "").toUpperCase();
return switch (normalized.length()) {
case 10 -> isValidIsbn10(normalized);
case 13 -> isValidIsbn13(normalized);
default -> false;
};
}
private boolean isValidIsbn10(String s) {
int sum = 0;
for (int i = 0; i < 9; i++) {
char c = s.charAt(i);
if (c < '0' || c > '9') {
return false;
}
sum += (c - '0') * (10 - i);
}
char last = s.charAt(9);
int check = (last == 'X') ? 10 : (last - '0');
if (last != 'X' && (last < '0' || last > '9')) {
return false;
}
return (sum + check) % 11 == 0;
}
private boolean isValidIsbn13(String s) {
int sum = 0;
for (int i = 0; i < 12; i++) {
char c = s.charAt(i);
if (c < '0' || c > '9') {
return false;
}
int digit = c - '0';
sum += (i % 2 == 0) ? digit : digit * 3;
}
int check = (10 - (sum % 10)) % 10;
return check == (s.charAt(12) - '0');
}
}
两个必须理解的约定:
isValid返回true表示「通过」,不是「有问题」。名字容易看反。null应当返回true。Bean Validation 的设计哲学是「空值是否合法由@NotNull系列负责」,校验器只管非空时的格式。若这里对null返回false,@Isbn就会和@NotBlank重复报错。
使用时把注解标到 DTO 字段上即可:
@NotBlank(message = "ISBN 不能为空")
@Isbn(message = "ISBN 格式不正确,请填写有效的 ISBN-10 或 ISBN-13")
private String isbn;
这里我们在使用处覆盖了默认 message,所以不依赖消息文件也能出中文提示。如果想走国际化,就去掉这里的 message,改用默认键。
9.2.5 校验消息的国际化
默认消息文件放在 src/main/resources/ValidationMessages.properties。注意 Bean Validation 的约定文件名必须是这个,且默认(无语言后缀)文件用来兜底:
# src/main/resources/ValidationMessages.properties(默认,兜底语言)
com.example.library.validation.Isbn.message=invalid ISBN format
jakarta.validation.constraints.NotBlank.message=must not be blank
中文消息放在带语言后缀的文件里:
# src/main/resources/ValidationMessages_zh_CN.properties(简体中文)
com.example.library.validation.Isbn.message=ISBN 格式不正确
jakarta.validation.constraints.NotBlank.message=不能为空
jakarta.validation.constraints.NotNull.message=不能为 null
jakarta.validation.constraints.Size.message=长度必须在 {min} 到 {max} 之间
{min}、{max} 是参数占位符,由约束注解上的属性值填充,@Size(min = 10, max = 17) 就会渲染成「长度必须在 10 到 17 之间」。
4.x 下属性文件的编码要求:ValidationMessages*.properties 是标准 java.util.Properties 文件,按规范用 ISO-8859-1 读取,直接写中文会乱码。两种正确做法:
| 做法 | 说明 |
|---|---|
用 \uXXXX 转义 | 最稳妥,ISBN 格式不正确 写成 ISBN \u683c\u5f0f\u4e0d\u6b63\u786e |
| 让构建工具按 UTF-8 处理 | Maven 配置 <properties><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties> 并确保 maven-resources-plugin 不转义 |
实践中推荐 \uXXXX 转义,因为它与构建工具、IDE 无关,任何环境都不会乱码。若嫌手写麻烦,可以用 native2ascii 或 IDE 的 properties 编辑器自动转义。
还有一点:Spring 的 MessageSource 与 Bean Validation 的消息文件是两套机制。前者默认读 messages.properties,后者读 ValidationMessages.properties。若想让 Bean Validation 也用 Spring 的 MessageSource(从而复用 Spring 的编码配置),需要往容器里注册一个 LocalValidatorFactoryBean 并设置它的 validationMessageSource,属于进阶配置。
9.2.6 @Pattern 与自定义校验器的取舍
看到正则就想到 @Pattern 是本能,但两者边界要清楚:
| 维度 | @Pattern | 自定义校验器 |
|---|---|---|
| 表达力 | 只能表达正则能描述的模式 | 任意 Java 逻辑 |
| 可读性 | 复杂正则难维护 | 逻辑显式、可单测 |
| 校验位算法 | 无法表达(需要累加与取模) | 可以 |
| 复用性 | 每个字段抄一遍正则 | 一个注解到处用 |
| 上手成本 | 零 | 要写两个类 |
结论:能用一条不长的正则说清的,就用 @Pattern;需要算术、查表、长度分支的,才自定义。ISBN 因为要算校验位,必须自定义;而「手机号 11 位数字」用 @Pattern(regexp = "^1\\d{10}$") 就够了。
一个务实的折中:正则放在注解里可读性差时,可以定义 @Pattern(regexp = IsbnPatterns.PHONE) 这类常量,避免正则散落各处。
9.2.7 在 Service 层手动触发校验
Controller 入参有 Spring MVC 自动触发校验,但 Service 层接收的是已经转换好的对象,需要手动触发。两种方式:
方式一:注入 Validator,显式调用。 适合「只在某个方法里校验」:
@Service
public class BookService {
private final Validator validator;
public BookService(Validator validator) {
this.validator = validator;
}
public void importBatch(BookCreateRequest req) {
Set<ConstraintViolation<BookCreateRequest>> violations = validator.validate(req);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
// ……继续处理
}
}
这里注入的 Validator 由 Spring Boot 自动配置提供(LocalValidatorFactoryBean),直接可用。
方式二:类上标注 @Validated。 适合「整个 Service 的方法都要校验」:
@Service
@Validated
public class BookService {
public void create(@Valid BookCreateRequest req) {
// 方法被调用前自动校验 req
}
public Book findByIsbn(@Isbn String isbn) {
// 方法参数直接校验,适合简单类型
}
}
@Validated 标在类上后,Spring 会为该 bean 生成代理,方法调用前先校验带约束的参数。若校验失败,抛的是 ConstraintViolationException(注意与 Controller 的 MethodArgumentNotValidException 不是同一个异常)。
| 对比项 | Controller 入参 | Service @Validated |
|---|---|---|
| 触发方式 | @Valid + @RequestBody | 类上 @Validated |
| 失败异常 | MethodArgumentNotValidException | ConstraintViolationException |
| HTTP 状态 | 自动 400 | 默认 500(需自己映射) |
最后一行是个大坑:ConstraintViolationException 默认不被 Spring MVC 当作 400 处理,会变成 500 服务器错误。要修,得在全局异常处理器里显式捕获它并映射成 400——第 10 章会给出代码。
9.2.8 真实的失败异常
自定义校验器失败时,异常里同样携带逐字段信息。日志片段:
2026-09-19T10:07:42.871+08:00 WARN 51204 --- [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.example.library.web.dto.BookResponse com.example.library.web.BookController.create(com.example.library.web.dto.BookCreateRequest) with 1 error: [Field error in object 'bookCreateRequest' on field 'isbn': rejected value [1234567890]; codes [Isbn.bookCreateRequest.isbn,Isbn.isbn,Isbn.java.lang.String,Isbn]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [bookCreateRequest.isbn,isbn]; arguments []; default message [ISBN 格式不正确]]; default message [ISBN 格式不正确]] ]
可以看到 codes 数组里出现了 Isbn.bookCreateRequest.isbn、Isbn.isbn 这样的键,框架正是靠这些键去消息文件查文本。默认响应体依然是那个不含细节的 400:
{
"timestamp": "2026-09-19T02:07:42.871+00:00",
"status": 400,
"error": "Bad Request",
"path": "/api/books"
}
Service 层抛出的 ConstraintViolationException 若未处理,响应会变成 500,并在日志里打印 jakarta.validation.ConstraintViolationException: ...。
小结
- 自定义约束由三部分组成:
@Constraint注解、ConstraintValidator实现、默认消息键。 message/groups/payload是规范强制要求的三个成员,缺一不可;payload业务中通常留空。isValid返回true表示通过,且对null应返回true(非空判断交给@NotBlank/@NotNull)。- 中文消息放在
ValidationMessages_zh_CN.properties,注意属性文件的 ISO-8859-1 编码问题,推荐\uXXXX转义。 - 能用短正则表达的规则优先
@Pattern;涉及算术、查表、长度分支才自定义校验器。 - Service 层手动校验有两条路:注入
Validator显式调用,或类上@Validated;后者失败抛ConstraintViolationException,默认会变成 500,需要自己映射成 400。
下一节解决最后一个常见难题:同一个 DTO,新增时 id 必须为空、修改时 id 必须非空,规则互相冲突该怎么办。
阅读导航:上一节:9.1 Bean Validation 常用注解 · 下一节:9.3 分组校验与嵌套校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。