现象
项目中需要使用到对象属性复制,于是使用hutool的BeanUtil.copyProperties方法。这个方法线上一直用着都没问题,然而最近修改代码后却突然报错:Can not convert XXX to XXX。结合代码得知,该报错为把Map中的字符串复制到Bean的枚举类属性,并为该属性设置对应对象时出现的。
报错截图如下:
报错内容如下:
cn.hutool.core.convert.ConvertException: Can not convert ORDER_INVALID to class com.xxx
at cn.hutool.core.convert.impl.EnumConverter.convertInternal(EnumConverter.java:53) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.convert.AbstractConverter.convert(AbstractConverter.java:58) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.convert.ConverterRegistry.convertSpecial(ConverterRegistry.java:357) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.convert.ConverterRegistry.convert(ConverterRegistry.java:271) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.convert.ConverterRegistry.convert(ConverterRegistry.java:297) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.convert.Convert.convertWithCheck(Convert.java:745) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.bean.copier.provider.MapValueProvider.value(MapValueProvider.java:65) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.bean.copier.provider.MapValueProvider.value(MapValueProvider.java:24) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.bean.copier.BeanCopier.lambda$valueProviderToBean$1(BeanCopier.java:259) ~[hutool-all-5.7.16.jar!/:na]
at java.util.LinkedHashMap$LinkedValues.forEach(LinkedHashMap.java:608) ~[na:1.8.0_322]
at cn.hutool.core.bean.BeanUtil.descForEach(BeanUtil.java:182) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.bean.copier.BeanCopier.valueProviderToBean(BeanCopier.java:232) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.bean.copier.BeanCopier.mapToBean(BeanCopier.java:133) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.bean.copier.BeanCopier.copy(BeanCopier.java:102) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.bean.BeanUtil.copyProperties(BeanUtil.java:742) ~[hutool-all-5.7.16.jar!/:na]
at cn.hutool.core.bean.BeanUtil.copyProperties(BeanUtil.java:706) ~[hutool-all-5.7.16.jar!/:na]
at
排查
之前一直用都没问题,突然间出现报错着实奇怪,首先想到的就是改动的代码导致的bug。
通过Git查看新提交的代码,发现本次改动只是在枚举类中增加一个通过code获取枚举类对象的静态方法getEnumByCode,代码如下:
package com.xxx;
import lombok.Getter;
@Getter
public enum XxxTypeEnum {
XX("1", "类型1"),
ORDER_INVALID("8", "订单失效"),
/*...*/
/*此处省略一堆枚举类对象*/
;
private final String code;
private final String desc;
private static final Map<String, XxxTypeEnum> ENUM_MAP = Arrays.stream(XxxTypeEnum.values()).collect(Collectors.toMap(XxxTypeEnum::getCode, Function.identity(), (v1, v2) -> v1));
XxxTypeEnum(String code, String desc) {
this.code = code;
this.desc = desc;
}
public String getFullDesc() {
return "[" + this.getCode() + "-" + this.getDesc() + "]";
}
// TODO: 2023-2-7 hutool的BeanUtil.copyProperties巨坑: 枚举类中有类似下面的方法会导致报错:Can not convert {字符串} to class {全类名}
public static XxxTypeEnum getEnumByCode(String code) {
return ENUM_MAP.get(code);
}
}
相关代码截图:XxxEventReq、XxxTypeEnum
经同事提醒,进一步排查问题,在idea下载hutool的源码,查看cn.hutool.core.convert.impl.EnumConverter.convertInternal方法源码如下:
@Override
protected Object convertInternal(Object value) {
Enum enumValue = tryConvertEnum(value, this.enumClass);
if (null == enumValue && false == value instanceof String) {
// 最后尝试先将value转String,再valueOf转换
enumValue = Enum.valueOf(this.enumClass, convertToStr(value));
}
if (null != enumValue) {
return enumValue;
}
// 报错在此抛出
throw new ConvertException("Can not convert {} to {}", value, this.enumClass);
}
继续查看tryConvertEnum方法代码,注意到方法描述中转换规则的【找到类似转换的静态方法调用实现转换且优先使用】内容,该内容指出【用户自定义的类似转换的静态方法】的优先级高于【Enum的valueOf方法】。因此,当枚举类中无【用户自定义的类似转换的静态方法】时,BeanUtil会使用【Enum的valueOf方法】根据字符串获取到枚举类对象并赋值给Bean中的该枚举类属性。当枚举类中增加自定义的getEnumByCode方法后,BeanUtil会根据该方法来转换对象,然而枚举类XxxTypeEnum中的code并没有包含ORDER_INVALID,所以getEnumByCode方法返回为null,进而导致convertInternal方法抛出异常。
综上,bug是枚举类中XxxTypeEnum增加类似转换的静态方法getEnumByCode导致的。当然hutool的上述转换规则确实是个坑,在使用时要特别注意。
/**
* 尝试转换,转换规则为:
* <ul>
* <li>如果实现{@link EnumItem}接口,则调用fromInt或fromStr转换</li>
* <li>找到类似转换的静态方法调用实现转换且优先使用</li>
* <li>约定枚举类应该提供 valueOf(String) 和 valueOf(Integer)用于转换</li>
* <li>oriInt /name 转换托底</li>
* </ul>
*
* @param value 被转换的值
* @param enumClass enum类
* @return 对应的枚举值
*/
protected static Enum tryConvertEnum(Object value, Class enumClass) {
if (value == null) {
return null;
}
// EnumItem实现转换
if (EnumItem.class.isAssignableFrom(enumClass)) {
final EnumItem first = (EnumItem) EnumUtil.getEnumAt(enumClass, 0);
if (null != first) {
if (value instanceof Integer) {
return (Enum) first.fromInt((Integer) value);
} else if (value instanceof String) {
return (Enum) first.fromStr(value.toString());
}
}
}
// 用户自定义方法
// 查找枚举中所有返回值为目标枚举对象的方法,如果发现方法参数匹配,就执行之
try {
final Map<Class<?>, Method> methodMap = getMethodMap(enumClass);
if (MapUtil.isNotEmpty(methodMap)) {
final Class<?> valueClass = value.getClass();
for (Map.Entry<Class<?>, Method> entry : methodMap.entrySet()) {
if (ClassUtil.isAssignable(entry.getKey(), valueClass)) {
return ReflectUtil.invokeStatic(entry.getValue(), value);
}
}
}
} catch (Exception ignore) {
//ignore
}
//oriInt 应该滞后使用 以 GB/T 2261.1-2003 性别编码为例,对应整数并非连续数字会导致数字转枚举时失败
//0 - 未知的性别
//1 - 男性
//2 - 女性
//5 - 女性改(变)为男性
//6 - 男性改(变)为女性
//9 - 未说明的性别
Enum enumResult = null;
if (value instanceof Integer) {
enumResult = EnumUtil.getEnumAt(enumClass, (Integer) value);
} else if (value instanceof String) {
try {
enumResult = Enum.valueOf(enumClass, (String) value);
} catch (IllegalArgumentException e) {
//ignore
}
}
return enumResult;
}
/**
* 获取用于转换为enum的所有static方法
*
* @param enumClass 枚举类
* @return 转换方法map,key为方法参数类型,value为方法
*/
private static Map<Class<?>, Method> getMethodMap(Class<?> enumClass) {
return VALUE_OF_METHOD_CACHE.get(enumClass, () -> Arrays.stream(enumClass.getMethods())
.filter(ModifierUtil::isStatic)
.filter(m -> m.getReturnType() == enumClass)
.filter(m -> m.getParameterCount() == 1)
.filter(m -> false == "valueOf".equals(m.getName()))
.collect(Collectors.toMap(m -> m.getParameterTypes()[0], m -> m, (k1, k2) -> k1)));
}
解决
封装枚举类操作工具类EnumHandleUtil,将枚举类中XxxTypeEnum的getEnumByCode方法抽取到工具类中。
ps
本文章中对应软件框架版本:
Java:1.8
spring boot:1.5.12.RELEASE
hutool:5.7.16