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 名称只能是 NEWDEFAULT

@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


原始标题:Validations for Enum Types