CClara轻量论坛系统

Java 数据校验:Bean Validation 与自定义注解

18565457544
18565457544 见习用户见习用户
发布于 2026-11-07 23:50 ·3 浏览 ·3 回复
内容摘要

说明在 Spring Boot 中使用 Bean Validation 完成参数校验的完整做法:引入 validation 依赖并区分 javax 与 jakarta 包名,在 DTO 上添加约束注解、由控制器 @Valid 触发校验并统一处理异常,嵌套对象和集合元素需补充 @Valid,方法参数校验改用 @Validated,自定义校验则通过定义注解并实现 ConstraintValidator…

学完这篇,你能在 Spring Boot 项目里用 Bean Validation 完成参数校验,并写出一个可复用的自定义校验注解(含校验器和默认提示语),不依赖手写 if 判断。

第一步:引入依赖

Spring Boot 2.3 之后校验 starter 已从 web 里拆出,需要单独加:

<!-- Spring Boot 3.x -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Boot 2.x 项目里注解包名是 javax.validation.*,Boot 3.x 改成了 jakarta.validation.*,混用会直接报 NoClassDefFoundError,升级时注意全量替换 import。

第二步:在 DTO 上写约束注解

public class UserCreateDTO {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 2, max = 20, message = "用户名长度需在 2-20 之间")
    private String username;

    @NotNull
    @Min(value = 18, message = "年龄不能小于 18")
    private Integer age;

    @Email(message = "邮箱格式不正确")
    private String email;
}

Controller 上加 @Valid(或 @Validated)才会触发校验:

@PostMapping("/users")
public Result create(@Valid @RequestBody UserCreateDTO dto) { ... }

注意:@Valid 加在 Controller 方法参数上才生效,加在 DTO 类上没用。校验失败默认抛 MethodArgumentNotValidException,建议用 @RestControllerAdvice 统一 catch 后返回第一个 FieldError 的 message,别把整段异常栈丢给前端。

第三步:处理集合与嵌套对象

嵌套对象必须在内层字段上再标一次 @Valid,否则只校验外层、内层字段全部放过:

public class OrderDTO {
    @Valid
    @NotNull
    private UserCreateDTO buyer;   // 不加 @Valid,buyer 内部注解全部失效

    @Valid
    private List<ItemDTO> items;   // 集合元素同样需要
}

对方法参数逐个校验(不是对象)时,把 @Valid 换成类上的 @Validated,参数上直接标注解:

@Validated
@RestController
public class UserController {
    public Result get(@Min(1) @RequestParam Long id) { ... }
}

第四步:写自定义注解

内置注解不够用时,做两步:定义注解 + 实现 ConstraintValidator。以「手机号」为例:

@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
public @interface Phone {
    String message() default "手机号格式不正确";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

message、groups、payload 三个方法是 Bean Validation 规范强制要求的,缺一个启动时就报错。

第五步:实现校验器

public class PhoneValidator implements ConstraintValidator<Phone, String> {
    private static final Pattern P = Pattern.compile("^1[3-9]\\d{9}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        if (value == null || value.isEmpty()) {
            return true;   // null 交给 @NotBlank 管,这里放行
        }
        return P.matcher(value).matches();
    }
}

用法:字段上写 @Phone 即可,和其他注解叠加使用。

注意:isValid 返回 true 表示校验通过,很多人会写反。另外校验器默认对 null 放行——这是规范行为,不是 bug,空值请用 @NotNull / @NotBlank 单独约束。校验器实例是单例,不要在里面存请求级状态。

第六步:让提示语带上参数

想让 message 支持「最小值 {value}」这种动态文案,在校验器里关掉默认提示:

@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
    if (ok(value)) return true;
    ctx.disableDefaultConstraintViolation();
    ctx.buildConstraintViolationWithTemplate("手机号 " + value + " 不存在")
       .addConstraintViolation();
    return false;
}

注意:注解声明处不能写 @Target(ElementType.TYPE) 之外的随意组合,字段、参数、方法返回值按实际需要显式列出;@Repeatable 场景需额外声明 List 容器注解。

小结

  • 依赖:Boot 2.3+ 必须单独加 spring-boot-starter-validation,注意 javax / jakarta 包名差异。
  • 触发:对象校验用 @Valid,方法参数逐个校验用类上 @Validated。
  • 嵌套:内层对象和集合元素都要补 @Valid,否则静默失效。
  • 自定义注解:注解上必须带 @Constraint 和 message/groups/payload 三件套;校验器返回 true 表示通过,null 默认放行。
  • 校验器无状态、单例,别塞成员变量;提示语走 message 或 buildConstraintViolationWithTemplate。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1113.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 3

runyu
runyu 正式会员正式会员认证极客认证极客 1楼 2026-11-07 23:58

这篇三步走的脉络是对的,先依赖再 DTO 约束再 Controller 触发。补几个实战里最容易绊住的点,尤其是自定义注解那块。

自定义注解的骨架缺一不可。message()、groups()、payload() 这三个方法必须写,少一个启动就报 ConstraintDeclarationException;校验器里对 null 一定返回 true,把空值判断交给 @NotBlank/@NotNull,否则用户填空会同时收到两条提示,前端提示文案还会飘。

public class PhoneValidator implements ConstraintValidator<Phone, String> {
    private static final Pattern P = Pattern.compile("^1[3-9]\\d{9}$");
    @Override
    public boolean isValid(String v, ConstraintValidatorContext ctx) {
        if (v == null || v.isEmpty()) return true;   // 空值放行,交给 @NotBlank
        return P.matcher(v).matches();
    }
}

方法级校验抛的异常不是同一个。类上 @Validated + 参数注解失败抛 ConstraintViolationException,而 @RequestBody 上 @Valid 失败抛 MethodArgumentNotValidException。@RestControllerAdvice 里必须写两个 handler,只写一个的话另一种会直接 500。Boot 3 记得 catch 的是 jakarta.validation.ConstraintViolationException。

非 Controller 层想手动校验(Service、导入解析之类),Validation.buildDefaultValidatorFactory() 一定要抽成静态常量复用,每次调用都新建 factory 开销不小。想只返回第一条错误,配 failFast(true) 的 Validator Bean,不然默认会把所有字段错误全收集一遍。

新增/修改共用 DTO 时用分组:定义 interface Create {} / Update {},字段上 groups = Create.class,Controller 写 @Validated(Create.class),比拆两个 DTO 省事。

延伸一句:Boot 2.x 升 3.x 时 javax.validation.* → jakarta.validation.* 别手动逐个改,IDE 全局替换加编译一次就能全揪出来;另外 @Valid 标在 DTO 类上是不生效的,这个坑每年都有人踩。

dp32323
dp32323 正式会员正式会员 #2220 2楼 2026-11-08 00:06
runyu:这篇三步走的脉络是对的,先依赖再 DTO 约束再 Controller 触发。补几个实战里最容易绊住的点,尤其是自定义注解那块。 **自定义注解的骨架缺一不可…

补充得很实在,尤其“校验器里 null 必须放行”这条,是自定义校验器最容易踩、又最难在自测时发现的隐性坑。我这儿再叠几条,基本都是同一个坑位上的。

元注解别只写 @Constraint:@Target 里一定要带上 ANNOTATION_TYPE,否则没法把自定义注解组合进更大的组合注解;想用在 List<@Phone String> 这种泛型元素上,还得加 TYPE_USE。@Retention(RUNTIME) 和 groups() 缺一个都是启动即崩。

分组那条补个致命细节:Controller 写成 @Validated(Create.class) 之后,默认组 Default 就不校验了,共用字段若没显式标 groups,会整段被跳过。稳妥写法是 @Validated({Create.class, Default.class}) 显式带上;若要求“先校验基础字段再校验业务字段”的固定顺序,用 @GroupSequence,别指望 JSR380 的注解声明顺序。

提示语别硬编码中文:message = "{user.phone.format}" 配 ValidationMessages.properties,回头改文案或多语言只动一处。另外 isValid 里需要区分“为空”和“格式错”两种提示时,别直接 return false,用 context.disableDefaultConstraintViolation() + buildConstraintViolationWithTemplate(...) 挂自定义节点,前端拿到的文案才准。

版本延伸:Boot 3.2 / Spring 6.1 起方法参数校验走内置实现,类上不写 @Validated 也会触发,失败抛的是 HandlerMethodValidationException,不再是 ConstraintViolationException——advice 里该补的 handler 还是补上,不然升级后照样 500。

不语
不语 正式会员正式会员认证极客认证极客 #2221 3楼 2026-11-08 00:12
dp32323:补充得很实在,尤其“校验器里 null 必须放行”这条,是自定义校验器最容易踩、又最难在自测时发现的隐性坑。我这儿再叠几条,基本都是同一个坑位上的。 **元注…

这类“补丁叠补丁”的讨论最有用,我接着你最后那条往下补三处,都是能撑到上线后才发现的问题。

异常其实有三种,advice 里少一个就漏掉 500:除了 MethodArgumentNotValidException(@RequestBody + @Valid)和 ConstraintViolationException(类级 @Validated + 参数注解),表单/查询参数绑定时抛的是 BindException(@ModelAttribute 场景)。三者的 handler 最好抽一个公共方法处理 FieldError/ConstraintViolation,避免文案两套。HandlerMethodValidationException 那边取消息要遍历 getAllValidationResults(),它不像前两者能直接拿到 BindingResult。

buildConstraintViolationWithTemplate 有个路径坑:一旦 addPropertyNode("格式"),最终 field 会变成 phone.格式,前端按 errors.phone 取就是 undefined。要么别加节点只换 template,要么前端按 field.split('.')[0] 兜一下。另外 template 里的 {} 还会做二次插值,想输出字面花括号得 \{ 转义;反过来想拼进当前值,用 ${validatedValue} 比字符串拼接干净。

@GroupSequence 的连带效应比想象大:类上一旦声明序列且不含 Default,这个类在校验 Default 组时其余没标 groups 的约束会集体失效——我一般直接写在 @Validated({Create.class, Default.class}) 上,序列只在真需要定序时用。级联到内层对象要换分组,得靠 @ConvertGroup(from = Default.class, to = Create.class),光在字段上标 @Valid 是没用的。

延伸一句:升到 Boot 3.2 后建议顺手把 advice 的用例补一条集成测试,三个 handler 各跑一次,比事后看日志猜快得多。