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

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. 转换器工厂

java 复制代码
@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. 枚举公共接口

java 复制代码
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)

java 复制代码
public enum LessonStatus implements IEnum<String>, EnumWithCode {
    // 枚举定义...
}

原因分析

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

ConversionService.convert(...)

java 复制代码
// 位于 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)

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

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

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

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

转换器匹配与缓存机制

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

java 复制代码
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。

java 复制代码
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 做特殊处理。源码如下:
java 复制代码
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之后的,难道是接口顺序问题,调换之后依然无效。

java 复制代码
GenericConverter converter = this.getRegisteredConverter(sourceType, targetType, convertiblePair);
java 复制代码
 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,它会将字符串直接转换为枚举常量(按名称匹配)。

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

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

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

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

解决方案

暂且修改为ConditionalGenericConverter

java 复制代码
@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());
    }

}
相关推荐
standovon2 小时前
SpringSecurity的配置
java
霸道流氓气质2 小时前
SpringBoot+LangChain4j+Ollama+RAG(检索增强生成)实现私有文档向量化检索回答
java·spring boot·后端
就叫飞六吧2 小时前
Docker Hub 上主流的nginx发行
java·nginx·docker
༒࿈南林࿈༒2 小时前
链家二手房数据自动化点选验证码
python·自动化·点选验证码
MiNG MENS2 小时前
基于SpringBoot和Leaflet的行政区划地图掩膜效果实战
java·spring boot·后端
2601_949814692 小时前
Spring Boot中的404错误:原因、影响及处理策略
java·spring boot·后端
RDCJM2 小时前
Spring Boot spring.factories文件详细说明
spring boot·后端·spring
小雅痞3 小时前
[Java][Leetcode simple] 28. 找出字符串中第一个匹配项的下标
java·开发语言·leetcode
likerhood3 小时前
java中的不可变类(Immutable)
java·开发语言