Spring 枚举转换器冲突问题分析与解决

在get方法查询的时候不能转换为enum

No enum constant com.tgerp.EQixue.domain.QuestionType.2]

public enum QuestionType implements IEnum<String> {
    SINGLE_CHOICE("1", "单选题"),
    MULTIPLE_CHOICE("2", "多选题"),
    JUDGMENT("3", "判断题"),
    FILL_IN_THE_BLANK("4", "填空题"),
    SUBJECTIVE("5", "主观题");

    private final String code;
    private final String name;

    QuestionType(String code, String name){
        this.code = code;
        this.name = name;
    }

    @JsonValue//序列化时输出code
    public String getCode(){
        return code;
    }

    @JsonCreator
    public static QuestionType fromCode(String code){
        if(map.containsKey(code)){
            return map.get(code);
        }
        throw new IllegalArgumentException("无效状态码:"+code);
    }

    @Override
    public String getValue() {
        return code;
    }

    private  static final Map<String, QuestionType> map = new HashMap<String, QuestionType>();
    static {
        {
            for(QuestionType type: QuestionType.values()){
                map.put(type.getCode(),type);
            }
        }
    }

    /**
     * 判断参数合法性
     */
    public static boolean isValidName(String name) {
        for (QuestionType type: QuestionType.values()) {
            if (type.name().equals(name)) {
                return true;
            }
        }
        return false;
    }
}

原因分析

是因为 Spring MVC 的数据绑定机制与 Jackson 是两套独立的体系

  • @JsonCreator / @JsonValueJackson 的注解,仅影响 Jackson 进行的 JSON 序列化/反序列化。
    典型使用场景:@RequestBody 接收 JSON 请求体时,Spring 会调用 Jackson 将 JSON 转为 Java 对象,此时 @JsonCreator 才会生效。

  • 表单提交application/x-www-form-urlencoded)或 URL 参数@RequestParam@ModelAttribute@PathVariable 等)由 Spring MVC 的 数据绑定 处理。
    默认情况下,Spring 会尝试将字符串参数转换为目标类型(如枚举):

    • 对于枚举类型,Spring 默认使用 Enum.valueOf,即根据枚举的 name() 进行匹配(例如 "SINGLE_CHOICE" 可以成功,而 "1" 则会失败)。
    • 不会读取 Jackson 的 @JsonCreator 注解,因此你的 fromCode(String code) 方法不会被自动调用。

解决方案

既然你的枚举已经定义了 fromCode 静态方法,有以下几种方式让它在非 JSON 场景下工作:

方案一:手动转换(最直接,推荐简单场景)

在控制器方法中,先用 String 接收参数,再手动调用 QuestionType.fromCode

@GetMapping("/questions")
public Result getQuestions(@RequestParam("type") String typeCode) {
    QuestionType type = QuestionType.fromCode(typeCode);
    // ...
}

如果不希望每个接口都写转换代码,可以封装一个工具方法或使用 Spring 的转换服务。

方案二:注册自定义 Converter<String, QuestionType>

Spring 允许我们为特定类型注册全局转换器,这样 @RequestParam 就能自动识别 "1" 转成 QuestionType

@Component
public class StringToQuestionTypeConverter implements Converter<String, QuestionType> {
    @Override
    public QuestionType convert(String source) {
        return QuestionType.fromCode(source);
    }
}

然后通过配置类注册(如果使用 Spring Boot,@Component + WebMvcConfigurer 即可):

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToQuestionTypeConverter());
    }
}

配置后,直接在控制器写 @RequestParam QuestionType type,Spring 会自动调用你的转换器。

方案三:使用 @InitBinder 注册属性编辑器

适用于某个 Controller 内的局部配置:

@RestController
public class QuestionController {

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.registerCustomEditor(QuestionType.class, new PropertyEditorSupport() {
            @Override
            public void setAsText(String text) throws IllegalArgumentException {
                setValue(QuestionType.fromCode(text));
            }
        });
    }

    @GetMapping("/questions")
    public Result getQuestions(@RequestParam QuestionType type) {
        // 直接使用,Spring 会自动调用上面的编辑器转换
    }
}

问题描述

在 Spring MVC 数据绑定过程中,出现如下错误:

Field error in object 'subjectConfigDTO' on field 'difficulty': rejected value [3]; 
codes [typeMismatch.subjectConfigDTO.difficulty,typeMismatch.difficulty,typeMismatch.com.tgerp.EQixue.enums.QuestionLevel,typeMismatch]; 
arguments [...]; 
default message [Failed to convert property value of type 'java.lang.String' to required type 'com.tgerp.EQixue.enums.QuestionLevel' for property 'difficulty'; 
nested exception is org.springframework.core.convert.ConversionFailedException: 
Failed to convert from type [java.lang.String] to type [com.tgerp.EQixue.enums.QuestionLevel] for value '3'; 
nested exception is java.lang.IllegalArgumentException: No enum constant com.tgerp.EQixue.enums.QuestionLevel.3]

根本原因:Spring 默认的 StringToEnumConverterFactory 与自定义的 StringToEnumWithCodeConverterFactory 存在竞争关系。默认转换器优先匹配,导致传入的值 "3" 被当作枚举常量名(而非 code 属性)去查找,从而抛出异常。


原有自定义转换器代码

1. 转换器工厂

@Component
public class StringToEnumWithCodeConverterFactory implements ConverterFactory<String, EnumWithCode> {
    @Override
    public <E extends EnumWithCode> Converter<String, E> getConverter(Class<E> targetType) {
        return source -> {
            System.out.println(source);
            System.out.println(EnumWithCode.fromCode(source, targetType));
            return EnumWithCode.fromCode(source, targetType);
        };
    }
}

2. 枚举公共接口

public interface EnumWithCode {
    @JsonCreator
    static <E extends EnumWithCode> E fromCode(String source, Class<E> enumClass) {
        System.out.println(Arrays.toString(enumClass.getEnumConstants()));
        for (E constant : enumClass.getEnumConstants()) {
            if (constant.getCode().equals(source)) {
                return constant;
            }
        }
        throw new IllegalArgumentException("Unknown code: " + source);
    }
    String getCode();
}

3. 实际枚举类(同时继承 MyBatis-Plus 的 IEnum)

public enum LessonStatus implements IEnum<String>, EnumWithCode {
    // 枚举定义...
}

原因分析

查找到每次是在哪里进行的转换

ConversionService.convert(…)

// 位于 GenericConversionService.ConverterFactoryAdapter 内部
@Override
public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
    return source == null 
        ? GenericConversionService.this.convertNullSource(sourceType, targetType)
        : this.converterFactory.getConverter(targetType.getObjectType()).convert(source);
}

这里一直匹配到StringToEnumConverterFactory
难道是 Spring 默认的 StringToEnumConverterFactory 与你的自定义转换器存在竞争关系。
Spring 的注册顺序通常是决定优先级的核心,谁先被注册,谁就可能被优先匹配。

1.通过 @Order 注解设置一个很小的值(如 Ordered.HIGHEST_PRECEDENCE)

@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 设置最高优先级
public class StringToEnumWithCodeConverterFactory implements ConverterFactory<String, EnumWithCode> {
    // ... 你的转换逻辑实现
}

2.通过 WebMvcConfigurer 编程式注册 (精细控制)

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        // 显式注册,确保它优先于默认的转换器
        registry.addConverterFactory(new StringToEnumWithCodeConverterFactory());
    }
}

这里我是用来@Order 注解设置一个很小的值来设置,还是不行,继续debug

转换器匹配与缓存机制

Spring 在 GenericConversionService 中维护了一个 converterCache,用于加速转换器查找:

ConverterCacheKey key = new ConverterCacheKey(sourceType, targetType);
GenericConverter converter = (GenericConverter) this.converterCache.get(key);
if (converter != null) {
    return converter != NO_MATCH ? converter : null;
} else {
    converter = this.converters.find(sourceType, targetType);
    if (converter == null) {
        converter = this.getDefaultConverter(sourceType, targetType);
    }
    if (converter != null) {
        this.converterCache.put(key, converter);   // 缓存匹配结果
        return converter;
    } else {
        this.converterCache.put(key, NO_MATCH);
        return null;
    }
}

核心要点:一旦某个转换器被缓存,后续相同源类型/目标类型的转换请求将直接使用缓存的转换器,即使后来注册了更合适的转换器也不会重新匹配。

💡 小贴士:Spring 的转换器缓存是应用级别的,在容器刷新时初始化。修改转换器注册逻辑后,必须重启应用(或重新加载 Spring 容器),否则旧的转换器仍会从缓存中命中。单纯的热部署(如 Spring DevTools 重启部分上下文)可能无效,因为缓存属于核心 ConversionService 单例。

转换器查找算法:遍历类层级

当缓存未命中时,converters.find() 会通过遍历源类型和目标类型的整个类层级来寻找可用的 ConvertiblePair。

public GenericConverter find(TypeDescriptor sourceType, TypeDescriptor targetType) {
    List<Class<?>> sourceCandidates = this.getClassHierarchy(sourceType.getType());
    List<Class<?>> targetCandidates = this.getClassHierarchy(targetType.getType());

    for (Class<?> sourceCandidate : sourceCandidates) {
        for (Class<?> targetCandidate : targetCandidates) {
            ConvertiblePair pair = new ConvertiblePair(sourceCandidate, targetCandidate);
            GenericConverter converter = this.getRegisteredConverter(sourceType, targetType, pair);
            if (converter != null) {
                return converter;
            }
        }
    }
    return null;
}
其中 getClassHierarchy() 方法会递归收集类本身、父类、接口,并对 Enum 做特殊处理。源码如下:
List<Class<?>> hierarchy = new ArrayList<>(20);
Set<Class<?>> visited = new HashSet<>(20);
addToClassHierarchy(0, ClassUtils.resolvePrimitiveIfNecessary(type), false, hierarchy, visited);
boolean array = type.isArray();

for (int i = 0; i < hierarchy.size(); ++i) {
    Class<?> candidate = hierarchy.get(i);
    candidate = array ? candidate.getComponentType() : ClassUtils.resolvePrimitiveIfNecessary(candidate);
    Class<?> superclass = candidate.getSuperclass();
    if (superclass != null && superclass != Object.class && superclass != Enum.class) {
        addToClassHierarchy(i + 1, candidate.getSuperclass(), array, hierarchy, visited);
    }
    addInterfacesToClassHierarchy(candidate, array, hierarchy, visited);
}

if (Enum.class.isAssignableFrom(type)) {
    addToClassHierarchy(hierarchy.size(), Enum.class, array, hierarchy, visited);
    addToClassHierarchy(hierarchy.size(), Enum.class, false, hierarchy, visited);
    addInterfacesToClassHierarchy(Enum.class, array, hierarchy, visited);
}

addToClassHierarchy(hierarchy.size(), Object.class, array, hierarchy, visited);
addToClassHierarchy(hierarchy.size(), Object.class, false, hierarchy, visited);
return hierarchy;

关键点:对于枚举类型,Enum.class 会被显式加入到类层级中。这意味着即使目标类型是 LessonStatus,Enum.class 也会出现在候选列表中。因此,注册在 Enum 上的转换器(默认的 StringToEnumConverterFactory)在遍历时可能会被优先匹配。

这里我们debug发现EnumCode这个接口是在Enum之前的,在mybatis-plus IEnum之后的,难道是接口顺序问题,调换之后依然无效。

GenericConverter converter = this.getRegisteredConverter(sourceType, targetType, convertiblePair);
 private GenericConverter getRegisteredConverter(TypeDescriptor sourceType, TypeDescriptor targetType, GenericConverter.ConvertiblePair convertiblePair) {
            ConvertersForPair convertersForPair = (ConvertersForPair)this.converters.get(convertiblePair);
            if (convertersForPair != null) {
                GenericConverter converter = convertersForPair.getConverter(sourceType, targetType);
                if (converter != null) {
                    return converter;
                }
            }

            for(GenericConverter globalConverter : this.globalConverters) {
                if (((ConditionalConverter)globalConverter).matches(sourceType, targetType)) {
                    return globalConverter;
                }
            }

            return null;
        }

这里的this.converters和globalConverter均不包含,我们的StringToEnumWithCodeConverterFactory,难道是没有注册成bean?
在这里插入图片描述
在idea中我们是能够发现bean的,看来只能去看一下是怎么创建的了。

Spring 转换器注册顺序

Spring 转换器的注册分为两个阶段:

第一阶段:服务创建与内置转换器注册

DefaultConversionService.addDefaultConverters() 中,默认注册了 StringToEnumConverterFactory,它会将字符串直接转换为枚举常量(按名称匹配)。

public static void addDefaultConverters(ConverterRegistry converterRegistry) {
    addScalarConverters(converterRegistry);      // 这里添加了 StringToEnumConverterFactory
    addCollectionConverters(converterRegistry);
    // ... 其他转换器
}
第二阶段:应用启动与自定义转换器注册

Spring 会从 BeanFactory 中获取所有 GenericConverterConverterPrinterParser 类型的 Bean 并注册到 FormatterRegistry

Set<Object> beans = new LinkedHashSet<>();
beans.addAll(beanFactory.getBeansOfType(GenericConverter.class).values());
beans.addAll(beanFactory.getBeansOfType(Converter.class).values());
// ... 遍历并注册

这里在注册的时候,并没有ConverterFactory,ConverterFactory怎么注册后续再研究吧,这里我们可以先使用其他方法来解决

解决方案

暂且修改为ConditionalGenericConverter

@Component
public class StringToEnumWithCodeConverter implements ConditionalGenericConverter {

    @Override
    public Set<ConvertiblePair> getConvertibleTypes() {
        // 声明它能处理 String -> Enum 的转换
        return Collections.singleton(new ConvertiblePair(String.class, Enum.class));
    }

    @Override
    public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
        // 核心逻辑:检查目标类型是否是 Enum 且实现了 EnumWithCode 接口
        Class<?> targetClass = targetType.getType();
        return targetClass.isEnum() && EnumWithCode.class.isAssignableFrom(targetClass);
    }

    @Override
    public EnumWithCode convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
        // 转换逻辑:根据code查找枚举实例
        String code = (String) source;
        // 这里实现你的通过code获取枚举的逻辑...
        return EnumWithCode.fromCode(code, (Class<? extends EnumWithCode>)targetType.getType());
    }

}

在这里插入图片描述

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐