前言

如果你使用了Mybatis-Plus(MP)的通用枚举,那么你的枚举需要依赖于它的库里面的特定注解(@EnumValue)或接口(IEnum),这样的话,如果我们的枚举类需要打包给司内其他项目使用时,就会导致对MP的依赖。所以希望将对MP的这种依赖去掉,同时保留他的能力。


思路

首先,我们的思路是去掉枚举类中的相关依赖,同时使用MP的能力。

通过查看Mybatis-Plus现有的通用枚举实现原理,发现其实现主要依赖于MybatisEnumTypeHandler这个API,该API的主要逻辑是通过扫描配置的枚举类,通过使用MybatisEnumTypeHandler代理枚举类的方式将其注册到SqlSessionFactory的配置类的TypeHandlerRegistry中(通过查看TypeHandlerRegistry可以发现,其构造函数已经初始注册了很多基础类型的映射器)。

那么我们也可以通过该方式来解决我们遇到的情况。


自定义类型映射器

实现步骤如下:
1、首先,我们需要定义一个针对枚举类的类型处理器。需要根据枚举类属性匹配数据库字段,完成映射,实现思路可以通过自定义注解(如MP的实现)或者通过约定的枚举属性,如code,value。
2、然后,我们通过配置枚举类的路径,进行包下枚举类扫描,包装到自定义的类型处理器中,最后注册到SqlSessionFactory的TypeHandlerRegistry


基于上面的描述步骤,实现如下:

1、自定义枚举类型处理器,继承于BaseTypeHandler类

/**
* 枚举类的类型处理器,或者代理类
* @param <E>
*/
@Slf4j
public class CustomEnumTypeHandler<E extends Enum<E>> extends BaseTypeHandler<E> {

private final Class<E> enumClassType;
private final String name;

public CustomEnumTypeHandler(Class<E> enumClassType, List<String> enumValueCanUseFieldNames) {
    if (enumClassType == null) {
        throw new IllegalArgumentException("Type argument cannot be null");
    }
    this.enumClassType = enumClassType;
    // 字段名称, 首字母大写
    this.name = findEnumValueFieldName(this.enumClassType, enumValueCanUseFieldNames);
}

private String findEnumValueFieldName(Class<E> enumClassType, List<String> enumValueCanUseNames) {
    for (Field declaredField : enumClassType.getDeclaredFields()) {
        String fieldName = declaredField.getName();
        if (enumValueCanUseNames.contains(fieldName)) {
            // 第一个字母大写
            return StringUtils.capitalize(fieldName);
        }
    }
    throw new IllegalArgumentException(String.format("could not find specific name:%s in Class: %s.", enumValueCanUseNames, this.enumClassType.getName()));
}

@Override
public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType) throws SQLException {
    ps.setInt(i, this.getValue(parameter));
}

@Override
public E getNullableResult(ResultSet rs, String columnName) throws SQLException {
    int code = rs.getInt(columnName);
    return getEnum(code);
}

@Override
public E getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
    int code = rs.getInt(columnIndex);
    return getEnum(code);
}

@Override
public E getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
    int code = cs.getInt(columnIndex);
    return getEnum(code);
}

/**
 * 根据枚举获取value值
 * @param parameter
 * @return
 */
private int getValue(E parameter) {
    if (StringUtils.isEmpty(name)) {
        log.warn("getValue enumClass:{} name empty", enumClassType);
        return 0;
    }
    try {
        Method method = parameter.getClass().getMethod("get" + name);
        Object value = method.invoke(parameter);
        if (value == null) {
            log.warn("getValue enumClass:{} enum empty", enumClassType);
            return 0;
        }
        return Integer.valueOf(value + "");
    } catch (Exception e) {
        e.printStackTrace();
    }
    return 0;
}

/**
 * 根据值获取枚举
 * @param value
 * @return
 */
private E getEnum(int value) {
    if (StringUtils.isEmpty(name)) {
        log.warn("getEnum enumClass:{} name empty", enumClassType);
        return null;
    }
    E[] enums = enumClassType.getEnumConstants();
    for (E e : enums) {
        if (getValue(e) == value) {
            return e;
        }
    }
    throw new IllegalArgumentException("Unknown enum:" + enumClassType + " value:" + value);
}

}

2、再来定义一个配置类

/**
 * 枚举映射器配置
 * @author
 */
@Setter
@Getter
@ConfigurationProperties(prefix = "enum-type-handler")
public class CustomEnumTypeHandlerProperties {

    /**
     * 是否启用
     */
    private boolean enable;

    /**
     * 枚举扫描包
     */
    private String packages;

    /**
     * 可使用的value名称
     */
    private List<String> canUseFiledNames;

}

通过enable是否启用,来决定是否启用自定义枚举类扫描与注册。

3、实现枚举类的扫描与注册

/**
 * 将枚举类通过代理类添加到spring容器中
 * @author 
 * @since
 * @see CustomEnumTypeHandlerProperties
 */
@Configuration
@ConditionalOnProperty(value = {"enum-type-handler.enable"}, havingValue = "true")
@EnableConfigurationProperties(CustomEnumTypeHandlerProperties.class)
public class CustomEnumTypeHandlerScanConfig implements BeanPostProcessor {

    @Resource
    private CustomEnumTypeHandlerProperties customEnumTypeHandlerProperties;

    @Nullable
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (bean instanceof SqlSessionFactory) {
            SqlSessionFactory sqlSessionFactory = (SqlSessionFactory) bean;
            org.apache.ibatis.session.Configuration configuration = sqlSessionFactory.getConfiguration();
            TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();

            // 扫描枚举类
            ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false);
            // 枚举类
            provider.addIncludeFilter(new AssignableTypeFilter(Enum.class));
            provider.findCandidateComponents(customEnumTypeHandlerProperties.getPackages());

            for (BeanDefinition beanDefinition : provider.findCandidateComponents(customEnumTypeHandlerProperties.getPackages())) {
                try {
                    // 使用当前线程的类加载器,不知道会使用当前类的类加载器。在common包里是Launcher$AppClassLoader,在项目中是RestartClassLoader
                    Class<?> clazz = Class.forName(beanDefinition.getBeanClassName(), true, Thread.currentThread().getContextClassLoader());

                    // 添加你的类型处理器
                    typeHandlerRegistry.register(clazz, new CustomEnumTypeHandler(clazz, customEnumTypeHandlerProperties.getCanUseFiledNames()));
                } catch (ClassNotFoundException e) {
                    e.printStackTrace();
                }
            }
        }
        return bean;
    }

}

通过注解 @ConditionalOnProperty(value = {“enum-type-handler.enable”}, havingValue = “true”) 来决定是否启用枚举类扫描

扫描类实现了BeanPostProcessor,我们知道BeanPostProcessor是Spring的Bean初始化后的一个后置处理器,可以针对特定bean进行处理。我们针对SqlSessionFactory这个bean进行了后置处理,进行类型处理器的一个扫描与注册。

至此,完成了自定义枚举类的查询映射,当编写sql查询时,可以将数据库字段映射成枚举类,即在我们接收的DTO中添加枚举类属性,完成自动映射。


问题与处理

问题描述
以上实现,可以封装一个starter进行引用,在应用时,发现报错:映射失败,未找到映射器!!!

以上代码已经进行了优化,出问题的行在(修复之后的代码):
Class<?> clazz = Class.forName(beanDefinition.getBeanClassName(), true, Thread.currentThread().getContextClassLoader());

修复之前的写法:
Class<?> clazz = Class.forName(beanDefinition.getBeanClassName()),默认使用当前类的类加载器。

关于类加载器可以查看另一篇文章:类加载器ClassLoader

问题分析
通过debug代码,发现初始化的类型映射中存在该枚举,但是注意classLoader是AppClassLoader:

而我们查询时,传递的type也是对应的枚举,然而classLoader是RestartClassLoader:

扩展一下:我们知道,同一个Class文件或字节码,在被不同的ClassLoader加载到虚拟机中,它们并不相同,他们有不同的名称空间。

故而,会报错找不到。


那这个RestartClassLoader是什么呢?

发现是项目中使用了spring-boot-devtools这个包,导致了项目中的一些类,包括自定义枚举是通过RestartClassLoader重新加载。

spring-boot-devtools是用于热部署(说实话有点鸡肋,还是要重启,推荐JRebel),项目启动的时候,Devtools中的RestartApplicationListener会拦截一些事件,然后将原本 spring 加载的处理流程接管到Restarter进行处理,每次热部署,都会使用新的RestartClassLoader和线程进行重新加载类。

所以
spring-boot-devtools重新加载类应用程序内的classpath中的class文件,自定义枚举类是RestartClassLoader加载的;而我们的自定义枚举类扫描器是jar的方式引入的,使用的AppClassLoader进行加载的,而扫描方法是通过spring启动中的线程进行回调的,而默认使用的是当前类的加载器,导致注册的自定义枚举类的classLoader是AppClassLoader,所以最终导致不一致的发生。

修复
1、指定使用当前线程的类加载器进行加载(推荐):
Class<?> clazz = Class.forName(beanDefinition.getBeanClassName(), true, Thread.currentThread().getContextClassLoader());
2、去掉项目中的spring-boot-devtools,因为没法控制将来其它项目的改动,所以不用该方案。
3、自定义jar中增加配置META-INF/spring-devtools.properties文件(如MP的:restart.include.mybatis-plus=/mybatis-plus-[\w-]+.jar),扩展扫描我们的jar。


总结

自定义MP类型映射器,可以完成查询时不同类型的映射;在使用时ClassLoader时一定要注意,Class.forName默认是使用当前调用类的类加载器加载,在使用时需要特别注意;如果使用了spring-boot-devtools热部署,不建议该功能在线上使用。