1. 引言
在之前的《Java Bean Validation 基础》和《Spring MVC 自定义校验》两篇文章中,我们已经掌握了如何使用 JSR 380(即 javax.validation
)对常见类型进行校验,以及如何实现自定义注解。
本文将聚焦一个实际开发中常见的痛点:如何优雅地对枚举类型(enum)做校验。你会发现,标准注解对 enum 支持非常有限,很多看似合理的写法其实会直接报错。别急,我们一步步来踩坑、填坑。
2. 枚举校验的现状与限制
⚠️ 大多数标准校验注解(如 @Pattern
、@Size
等)无法直接用于枚举类型。
比如你尝试给 enum 字段加上 @Pattern
:
@Pattern(regexp = "NEW|DEFAULT")
private CustomerType customerType;
运行时会抛出类似这样的异常:
javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint
'javax.validation.constraints.Pattern' validating type 'com.baeldung.javaxval.enums.demo.CustomerType'.
Check configuration for 'customerTypeMatchesPattern'
✅ 目前能直接用于 enum 的标准注解只有两个:@NotNull
和 @Null
。
所以,想对 enum 做更复杂的校验?只能自己动手,丰衣足食。
3. 校验枚举名称的正则表达式
3.1 定义注解
我们先实现一个能校验 enum 名称是否符合正则的自定义注解:
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = EnumNamePatternValidator.class)
public @interface EnumNamePattern {
String regexp();
String message() default "must match \"{regexp}\"";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
3.2 使用示例
把这个注解用在字段上,限制 enum 名称只能是 NEW
或 DEFAULT
:
@EnumNamePattern(regexp = "NEW|DEFAULT")
private CustomerType customerType;
3.3 实现校验器
注解本身不包含逻辑,真正的校验由 ConstraintValidator
完成:
public class EnumNamePatternValidator implements ConstraintValidator<EnumNamePattern, Enum<?>> {
private Pattern pattern;
@Override
public void initialize(EnumNamePattern annotation) {
try {
pattern = Pattern.compile(annotation.regexp());
} catch (PatternSyntaxException e) {
throw new IllegalArgumentException("Given regex is invalid", e);
}
}
@Override
public boolean isValid(Enum<?> value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
Matcher m = pattern.matcher(value.name());
return m.matches();
}
}
📌 核心逻辑:通过 value.name()
获取枚举的名称(如 CustomerType.NEW.name()
返回 "NEW"
),再用正则匹配。
⚠️ 缺点:正则写错了编译期无法发现,属于“字符串魔法”,不够类型安全。
4. 校验枚举值是否属于指定子集
4.1 为什么不用正则?
虽然正则能解决问题,但它是字符串匹配,没有编译期检查。万一拼错 enum 名称,只有运行时才能发现。
更安全的做法是:直接用 enum 常量做比对。
4.2 定义子集校验注解
由于 Java 注解不支持泛型枚举参数,我们只能为每个 enum 类型单独定义注解。以下是针对 CustomerType
的实现:
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = CustomerTypeSubSetValidator.class)
public @interface CustomerTypeSubset {
CustomerType[] anyOf();
String message() default "must be any of {anyOf}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
4.3 使用方式
直接传入允许的 enum 常量:
@CustomerTypeSubset(anyOf = {CustomerType.NEW, CustomerType.OLD})
private CustomerType customerType;
4.4 实现校验器
public class CustomerTypeSubSetValidator implements ConstraintValidator<CustomerTypeSubset, CustomerType> {
private CustomerType[] subset;
@Override
public void initialize(CustomerTypeSubset constraint) {
this.subset = constraint.anyOf();
}
@Override
public boolean isValid(CustomerType value, ConstraintValidatorContext context) {
return value == null || Arrays.asList(subset).contains(value);
}
}
✅ 优点:类型安全,IDE 能自动补全,拼写错误编译报错。
❌ 缺点:每个 enum 都要写一套注解和校验器,代码重复。
💡 折中方案:可以把 isValid
逻辑抽象成通用工具方法,在多个校验器中复用。
5. 校验字符串是否匹配枚举值
5.1 场景说明
在处理 JSON 请求时,前端传的是字符串(如 "NEW"
),后端用 enum 接收。如果传了非法值(如 "UNDEFINED"
),Jackson 会直接抛异常:
Cannot deserialize value of type CustomerType from String value 'UNDEFINED': value not one
of declared Enum instance names: [...]
这个异常会打断整个反序列化流程,而且无法和其他校验错误一起返回。
5.2 更优雅的解法
👉 先用 String
接收,再通过校验注解判断其值是否合法。
5.3 定义通用字符串校验注解
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = ValueOfEnumValidator.class)
public @interface ValueOfEnum {
Class<? extends Enum<?>> enumClass();
String message() default "must be any of enum {enumClass}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
5.4 使用示例
@ValueOfEnum(enumClass = CustomerType.class)
private String customerTypeString;
5.5 实现校验器
public class ValueOfEnumValidator implements ConstraintValidator<ValueOfEnum, CharSequence> {
private List<String> acceptedValues;
@Override
public void initialize(ValueOfEnum annotation) {
acceptedValues = Stream.of(annotation.enumClass().getEnumConstants())
.map(Enum::name)
.collect(Collectors.toList());
}
@Override
public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return acceptedValues.contains(value.toString());
}
}
📌 核心逻辑:初始化时提取目标 enum 的所有 name()
值,校验时判断字符串是否在集合中。
✅ 优势:
- 可与其他校验(如
@NotBlank
)组合使用 - 所有错误可统一收集返回
- 对前端更友好
6. 综合使用示例
现在我们可以把所有自定义注解组合使用:
public class Customer {
@ValueOfEnum(enumClass = CustomerType.class)
private String customerTypeString;
@NotNull
@CustomerTypeSubset(anyOf = {CustomerType.NEW, CustomerType.OLD})
private CustomerType customerTypeOfSubset;
@EnumNamePattern(regexp = "NEW|DEFAULT")
private CustomerType customerTypeMatchesPattern;
// constructor, getters etc.
}
📌 注意:
- 所有自定义校验器默认允许
null
(符合 Bean Validation 规范) - 如需非空,必须显式加上
@NotNull
7. 单元测试验证
7.1 准备工作
public class EnumValidationTest {
private static Validator validator;
@BeforeAll
static void setUp() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
}
7.2 测试正常情况
@Test
public void whenAllAcceptable_thenShouldNotGiveConstraintViolations() {
Customer customer = new Customer();
customer.setCustomerTypeOfSubset(CustomerType.NEW);
Set<ConstraintViolation<Customer>> violations = validator.validate(customer);
assertThat(violations).isEmpty();
}
7.3 测试 null 值处理
@Test
public void whenAllNull_thenOnlyNotNullShouldGiveConstraintViolations() {
Customer customer = new Customer();
Set<ConstraintViolation<Customer>> violations = validator.validate(customer);
assertThat(violations.size()).isEqualTo(1);
assertThat(violations)
.anyMatch(havingPropertyPath("customerTypeOfSubset")
.and(havingMessage("must not be null")));
}
7.4 测试非法输入
@Test
public void whenAllInvalid_thenViolationsShouldBeReported() {
Customer customer = new Customer();
customer.setCustomerTypeString("invalid");
customer.setCustomerTypeOfSubset(CustomerType.DEFAULT);
customer.setCustomerTypeMatchesPattern(CustomerType.OLD);
Set<ConstraintViolation<Customer>> violations = validator.validate(customer);
assertThat(violations.size()).isEqualTo(3);
assertThat(violations)
.anyMatch(havingPropertyPath("customerTypeString")
.and(havingMessage("must be any of enum class com.baeldung.javaxval.enums.demo.CustomerType")));
assertThat(violations)
.anyMatch(havingPropertyPath("customerTypeOfSubset")
.and(havingMessage("must be any of [NEW, OLD]")));
assertThat(violations)
.anyMatch(havingPropertyPath("customerTypeMatchesPattern")
.and(havingMessage("must match \"NEW|DEFAULT\"")));
}
✅ 确保每个校验器都能正确触发错误信息。
8. 总结
本文介绍了三种校验枚举的实用方案,各有适用场景:
方案 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
@EnumNamePattern + 正则 |
快速原型、简单规则 | 简单直接 | 类型不安全 |
@CustomerTypeSubset 子集校验 |
类型安全要求高 | 编译期检查 | 每个 enum 需单独定义 |
@ValueOfEnum 校验字符串 |
接收 JSON 请求参数 | 可聚合错误、体验好 | 多一层转换 |
📌 最佳实践建议:
- 内部服务间调用:直接用 enum + 子集校验
- 对外 API 接口:用
String
+@ValueOfEnum
,配合全局异常处理返回统一错误格式
所有示例代码已托管至 GitHub:https://github.com/yourname/tutorials/tree/master/javaxval