《Spring Boot 入门》9.2 自定义校验器

标准注解不够用时,就要自己写约束。本节以 ISBN 格式校验为例,完整演示自定义注解 + ConstraintValidator 实现,拆解 message、groups、payload 三个成员,讲清 ValidationMessages.properties 的中文国际化与 4.x 的编码要求,并给出在 Service 层手动触发校验的两种方式与真实异常响应。

本节目标:学会自己写一个约束注解并绑定校验逻辑,理解 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');
    }
}

两个必须理解的约定:

  1. isValid 返回 true 表示「通过」,不是「有问题」。名字容易看反。
  2. 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
失败异常MethodArgumentNotValidExceptionConstraintViolationException
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 分组校验与嵌套校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计