☰
ValidX分组验证详解:不同场景使用不同验证规则
2026/10/20 1:46:24 网站建设 项目流程

ValidX分组验证详解:不同场景使用不同验证规则

引言

一个 DTO 引发的连环事故:

  • 创建时必填、更新时禁止:UserDTO里用户名注册时必须填、编辑资料时却不用改;ID 新增时不能传、修改时必须带。于是团队拆了CreateUserDTO和UpdateUserDTO两个类,字段复制一份,改字段时漏改一处,线上悄悄出现"更新把用户名清空了"的 bug。
  • 同一字段两套规则:订单号在"下单"和"查询"两个接口里一个必填、一个可空。为了一个字段写两个 DTO,Controller 里到处是if (dto.getX() != null)的补丁式判断。
  • 审核流程校验崩溃:运营后台把"草稿"状态的商品直接提交审核,DTO 里"审核人必须存在"的规则把运营卡死——因为那条规则在草稿阶段本不该执行。

这三个问题的根源是同一个:一份数据在生命周期不同阶段,需要满足的规则不一样,却只有一套验证。

ValidX 基于 Jakarta Bean Validation 规范(项目实际使用javax.validation 2.0.1+Hibernate Validator 6.1.5),所有注解原生支持标准分组机制。本文对照源码讲透分组验证的完整用法:从"分组是什么"到"Default 组/组继承/@GroupSequence"的进阶玩法,再落到 Spring Boot 与链式 API 的边界,最后给出可直接照抄的全场景 DTO。

说明:文中 ValidX 相关内容对照 v1.2.0 源码(全部 100+ 注解的groups属性、chain/ValidX.java链式实现)与测试用例(HourMinuteSecondValidatorTest、NotContainsValidatorTest等使用Validation.buildDefaultValidatorFactory().getValidator().validate(...)的标准触发方式)核实。


一、为什么需要分组验证

1.1 一套数据,多种身份

同一个业务对象在不同阶段,规则不同:

场景对象典型规则差异
创建新数据必填字段最多、ID 必须为空
更新存量数据ID 必须存在、部分字段禁止修改
审核提审数据只有审核相关字段被校验
查询查询条件全部可空,只校验"填了的"格式

没有分组时,要么拆 DTO(类爆炸、字段重复维护),要么写if补丁(校验逻辑散落、容易漏)。

1.2 分组验证的本质

分组验证是 Bean Validation 规范的标准机制,核心就三件事:

1. 定义分组接口(一个空接口即可) 2. 注解上用 groups 指定它属于哪个分组 3. 触发验证时传入要执行的分组

ValidX 的每个注解都原生带groups()属性,与标准注解@NotBlank、@Size等完全混用,不需要任何额外配置。看一个真实源码中的注解定义(annotations/ChineseName.java):

@Constraint(validatedBy=ChineseNameValidator.class)public@interfaceChineseName{Stringmessage()default"{io.github.vipxieliang.validx.annotation.chinese.name}";Class<?>[]groups()default{};// ← 分组:每个 ValidX 注解都有Class<?extendsPayload>[]payload()default{};}

groups是标准的Class<?>[]数组——一个约束可以同时属于多个分组。


二、三分钟上手:定义分组并用起来

2.1 定义分组

分组就是空接口,没有方法、没有继承约束,纯粹是"标记"。习惯上作为嵌套接口放在 DTO 里,就近管理:

publicclassUserDTO{/** 创建场景 */publicinterfaceCreateGroup{}/** 更新场景 */publicinterfaceUpdateGroup{}// ===== 以下字段按场景挂分组 =====@Null(groups=CreateGroup.class,message="创建时 ID 必须为空")@NotNull(groups=UpdateGroup.class,message="更新时 ID 不能为空")privateLongid;@NotBlank(groups=CreateGroup.class,message="创建时用户名必填")@Size(min=2,max=20,message="用户名长度 2~20")privateStringusername;@EmailprivateStringemail;}

注意email字段没写 groups——它属于默认的Default组(详见第三章),不限定场景时所有场景都校验。

2.2 触发验证:传入分组

非 Spring 环境(工具类、单元测试、服务层手动校验),用标准 Bean Validation 入口:

importjavax.validation.Validation;importjavax.validation.Validator;importjavax.validation.ConstraintViolation;Validatorvalidator=Validation.buildDefaultValidatorFactory().getValidator();// 只执行 CreateGroup 分组的约束Set<ConstraintViolation<UserDTO>>violations=validator.validate(dto,UserDTO.CreateGroup.class);if(!violations.isEmpty()){Stringmsg=violations.iterator().next().getMessage();// 创建时:id 必须为 null、用户名必填……email 的 @Email 也生效}

validate(obj, Class<?>... groups)支持传多个分组,用逗号并列即可:

// 同时执行 CreateGroup 和 UpdateGroupvalidator.validate(dto,UserDTO.CreateGroup.class,UserDTO.UpdateGroup.class);

关键行为:传入的分组决定"哪些约束执行"。没传分组(validator.validate(dto))等价于只传Default组。

2.3 对照源码:这就是 ValidX 测试的调用方式

分组验证不是 ValidX 发明的,但 ValidX 的注解要能被validate(dto, groups...)驱动,前提是每个注解都规范实现了message/groups/payload。这一点在仓库测试里可以验证——所有注解测试都用同一套标准入口:

// 摘自 ValidX 测试用例(如 HourMinuteSecondValidatorTest)ValidatorFactoryfactory=Validation.buildDefaultValidatorFactory();Validatorvalidator=factory.getValidator();Set<ConstraintViolation<TestModel>>violations=validator.validate(model);

把validate(model)换成validate(model, XxxGroup.class),就是分组验证。注解方式的分组支持是天然可用的,0 配置。


三、Default 组:没写 groups 的注解去哪了

3.1 隐式归属

不写groups的约束,隐式属于javax.validation.groups.Default组。这是最容易踩的坑:

publicclassUserDTO{@NotBlank(groups=CreateGroup.class,message="创建时必填")privateStringusername;@Email// 属于 Default 组privateStringemail;}

调用validate(dto, CreateGroup.class)时,只有 username 的 @NotBlank 执行,email 的 @Email 不执行——因为触发的是CreateGroup,而@Email挂在Default上。

3.2 三个判定规则

调用方式执行的约束说明
validate(dto)只执行Default组未标 groups 的约束全部执行
validate(dto, CreateGroup.class)只执行CreateGroup未标 groups 的约束全部跳过
validate(dto, Default.class, CreateGroup.class)Default+CreateGroup传多个组即可合并

也就是说:一旦指定了任意非 Default 分组,那些"忘了写 groups"的约束就会悄悄失效。这是分组验证最常见的"看起来没生效"的原因。

3.3 让分组"包含"默认规则

希望"用 CreateGroup 时默认规则也执行",让分组接口继承 Default:

publicclassUserDTO{/** 让 CreateGroup 包含默认规则 */publicinterfaceCreateGroupextendsDefault{}}

这样validate(dto, CreateGroup.class)会同时执行CreateGroup组约束和Default组约束。命名上更推荐CreateGroup extends Default而不是反过来,语义是"创建时包含通用规则"。


四、组继承:子组天然拥有父组规则

分组接口之间可以互相继承,验证子组时,父组的约束也一并执行:

publicclassUserDTO{/** 基础规则:所有写操作都要校验 */publicinterfaceBasicGroup{}/** 创建 = 基础 + 创建专属 */publicinterfaceCreateGroupextendsBasicGroup{}/** 更新 = 基础 + 更新专属 */publicinterfaceUpdateGroupextendsBasicGroup{}@NotBlank(groups=BasicGroup.class,message="用户名不能为空")@Size(min=2,max=20,message="用户名长度 2~20")privateStringusername;@Null(groups=CreateGroup.class,message="创建时 ID 必须为空")@NotNull(groups=UpdateGroup.class,message="更新时 ID 不能为空")privateLongid;}

行为:

触发分组执行的约束
BasicGroupusername 必填/长度
CreateGroupusername 规则 + id 必须为 null
UpdateGroupusername 规则 + id 不能为 null

继承解决的是"公共规则只写一次":把每写必用的约束挂到父组,各场景组继承它,不用在子组里重复标注。


五、@GroupSequence:控制验证顺序与短路

5.1 为什么需要顺序

默认情况下分组验证无序,全部约束都跑一遍。但有些场景要求"先轻后重":

  • 先做格式校验(字段格式对不对),再做业务校验(值在不在合理范围)——格式都错了,业务校验白算;
  • 先做成本低的校验,再做成本高的校验(如查库)。

@GroupSequence让分组按指定顺序验证,前一个分组有约束失败,后面分组直接跳过(短路)。

importjavax.validation.GroupSequence;/** 先校验基础格式,再校验业务规则 */@GroupSequence({BasicGroup.class,BusinessGroup.class})publicclassUserDTO{publicinterfaceBasicGroup{}publicinterfaceBusinessGroup{}@NotBlank(groups=BasicGroup.class,message="用户名不能为空")privateStringusername;@NotNull(groups=BusinessGroup.class,message="用户名不能重复")privateStringusernameUniqueCheck;// 业务校验示例(实际校验逻辑由自定义验证器承担)}

注意:顺序组只有在验证Default组时才生效——规范规定,触发顺序组的正确姿势是把它作为Default组的定义(类上标注的@GroupSequence会把Default组重定义为序列),所以最稳妥的写法是把默认组加进序列尾部:

@GroupSequence({BasicGroup.class,BusinessGroup.class,Default.class})publicclassUserDTO{...}

验证时照常validate(dto, Default.class)(等价于validate(dto)),就会按BasicGroup → BusinessGroup → Default的顺序执行,前一组的约束有失败,后面的组直接跳过。

5.2 实用建议

@GroupSequence适合同一字段上"格式 → 业务"分级,以及多字段校验成本差异大的场景。如果只是"创建/更新规则不同",用普通分组即可,不要为用而用。


六、分组验证在 Spring Boot 中的落地

6.1 @Validated 指定分组

Spring 里区分两类注解:

注解支持分组适用
@Valid❌无分组的常规校验、嵌套对象递归
@Validated✅需要指定分组时(写在方法参数上)
@RestControllerpublicclassUserController{@PostMapping("/users")publicResultcreate(@Validated(UserDTO.CreateGroup.class)@RequestBodyUserDTOdto){returnuserService.create(dto);}@PutMapping("/users/{id}")publicResultupdate(@Validated(UserDTO.UpdateGroup.class)@RequestBodyUserDTOdto){returnuserService.update(dto);}}

坑点:Controller 方法参数上用了@Validated后,类上的@Validated就不必再写;方法参数级@Validated(分组)只对该参数生效。

6.2 嵌套对象的分组:@ConvertGroup

嵌套对象默认沿用外层分组,需要"把外层分组映射成内层分组"时用@ConvertGroup:

publicclassOrderDTO{@NotNull(groups=SubmitGroup.class,message="收货地址不能为空")@Valid@ConvertGroup(from=SubmitGroup.class,to=AddressDTO.SubmitGroup.class)privateAddressDTOaddress;}

6.3 统一异常处理

分组验证失败同样抛MethodArgumentNotValidException(参数校验失败),与无分组完全一致,全局异常处理器无需改动:

@RestControllerAdvicepublicclassGlobalExceptionHandler{@ExceptionHandler(MethodArgumentNotValidException.class)publicResult<Void>handleValidation(MethodArgumentNotValidExceptione){Stringmsg=e.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage).collect(Collectors.joining(";"));returnResult.fail(400,msg);}}

七、ValidX 的能力边界:注解支持分组,链式 API 不支持

7.1 两种模式的分组支持对比

这是理解 ValidX 必须记住的分工:

能力注解方式链式 API
分组验证✅groups原生支持❌ 无分组概念
触发方式validate(dto, Group.class)/@Validated(Group.class)field(...).isXxx(...)全部当场执行
适用DTO 数据契约、多场景复用动态数据、临时校验、非 Spring 环境

对照源码可以确认:ValidX 全部注解(ChineseName、ChineseIdCard、PhoneNumber、Email、Date、Timestamp……100+ 个)都实现了标准的groups()/payload();而链式入口chain/ValidX.java中没有任何 group 相关方法——isValid()就是全部校验结果,不支持按分组选择性执行。

7.2 链式 API 需要"分组"怎么办

链式 API 没有分组,两种替代方案:

方案一:职责拆分。把"必填/格式"这类场景差异放到注解 DTO 上走分组,链式 API 只做流程内的临时校验,各司其职:

// 注解 DTO:分组管"什么场景校验什么"// 链式 API:管"动态数据当场校验"ValidXvalidator=ValidX.init().field("回调时间戳").isTimestamp(notify.getTimestamp(),Timestamp.TimestampUnit.SECONDS);

方案二:if 分支模拟。业务场景本身可以用分支表达:

if(isCreate){validator.field("用户名").isRequired(username);}else{validator.field("用户名").isAlphaNum(username);}

两种方案都不完美,但足够清晰。凡是"同一 DTO 多场景复用",优先用注解方式 + 分组——这正是本文的适用面。


八、实战:用户资料 DTO 全场景覆盖

把前面的知识串起来,写一个覆盖"注册 / 更新 / 审核"三个场景的完整 DTO:

importjavax.validation.Valid;importjavax.validation.constraints.*;importjavax.validation.groups.Default;importio.github.vipxieliang.validx.annotations.*;publicclassUserDTO{// ===== 分组定义 =====/** 公共规则(所有写操作) */publicinterfaceBasicGroup{}/** 注册 */publicinterfaceCreateGroupextendsBasicGroup,Default{}/** 更新资料 */publicinterfaceUpdateGroupextendsBasicGroup,Default{}/** 运营审核 */publicinterfaceAuditGroupextendsDefault{}// ===== 字段 =====@Null(groups=CreateGroup.class,message="注册时 ID 必须为空")@NotNull(groups=UpdateGroup.class,message="更新时 ID 不能为空")privateLongid;@NotBlank(groups=BasicGroup.class,message="用户名不能为空")@Size(min=2,max=20,message="用户名长度 2~20")@AlphaNum// ValidX:字母数字privateStringusername;@Email// Default 组:所有场景都校验格式privateStringemail;@NotBlank(groups=CreateGroup.class,message="注册时密码必填")@Password(minLength=8,groups=CreateGroup.class,message="密码至少 8 位")privateStringpassword;@PhoneNumber// Default 组:有值才校验格式privateStringphone;@NotBlank(groups=AuditGroup.class,message="审核意见不能为空")@Size(max=200,message="审核意见最多 200 字")privateStringauditRemark;}

三个场景触发:

// 注册:创建专属 + 公共 + 默认(email 格式、phone 格式也校验)validator.validate(dto,UserDTO.CreateGroup.class);// 更新:更新专属 + 公共 + 默认validator.validate(dto,UserDTO.UpdateGroup.class);// 审核:只校验审核意见 + 默认格式validator.validate(dto,UserDTO.AuditGroup.class);

这个 DTO 在 Spring 中对应三个接口:

@PostMapping("/register")publicResultregister(@Validated(UserDTO.CreateGroup.class)@RequestBodyUserDTOdto){...}@PutMapping("/profile")publicResultupdateProfile(@Validated(UserDTO.UpdateGroup.class)@RequestBodyUserDTOdto){...}@PostMapping("/audit")publicResultaudit(@Validated(UserDTO.AuditGroup.class)@RequestBodyUserDTOdto){...}

一个 DTO、三套规则、零if分支。字段规则变更只改一处,新增场景只加一个分组接口。


九、常见坑清单

  1. 指定分组后"忘了写 groups"的约束全失效——@Email没写groups,用CreateGroup触发时它不执行。解决办法:让分组extends Default,或明确给约束标组。
  2. Spring 里用@Valid传分组——@Valid不支持分组参数,指定分组必须用@Validated(分组.class)。
  3. 类上和方法参数上同时写@Validated——方法参数级会覆盖类级,避免混淆,分组写在需要的那一层。
  4. 嵌套对象分组没对齐——内层 DTO 的约束挂了内层分组,外层触发的是外层分组,用@ConvertGroup显式映射。
  5. @GroupSequence不生效——顺序组必须通过Default触发(把顺序组作为Default的定义),直接validate(dto, 某顺序组.class)不会按序列执行。
  6. 链式 API 里找分组——链式 API 没有分组,别在field(...)上找group()方法,那是注解方式的特性。
  7. 分组接口误加方法——分组接口必须是空接口(纯标记),加方法会破坏分组语义。

十、最佳实践清单

  1. 分组接口就近嵌套:放在 DTO 内部(UserDTO.CreateGroup),字段分组一目了然;
  2. 公共规则上提父组:多场景共用的约束挂父组,子组extends父组,避免重复标注;
  3. 必填差异用分组,格式校验放 Default:格式类约束(@Email、@PhoneNumber、@AlphaNum)默认挂 Default 全场景生效,只有"必填与否"的差异才用分组区分;
  4. 一个字段一个语义:同一字段多分组标注时,groups数组一次写全,别用两行相同约束制造歧义;
  5. Spring 中分组只写在方法参数:@Validated(CreateGroup.class)直接标注在方法参数上,作用域最清晰;
  6. 顺序校验才用 @GroupSequence:普通创建/更新差异不需要顺序,别为用而用;
  7. 文档写明分组触发点:接口文档标注"创建/更新/审核分别校验哪些分组",让调用方知道错误信息的范围;
  8. 与链式 API 分工:多场景 DTO 用注解 + 分组,动态数据临时校验用链式 API,各司其职。

总结

  • 分组验证是 Bean Validation 标准机制,ValidX 所有注解原生支持groups(),与@NotBlank/@Size等标准注解完全混用,0 配置;
  • 核心三要素:定义分组接口 → 注解标groups→ 验证时传分组;
  • 不写groups的约束属于Default组,指定非 Default 分组后它们会失效,可用分组 extends Default合并;
  • 组继承让"公共规则只写一次",@GroupSequence提供顺序验证与短路;
  • Spring 中用@Validated(分组.class)指定分组,@Valid不支持;
  • ValidX 的能力边界:注解方式支持分组,链式 API(chain/ValidX.java)不支持——多场景 DTO 复用请走注解方式。

ValidX 是基于 Jakarta Bean Validation 规范的 Java 验证库,注解与链式 API 双模式,内置 100+ 验证规则。项目地址:github.com/vipxieliang/validx

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询