1. 引言

在之前的 Java Bean Validation 基础 教程中,我们介绍了 jakarta.validation 提供的各种内置校验注解。本文将深入讲解一个非常实用但容易被忽略的功能:如何对校验约束进行分组(Grouping Constraints)

这个特性在实际开发中特别有用,尤其是在多步骤表单、条件校验等场景下,能让你的代码更清晰、更灵活。

2. 使用场景

我们经常会遇到这样的需求:对同一个 Java Bean 的不同字段集合,在不同阶段分别进行校验

举个典型的例子:一个两步注册流程。

  • 第一步:用户填写基本信息,如姓名、邮箱、手机号、验证码;
  • 第二步:用户填写详细地址信息,如街道、城市、邮编等。

关键点在于:验证码在两个步骤中都需要校验。如果使用默认的校验方式,每次都会校验所有字段,显然不符合业务逻辑。

这时候,约束分组(Constraint Groups) 就派上用场了。

3. 约束分组实战

jakarta.validation 中的所有校验注解(如 @NotBlank, @Email)都提供了一个 groups 属性。通过这个属性,我们可以指定该注解属于哪个校验组。校验时,只需指定对应的组,就能只校验该组内的字段。

✅ 核心机制:

每个约束可以指定一个或多个 group,校验时传入特定 group,只会触发该 group 下的约束。

⚠️ 注意:默认情况下,所有未指定 groups 的约束都属于 Default.class

3.1 定义校验组接口

校验组本质上是空接口,仅作为标识使用。

我们为注册表单定义两个组:

public interface BasicInfo {
}
public interface AdvanceInfo {
}

这两个接口没有任何方法,纯粹作为类型标记(type marker),用于在注解中引用。

3.2 在实体类中使用分组

接下来,在 RegistrationForm 中使用这些组:

public class RegistrationForm {
    @NotBlank(groups = BasicInfo.class)
    private String firstName;
    
    @NotBlank(groups = BasicInfo.class)
    private String lastName;
    
    @Email(groups = BasicInfo.class)
    private String email;
    
    @NotBlank(groups = BasicInfo.class)
    private String phone;

    @NotBlank(groups = {BasicInfo.class, AdvanceInfo.class})
    private String captcha;

    @NotBlank(groups = AdvanceInfo.class)
    private String street;
    
    @NotBlank(groups = AdvanceInfo.class)
    private String houseNumber;
    
    @NotBlank(groups = AdvanceInfo.class)
    private String zipCode;
    
    @NotBlank(groups = AdvanceInfo.class)
    private String city;
    
    @NotBlank(groups = AdvanceInfo.class)
    private String contry;
}

关键点解析:

  • firstName, email 等只属于 BasicInfo 组;
  • street, city 等只属于 AdvanceInfo 组;
  • captcha 同时属于两个组,表示它在两步中都需要校验 ✅

3.3 单一组校验测试

现在我们来验证分组是否生效。

测试 BasicInfo 分组

当只校验 BasicInfo 组时,即使 AdvanceInfo 的字段为空,也不应报错:

@Test
public void whenBasicInfoIsNotComplete_thenShouldGiveConstraintViolationsOnlyForBasicInfo() {
    RegistrationForm form = buildRegistrationFormWithBasicInfo();
    form.setFirstName(""); // 模拟 firstName 为空
 
    Set<ConstraintViolation<RegistrationForm>> violations = validator.validate(form, BasicInfo.class);
 
    assertThat(violations.size()).isEqualTo(1);
    violations.forEach(violation -> {
        assertThat(violation.getMessage()).isEqualTo("must not be blank");
        assertThat(violation.getPropertyPath().toString()).isEqualTo("firstName");
    });
}

private RegistrationForm buildRegistrationFormWithBasicInfo() {
    RegistrationForm form = new RegistrationForm();
    form.setFirstName("devender");
    form.setLastName("kumar");
    form.setEmail("devender@example.com");
    form.setPhone("12345");
    form.setCaptcha("Y2HAhU5T");
    return form;
}

测试 AdvanceInfo 分组

同理,只校验 AdvanceInfo 组:

@Test
public void whenAdvanceInfoIsNotComplete_thenShouldGiveConstraintViolationsOnlyForAdvanceInfo() {
    RegistrationForm form = buildRegistrationFormWithAdvanceInfo();
    form.setZipCode(""); // 模拟 zipCode 为空
 
    Set<ConstraintViolation<RegistrationForm>> violations = validator.validate(form, AdvanceInfo.class);
 
    assertThat(violations.size()).isEqualTo(1);
    violations.forEach(violation -> {
        assertThat(violation.getMessage()).isEqualTo("must not be blank");
        assertThat(violation.getPropertyPath().toString()).isEqualTo("zipCode");
    });
}

private RegistrationForm buildRegistrationFormWithAdvanceInfo() {
    RegistrationForm form = new RegistrationForm();
    return populateAdvanceInfo(form);
}

private RegistrationForm populateAdvanceInfo(RegistrationForm form) {
    form.setCity("Berlin");
    form.setContry("DE");
    form.setStreet("alexa str.");
    form.setZipCode("19923");
    form.setHouseNumber("2a");
    form.setCaptcha("Y2HAhU5T");
    return form;
}

3.4 多组校验测试

captcha 字段同时属于两个组,我们分别测试:

在 BasicInfo 组中校验 captcha

@Test
public void whenCaptchaIsBlank_thenShouldGiveConstraintViolationsForBasicInfo() {
    RegistrationForm form = buildRegistrationFormWithBasicInfo();
    form.setCaptcha("");
 
    Set<ConstraintViolation<RegistrationForm>> violations = validator.validate(form, BasicInfo.class);
 
    assertThat(violations.size()).isEqualTo(1);
    violations.forEach(violation -> {
        assertThat(violation.getMessage()).isEqualTo("must not be blank");
        assertThat(violation.getPropertyPath().toString()).isEqualTo("captcha");
    });
}

在 AdvanceInfo 组中校验 captcha

@Test
public void whenCaptchaIsBlank_thenShouldGiveConstraintViolationsForAdvanceInfo() {
    RegistrationForm form = buildRegistrationFormWithAdvanceInfo();
    form.setCaptcha("");
 
    Set<ConstraintViolation<RegistrationForm>> violations = validator.validate(form, AdvanceInfo.class);
 
    assertThat(violations.size()).isEqualTo(1);
    violations.forEach(violation -> {
        assertThat(violation.getMessage()).isEqualTo("must not be blank");
        assertThat(violation.getPropertyPath().toString()).isEqualTo("captcha");
    });
}

✅ 结论:同一字段可跨组校验,灵活度拉满。

4. 使用 GroupSequence 控制校验顺序

默认情况下,各组之间没有执行顺序。但在某些场景下,我们希望 先校验前置组,如果通过再校验后续组,避免不必要的计算。

例如:先校验基本信息,通过后再校验地址信息。

这时就要用到 @GroupSequence 注解。

4.1 在实体类上使用 GroupSequence

最简单的方式是直接在实体类上标注:

@GroupSequence({BasicInfo.class, AdvanceInfo.class})
public class RegistrationForm {
    @NotBlank(groups = BasicInfo.class)
    private String firstName;
    
    @NotBlank(groups = AdvanceInfo.class)
    private String street;
}

这样,当你使用 CompleteInfo 类型的组进行校验时,会**先执行 BasicInfo,再执行 AdvanceInfo**。

4.2 在接口上定义 GroupSequence(推荐)

更优雅的方式是定义一个接口来封装顺序,便于复用:

@GroupSequence({BasicInfo.class, AdvanceInfo.class})
public interface CompleteInfo {
}

这样,多个实体都可以使用 CompleteInfo 作为校验入口,保持一致性。

4.3 测试 GroupSequence 行为

测试前置组失败时,后续组不执行

@Test
public void whenBasicInfoIsNotComplete_thenShouldGiveConstraintViolationsForBasicInfoOnly() {
    RegistrationForm form = buildRegistrationFormWithBasicInfo();
    form.setFirstName("");
 
    Set<ConstraintViolation<RegistrationForm>> violations = validator.validate(form, CompleteInfo.class);
 
    assertThat(violations.size()).isEqualTo(1);
    violations.forEach(violation -> {
        assertThat(violation.getMessage()).isEqualTo("must not be blank");
        assertThat(violation.getPropertyPath().toString()).isEqualTo("firstName");
    });
    // 注意:即使 street 为空,也不会报错,因为 BasicInfo 失败后 AdvanceInfo 不会执行
}

测试全量校验通过

@Test
public void whenBasicAndAdvanceInfoIsComplete_thenShouldNotGiveConstraintViolationsWithCompleteInfoValidationGroup() {
    RegistrationForm form = buildRegistrationFormWithBasicAndAdvanceInfo();
 
    Set<ConstraintViolation<RegistrationForm>> violations = validator.validate(form, CompleteInfo.class);
 
    assertThat(violations.size()).isEqualTo(0);
}

✅ 踩坑提醒:

GroupSequence短路机制,一旦某组校验失败,后续组将不再执行。这能提升性能,但也意味着你拿不到所有错误信息。如果需要收集全部错误,不要使用 GroupSequence

5. 总结

通过本文,你应该掌握了 jakarta.validation 中约束分组的核心用法:

  • ✅ 使用空接口定义 groups,实现逻辑分组;
  • ✅ 通过 groups 属性将注解分配到不同组;
  • ✅ 校验时传入指定 group,实现按需校验;
  • ✅ 使用 @GroupSequence 控制校验顺序,支持短路执行;

这个技巧在实际项目中非常实用,尤其是在处理复杂表单、向导流程、条件校验等场景时,能显著提升代码的可维护性和灵活性。

所有示例代码已托管至 GitHub:https://github.com/baeldung/tutorials/tree/master/javaxval


原始标题:Grouping Jakarta (Javax) Validation Constraints | Baeldung