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 中获取所有 GenericConverter、Converter、Printer、Parser 类型的 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());
}
}
