buguge - Keep it simple,stupid

知识就是力量,但更重要的,是运用知识的能力why buguge?

导航

如何自定义 MyBatis枚举处理器(EnumTypeHandler):分享从 “同包同名 shadow 覆盖” 到 “官方扩展点” 的重构经历

场景:sby-component-mybatisplus 组件中自定义 MyBatis 枚举处理器,从"同包同名 shadow 覆盖"重构为"setDefaultEnumTypeHandler 官方扩展点注册"。本文记录两种方案的原理、对比与关于健壮性代码的思考。


一、背景:为什么需要自定义“mybatis枚举处理器”

我们的业务系统里,数据库枚举字段的存储形式存在如下不统一的情况:

  • 有的存枚举名:SIGN
  • 有的存小写:sign
  • 有的存业务 code:4

MyBatis 默认的 EnumTypeHandler 只支持按枚举名精确匹配,读到 sign4 会直接抛 IllegalArgumentException,导致历史脏数据、跨系统数据无法正常读取。

一个健壮的系统需要兼容这种“异常”数据的情况。


二、初版方案:同包同名 Shadow 覆盖

彼时的2023年8月,我决定在我们项目底层 sby-component-mybatisplus 组件里实现一个兼容处理器,匹配策略:

  1. 优先按 Enum.name() 精确匹配;
  2. 失败后按忽略大小写匹配;
  3. 若枚举实现了 EnumAbility,再尝试按 code 匹配。

2.1 实现方式-shadow模式

为了让 MyBatis 的枚举映射自动走到自定义处理器,最初的思路非常直接:在组件 jar 里定义一个与 MyBatis 官方类完全同名同包的类:

// sby-component-mybatisplus/src/main/java/org/apache/ibatis/type/EnumTypeHandler.java(旧)
package org.apache.ibatis.type;

/**与 mybatis jar 中的 org.apache.ibatis.type.EnumTypeHandler 同名同包
 * @author gz.zhang
 * <p>见 mybatis-3.5.x.jar 中的同名class。 可以点击{@link org.apache.ibatis.type.EnumOrdinalTypeHandler}进行寻找</p>
 */
public class EnumTypeHandler<E extends Enum<E>> extends BaseTypeHandler<E> {

    @Override
    public E getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String colValue = rs.getString(columnName);
        return colValue == null ? null : enumValueOf(type, colValue);
    }
    
    // 其他重载方法...

    // 兼容策略:name -> 忽略大小写 -> EnumAbility.code
    public static <E extends Enum<E>> E enumValueOf(Class<E> _enumType, String name) {
        if (name == null || "".equals(name)) return null;
        try {
            return Enum.valueOf(_enumType, name.trim());
        } catch (IllegalArgumentException e) {
            // 1. 忽略大小写匹配
            for (E enumConstant : _enumType.getEnumConstants()) {
                if (enumConstant.name().equalsIgnoreCase(name)) {
                    return enumConstant;
                }
            }
            // 2. 实现 EnumAbility 的枚举按 code 匹配
            if (EnumAbility.class.isAssignableFrom(_enumType)) {
                for (E enumConstant : _enumType.getEnumConstants()) {
                    EnumAbility<Object> enumAbility = (EnumAbility) enumConstant;
                    Object code = name;
                    if (enumAbility.getCode() instanceof Integer) {
                        code = Integer.parseInt(name);
                    }
                    if (enumAbility.codeEquals(code)) {
                        return enumConstant;
                    }
                }
            }
            log.warn("从db读取数据,枚举转换失败--字段值={},期望枚举是:{}", name, _enumType.getName());
        }
        return null;
    }
}

2.2 生效原理:靠 classpath 顺序"巧合"生效

JVM 加载类时按 classpath 从前到后查找第一个匹配的类。因此只要组件 jar 在 classpath 中排在 mybatis jar 之前,org.apache.ibatis.type.EnumTypeHandler 就会被加载成我们的版本。

而 classpath 顺序由 Maven 依赖解析顺序决定(依赖声明顺序 + 间接依赖解析规则),所以这个方案隐含地绑定在 pom.xml 的依赖声明顺序上

<!-- 只有当 sby-component-mybatisplus 声明在 mybatis 相关依赖之前时,覆盖才生效 -->
<dependency>
    <groupId>com.serviceshare</groupId>
    <artifactId>sby-component-mybatisplus</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>

2.3 为什么说这是"巧合编程"

风险 说明
依赖顺序敏感 任何人调整 pom 中依赖的先后顺序,覆盖立即静默失效,MyBatis 恢复默认行为
间接依赖不可控 Maven 对传递依赖的排序规则并不直观,改一个无关模块都可能影响全局 classpath
环境不一致 IDE 运行、单测、打包产物的 classpath 可能不同,行为不一致且极难排查
拆分包(split package) org.apache.ibatis.type 同时出现在两个 jar 中;JDK 9+ 模块系统(JPMS)直接禁止,jdeps/shade 等工具告警
升级脆断 升级 MyBatis 小版本,官方类一旦有变化,覆盖可能以不可预期的方式失效
可读性差 新人看到 EnumTypeHandler 以为就是官方类,实际被偷偷替换,认知成本极高

近期有同事在一个上层应用pom中增加了高版本的mybatis依赖,直接导致这个应用中的`EnumTypeHandler` shadow覆盖失效,导致部分业务功能不可用。因此,这种靠巧合的实现方式存在隐患。

三、重构方案:利用mybatis官方扩展点 setDefaultEnumTypeHandler

3.1 思路转变→ 我们不需要"顶替"MyBatis 的类,只需要告诉 MyBatis"用我的类作为默认枚举处理器"

经查资料,MyBatis 官方早已提供该扩展点:

// org.apache.ibatis.session.Configuration
public void setDefaultEnumTypeHandler(Class<? extends TypeHandler> typeHandler)

于是重构分两步:

  1. 挪包:把自定义类从 org.apache.ibatis.type 迁移到自有包 com.serviceshare.mybatisplus.typehandler。类名可以保持 EnumTypeHandler(与官方类名相同没问题,因为包不同就是两个完全不同的类),关键是彻底脱离第三方包的命名空间。
  2. 显式注册:通过 MyBatis-Plus 的 ConfigurationCustomizer 在应用启动时注册为默认枚举处理器。

这样旧的生产代码目录 org/apache/ibatis/type/ 已被删除,拆分包从根上消除

3.2 代码

自定义处理器(已迁到自有包):

// com/serviceshare/mybatisplus/typehandler/EnumTypeHandler.java(新)
package com.serviceshare.mybatisplus.typehandler;

import org.apache.ibatis.type.BaseTypeHandler;

@Slf4j
public class EnumTypeHandler<E extends Enum<E>> extends BaseTypeHandler<E> {
    // 兼容逻辑与原来完全一致:name -> 忽略大小写 -> EnumAbility.code
    // 虽名为 EnumTypeHandler,但位于自有包,与官方的 org.apache.ibatis.type.EnumTypeHandler 是完全不同的两个类
}

注册点(MyBatisPlusComponentConfig):

@Bean
public ConfigurationCustomizer enumDefaultTypeHandlerCustomizer() {
    // 用自定义枚举处理器替换 MyBatis 默认的 EnumTypeHandler(兼容 name / EnumAbility.code / 字符串)。
    // 通过官方扩展点显式注册,不再依赖“同包同名 shadow”覆盖,避免拆分包与类加载顺序问题。
    return configuration -> configuration.setDefaultEnumTypeHandler(
            com.serviceshare.mybatisplus.typehandler.EnumTypeHandler.class);
}

四、两种方案对比

维度 Shadow(同包同名覆盖) 官方扩展点注册
生效机制 classpath 顺序巧合 显式配置注册
确定性 依赖 pom 声明顺序,脆弱 确定、可预期
依赖顺序敏感性 敏感,调顺序即失效 不敏感
同包同名冲突 是(拆分包) 否,类已迁入自有包,拆分包根除
框架升级影响 可能静默失效 官方扩展点持续兼容
可排查性 排查 classpath,困难 代码可见,易定位
规范合规 违反 JPMS 包唯一性 符合
团队可维护性 靠"约定"维持,易被无意破坏 代码自文档化

为什么更健壮

  • 显式:注册行为在代码中一目了然,不依赖任何 classpath 偶然性,注释写明意图。
  • 确定:无论依赖顺序、打包方式如何变化,行为始终一致。
  • 官方通路:这是框架留给使用者的正规扩展点,随版本演进持续兼容。
  • 彻底消除拆分包:类已迁出 org.apache.ibatis.type,生产代码里不再有任何 org/apache/ibatis/type/ 下的自定义类,split package 问题从根上消失;类名尽管仍叫 EnumTypeHandler,但因位于自有包,与官方类是两个不同的类,JVM 不会混淆。
  • 与 MyBatis-Plus 枚举机制兼容setDefaultEnumTypeHandler 只作为"默认值"兜底,字段若配置了 @EnumValue 或实现了 IEnum,仍走 MyBatis-Plus 自身的处理器,两者互不干扰。

五、结语

这次重构没有增加任何新功能——兼容逻辑原封不动,只改了类的包路径注册方式(从"顶替官方类"改为"通过官方扩展点注册")。但正是这两点改动,把一段"碰巧能跑"的代码,变成了"必然能跑"的代码。

如果某个行为的正确性取决于"顺序"、"恰好"、"没人动它",那它就是脆弱的。 健壮性不是"代码不出错",而是"出错也容易发现,升级也不怕,重构也不碎"。


识别代码中的"shadow 式陷阱"

任何"顶替框架类"的方案,本质上都是把第三方库当成了可以随意修改的代码。正确姿势永远是:先查官方扩展点。MyBatis 有 setDefaultEnumTypeHandler,Spring 有 @Primary / @ConditionalOnMissingBean,SPI 有 ServiceLoader 的排序规范……框架几乎总为"定制默认行为"留有正规通路。

场景 不健壮的做法 健壮的做法
枚举处理器 同包同名顶替官方类 setDefaultEnumTypeHandler + 自有包类名
Spring Bean 覆盖 同名 Bean 靠注册顺序胜出 @Primary@ConditionalOnMissingBean 显式声明
配置覆盖 依赖 spring.factories 加载顺序 使用官方配置属性 / EnvironmentPostProcessor
静态方法替换 反射修改 final 字段 / 字节码注入 框架扩展接口(如 TypeHandlerInterceptor
依赖版本 靠传依赖"恰好"解析到想要的版本 显式声明 dependencyManagement 锁定

posted on 2026-08-25 20:54  buguge  阅读(3)  评论(0)    收藏  举报