Spring 枚举转换器冲突问题分析与解决
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/@JsonValue是 Jackson 的注解,仅影响 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)方法不会被自动调用。
- 对于枚举类型,Spring 默认使用
解决方案
既然你的枚举已经定义了 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 中获取所有 GenericConverter、Converter、Printer、Parser 类型的 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());
}
}

更多推荐




所有评论(0)