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