1. typeHandler在MyBatis全链路中到底扮演什么角色
做后端开发这些年,天天用MyBatis,但直到有一次排查一个“查询结果字段类型对不上”的棘手问题,才真正把typeHandler源码翻了个底朝天。MyBatis的typeHandler,简而言之,就是一套负责Java类型与JDBC类型互相转换的处理器:写操作时,它把Java参数塞进PreparedStatement;读操作时,它把ResultSet里的数据取出来变成Java对象。如果你遇到过枚举字段存储、JSON字符串映射、时间戳精度丢失这类需求,其实都是在跟typeHandler打交道。网上讲MyBatis用法的文章不少,但专门把typeHandler源码一层层掰开揉碎讲清楚的,确实不多。这篇文章我就从源码入手,结合我实际踩过的坑,把typeHandler的设计思路、实现细节和自定义方案讲透,希望能给正在啃MyBatis源码的开发者一点参考。
1.1 typeHandler在MyBatis执行链路中的位置
要理解typeHandler,先得知道它在MyBatis执行SQL的哪个环节起作用。MyBatis一次完整的查询大致要走过这几步:拿到Mapper方法调用,解析对应的MappedStatement,通过SqlSource生成BoundSql,然后创建PreparedStatementHandler,最终执行JDBC。其中typeHandler的主要工作集中在PreparedStatementHandler的执行阶段。
我来用一段实际代码说明。当我们执行:
User user = userMapper.selectById(1L);这条SQL在底层执行时,MyBatis要做两件事。第一件事发生在SQL执行前,Mapper接口的方法参数要设置到PreparedStatement中。如果参数是基础类型,MyBatis根本不需要typeHandler,直接通过JDBC的setLong、setString就能搞定。但如果参数是一个复杂对象,比如我们的示例类:
public class User { private Long id; private String name; private RoleType role; // 枚举类型 private Map<String, Object> extraInfo; // 需要存成JSON的字段 }当MyBatis要设置role和extraInfo这两个字段时,就轮到typeHandler上场了。它负责把Java的RoleType枚举转换成数据库能存储的类型,把Map转换成JSON字符串;反过来,查询时它再把JDBC返回的INT类型转回RoleType枚举,把VARCHAR里的JSON字符串还原成Map对象。
我换个说法可能更好理解:如果你把MyBatis想象成一个快递中转站,SQL是物流车,那么typeHandler就是那个“标注规格的搬运工”。它知道某个Java对象应该打包成什么样的包裹(参数值),也知道收到包裹后如何拆包还原成之前的物品(结果值)。没有它,Java类型和数据库类型之间就像两种完全不同的语言,谁也听不懂对方说什么。
从源码角度看,typeHandler的调用不是孤立的。MyBatis在解析Mapper XML、处理@Results注解、执行ResultSetHandler时,会反复以“Java类型 + JdbcType”为Key去TypeHandlerRegistry里查找对应的TypeHandler实例。搞清楚这个查找过程,是看懂typeHandler设计的第一步。
1.2 typeHandler的注册与查找机制:源码里的两张“地图”
TypeHandlerRegistry是整个typeHandler家族的中枢。它本质上维护了多张Map,用来存储“什么样的Java类型配什么样的TypeHandler”。我初次读这个类源码时,第一反应是:这不就是一个复杂版的Map工厂吗?实际上,它的确就是一个Map工厂,只不过内部的分层设计非常细致。
TypeHandlerRegistry的构造方法会一次性注册MyBatis内置的所有TypeHandler,包括处理基本类型和包装类型的IntegerTypeHandler、LongTypeHandler、StringTypeHandler,处理时间类型的DateTypeHandler、LocalDateTimeTypeHandler,处理字节数组的ByteArrayTypeHandler等。注意一个细节:几乎每个基础类型Handler都会注册两遍,一遍以Java类型为Key,比如Integer.class -> IntegerTypeHandler实例;一遍以“Java类型 + JdbcType”的组合为Key。为什么要注册两遍?这是因为MyBatis在设置参数和读取结果时的查找策略不同。设置参数时,Mapper XML如果指定了jdbcType,那么要用组合Key来查找;如果没有指定,就退化成只用Java类型查找。读取结果时,同样要区分是否有jdbcType。
所以,TypeHandlerRegistry里至少有以下这些关键Map:
- javaTypeMap:Key是Java类型,Value是TypeHandler
- jdbcTypeMap:Key是JdbcType,Value是TypeHandler,主要给那些没有对应Java类型但指定了jdbcType的场景使用
- allTypeHandlersMap:Key是TypeHandler的Class,Value是TypeHandler实例,用于按类查找和去重
- typeHandlerMap:其实是嵌套Map,Key是Java类型,Value是另一张Map,内层Map的Key是JdbcType,Value是TypeHandler
我这里不逐一列字段名,因为不同MyBatis版本内部结构有差异,但核心思路一致:用组合Key解决查找精度问题。
在获取TypeHandler时,核心逻辑是getTypeHandler方法。它的查找顺序大致是:
- 如果同时传入javaType和jdbcType,先尝试用组合Key去typeHandlerMap查,查不到再退化;
- 如果只传入javaType,就用javaType去javaTypeMap查;
- 如果传入了jdbcType但没传入javaType,就用jdbcTypeMap查;
- 如果都没查到,返回null。
这个查找过程直接决定了typeHandler是否生效。很多开发者遇到的问题——“我在XML里配了typeHandler,但它就是不执行”——往往就出在这个查找逻辑上。比如某个Java类型同时注册了多个处理器,MyBatis可能因为类型不匹配而选择了另一个Handler。后面我会专门用一节的篇幅讲这类问题的排查方法。
读到这里你应该能感受到:typeHandler不是孤立组件,它和TypeHandlerRegistry、Configuration、ResultSetHandler这些核心类都有关联。理解了注册表的设计,再看具体的TypeHandler实现源码,思路就会清晰很多。
2. 从接口到基类:typeHandler核心源码拆解
2.1 TypeHandler接口与TypeReference的职责边界
先看最基础的TypeHandler接口。源码很简洁,总共只有四个方法:
public interface TypeHandler<T> { void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException; T getResult(ResultSet rs, String columnName) throws SQLException; T getResult(ResultSet rs, int columnIndex) throws SQLException; T getResult(CallableStatement cs, int columnIndex) throws SQLException; }让我针对这4个方法逐个说明它在什么场景下被调用。setParameter是设置写入参数时调用的,它的4个参数分别说明:PreparedStatement对象、占位符的索引(从1开始)、Java参数值、以及jdbcType。注意jdbcType可能是null,比如我们在Mapper XML里没指定jdbcType、又没有全局配置时,它就是null。所以TypeHandler内部处理null jdbcType时要有兜底策略。
getResult的三个重载分别对应三种情况:从ResultSet按列名取值、从ResultSet按列索引取值、从CallableStatement按列索引取值。前两个最常见,第三个是为存储过程准备的特殊场景。为什么接口要定义三个取值方法而不是一个?最重要的原因是性能。按列名取值在底层需要先解析列名对应的索引,而按索引取值可以省掉这层解析;在结果集较大的场景下,按索引取值的效率优势明显。MyBatis的ResultSetHandler在已经知道列索引时,会优先调用按索引的getResult方法。
再看TypeReference。这是一个抽象类,核心作用是在运行时推断Handler的泛型类型。因为Java的泛型会被类型擦除,直接拿TypeHandler对象的Class,是拿不到它到底处理哪种Java类型的。TypeReference通过获取类继承父类的泛型参数来绕过这个限制。它的核心代码大概是:
public abstract class TypeReference<T> { private final Type rawType; protected TypeReference() { // 用反射获取当前类的父类泛型类型 Type genericSuperclass = getClass().getGenericSuperclass(); // 如果是ParameterizedType,则取出实际类型参数 this.rawType = ((ParameterizedType) genericSuperclass).getActualTypeArguments()[0]; } public final Type getRawType() { return rawType; } }这里有个很tricky的点:TypeReference必须在具体子类中才有效。如果直接写new TypeReference<>()这样匿名内部类,也能拿到泛型信息,但MyBatis官方建议把它和自定义TypeHandler结合,让TypeHandler本身继承TypeReference。例如:
public class MyHandler extends BaseTypeHandler<MyType> implements TypeReference<MyType> { ... }这样在注册时,MyBatis不需要你手动指定Handler对应的Java类型,而是自动从TypeReference的泛型里推断出来。这设计很聪明,省掉了很多重复的注册配置。但也意味着:如果你的自定义Handler没有正确继承TypeReference,或者泛型写在了方法级别而不是类级别,TypeRegistry就无法自动推断,只能靠手动注册。
2.2 BaseTypeHandler:让子类只关心“非空值”逻辑
TypeHandler接口定义了4个方法,但如果每个子类都自己实现null判断和异常处理,代码会大量重复。MyBatis用BaseTypeHandler这个抽象基类把重复逻辑收拢起来,暴露给子类的是4个抽象方法:
public abstract class BaseTypeHandler<T> extends TypeReference<T> implements TypeHandler<T> { public void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException { if (parameter == null) { if (jdbcType == null) { throw new TypeException("JDBC requires that the JdbcType must be specified for all nullable parameters."); } ps.setNull(i, jdbcType.TYPE_CODE); } else { setNonNullParameter(ps, i, parameter, jdbcType); } } public T getResult(ResultSet rs, String columnName) throws SQLException { T result = getNullableResult(rs, columnName); if (rs.wasNull()) { return null; } return result; } ... }这就是模板方法模式的典型应用。父类定义流程骨架,子类覆盖关键步骤。子类只需要关注“传入的是非null参数怎么处理”和“取出结果后怎么转换成Java类型”,其余null判断、jdbcType为空时的异常、wasNull判断都由基类代劳。
我特别想提醒两点。第一,setParameter方法里,如果参数是null且jdbcType也为null,MyBatis会直接抛TypeException。这点很多人容易忽略。比如我们自定义一个Handler处理枚举类型,映射到整数,如果枚举允许为null,那么Mapper XML里一定要记得指定jdbcType,否则一旦传入null参数就会报错。第二,getResult方法返回前要用rs.wasNull()判断原值是否为NULL,否则可能把NULL当成空字符串或0。这两种情况在实际项目中都很常见。
BaseTypeHandler还实现了TypeReference,这一点刚才提过。所以子类在声明时只要写成extends BaseTypeHandler ,MyBatis就能通过TypeReference自动知道这个Handler处理的是MyClass类型,非常方便。
2.3 IntegerTypeHandler源码:最简单的内置实现长什么样
理解了基类之后,看内置实现就很轻松。我拿IntegerTypeHandler举例,它的源码非常直观:
public class IntegerTypeHandler extends BaseTypeHandler<Integer> { public void setNonNullParameter(PreparedStatement ps, int i, Integer parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter); } public Integer getNullableResult(ResultSet rs, String columnName) throws SQLException { return rs.getInt(columnName); } public Integer getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return rs.getInt(columnIndex); } public Integer getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return cs.getInt(columnIndex); } }就这么点代码。它做的事很简单:写入时用ps.setInt写入整数,读取时用rs.getInt取出结果。可能你会疑惑:既然这么简单,为什么还要封装成TypeHandler?因为它的意义不在于自己做了什么,而在于为其他所有TypeHandler提供了统一的扩展点。比如你想要一个能压缩存储的整数Handler,或者要一个脱敏后的字符串Handler,都可以在这个基座上做文章。
顺着这个思路,还可以顺带理解MyBatis为什么要把jdbcType和javaType解耦。假设数据库列类型是VARCHAR,但Java类型是Integer,你只需要在XML里告诉MyBatis用哪个TypeHandler即可,MyBatis自己不管数据库列的物理类型,它只按照Handler里的实现去调JDBC接口。这个“解耦”正是typeHandler机制最大的价值:类和库表之间的映射规则,不再写死在JDBC代码里,而是由一个个可插拔的Handler来承载。
3. 自定义typeHandler的完整实操:一文拿下一个JSON类型映射案例
源码读完了,最终还是要落地。我自己的项目中,最常用到自定义typeHandler的场景有两个:一个是枚举转数值,一个是对象转JSON字符串。这里我就拿JSON字段映射作为完整案例,一步步带你写一个可用的TypeHandler。
3.1 业务背景与方案选型
假设我们有一张监控告警配置表,需要保存“告警时可以执行的动作列表”,动作可能有三种:发送邮件、发送短信、调用外部Webhook。在Java对象里,这个字段很自然地用一个List<String>或者List<AlertAction>来表示。但是数据库是MySQL,最直接的做法是存成VARCHAR字段,里面放一个JSON数组字符串,比如["SMS","EMAIL","WEBHOOK"]。
如果按照传统写法,我们可以在Service层手动做JSON序列化和反序列化,但这样做的坏处是:只要改一处查询,就得记得在多个地方手动转一遍,很容易漏。用了typeHandler,就能把转换逻辑收敛到一个处理器里,所有DAO层查询自动完成转换。选择typeHandler而不是用插件或继承某Mapper基类,是因为它最贴合MyBatis原生机制,不需要额外依赖,通用性也最好。
3.2 完整代码与逐行解析
我们先定一个简单的枚举类:
public enum AlertAction { SMS("短信"), EMAIL("邮件"), WEBHOOK("Webhook回调"); private final String desc; AlertAction(String desc) { this.desc = desc; } public String getDesc() { return desc; } }然后自定义TypeHandler,继承BaseTypeHandler,处理一个List<AlertAction>类型的字段:
@MappedTypes(List.class) @MappedJdbcTypes(JdbcType.VARCHAR) public class AlertActionListTypeHandler extends BaseTypeHandler<List<AlertAction>> { private static final ObjectMapper MAPPER = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, List<AlertAction> parameter, JdbcType jdbcType) throws SQLException { // 把枚举列表转成JSON字符串再写入数据库 ps.setString(i, MAPPER.writeValueAsString(parameter)); } @Override public List<AlertAction> getNullableResult(ResultSet rs, String columnName) throws SQLException { String value = rs.getString(columnName); return parseJsonToActionList(value); } @Override public List<AlertAction> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String value = rs.getString(columnIndex); return parseJsonToActionList(value); } @Override public List<AlertAction> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String value = cs.getString(columnIndex); return parseJsonToActionList(value); } private List<AlertAction> parseJsonToActionList(String value) { if (value == null || value.isEmpty()) { return null; } try { // 这里用数组方式读取,避免泛型擦除问题 AlertAction[] actions = MAPPER.readValue(value, AlertAction[].class); return Arrays.asList(actions); } catch (IOException e) { throw new RuntimeException("解析告警动作列表失败", e); } } }逐行解释几个关键点。
- 类上的@MappedTypes(List.class)和@MappedJdbcTypes(JdbcType.VARCHAR)注解,可以让MyBatis在自动扫描注册时,自动把List类型、VARCHAR类型与这个Handler关联起来。
- 只实现了setNonNullParameter,因为父类BaseTypeHandler已经处理了null参数的情况。如果你的场景要求null值也能写进去,父类在jdbcType不为null时会调用rs.setNull,不需要子类操心。
- 读取时我们统一先getString,再手动用Jackson解析成List<AlertAction>。这里用AlertAction[].class来做反序列化,再用Arrays.asList转List,巧妙地避开了泛型擦除问题。如果用MAPPER.readValue(value, List.class),得到的List里元素其实是LinkedHashMap,不是AlertAction枚举,后续使用会出问题。这是非常容易踩的坑。
3.3 三种注册方式对比与最佳实践
自定义TypeHandler写好了,怎么让MyBatis认识它?有三种常见方式。
第一种是在Mapper XML中使用typeHandler="com.example.AlertActionListTypeHandler",例如:
<resultMap id="alertConfigMap" type="com.example.AlertConfig"> <id property="id" column="id" /> <result property="actions" column="actions" typeHandler="com.example.AlertActionListTypeHandler"/> </resultMap>这种方式的优点是显式、直观,缺点是必须手动写全类名,如果涉及多个Mapper,每个都要配一遍。
第二种是全局注册,在MyBatis配置文件中加入:
<typeHandlers> <typeHandler handler="com.example.AlertActionListTypeHandler" javaType="java.util.List" jdbcType="VARCHAR"/> </typeHandlers>全局注册后,所有匹配javaType为List且jdbcType为VARCHAR的地方,都会自动使用这个Handler。但要注意:全局注册会影响所有列,如果数据库里还有其他普通List字段,也会被这个Handler接管,所以要评估影响范围。
第三种是扫描注册,在Spring Boot环境中,通过配置项指定type-handlers-package:
mybatis: type-handlers-package: com.example.handler这会让MyBatis扫描该包下的所有类,自动读取@MappedTypes和@MappedJdbcTypes注解来注册。这是最省事的做法,也是我目前项目里的默认选择。不过要注意,扫描注册要求Handler类必须有@MappedTypes注解,否则MyBatis不知道它对应什么Java类型。
| 注册方式 | 关键配置 | 适用场景 | 缺点 |
|---|---|---|---|
| Mapper XML里指定 | resultMap/参数中使用 | 单字段精确控制 | 每个字段都要写 |
| 全局注册 | mybatis-config.xml | 通用处理器 | 影响所有匹配类型 |
| 包扫描注册 | type-handlers-package | Spring Boot项目 | 依赖注解标注 |
我个人的建议是:如果Handler很通用,比如一个通用的JsonTypeHandler,我倾向于放在type-handlers-package里全局注册;如果Handler只服务于某个表字段,比如AlertActionListTypeHandler这种业务性强的,我更倾向于在resultMap里显式指定typeHandler,消息一清二楚,不会误伤其他字段。这两种方式可以混用,关键是团队约定要统一。
4. 源码排查实录:typeHandler相关的常见问题和排查思路
读源码和写Handler都完成了,真正工作中的重头戏是排查问题。这一节把我这些年遇到的typeHandler典型问题整理出来,每一个都附上排查思路和解决方案。
4.1 问题一:typeHandler配置了,但实际没有生效
这个问题大概占typeHandler问题的一半。症状很典型:在Mapper XML里明明写了typeHandler属性,结果执行查询时,字段还是走默认的转换逻辑。
排查思路分三步。
第一步,先确认TypeHandler是否真的被注册了。可以在应用启动日志里搜索typeHandler相关的注册信息,或者直接在调试模式下打断点,看Configuration.getTypeHandlerRegistry()里的所有TypeHandler实例是否包含我们自定义的类。如果没有注册,就要去检查type-handlers-package路径是否写错,或者Handler类是否被Spring扫描到。
第二步,确认TypeHandler是否被“注册为多个类型”。这是最容易踩的坑:如果同一个Handler同时注册了多个javaType,比如List.class和ArrayList.class,MyBatis在查找时可能会优先匹配更具体的类型。假如我们注册时用javaType="java.util.List",但实体字段声明的是ArrayList,此时MyBatis会先去javaTypeMap里查ArrayList对应的Handler,查不到才降级查List的Handler,如果降级逻辑有问题,Handler就不生效。解决办法是确保javaType和字段类型严格一致。
第三步,检查Map的解析顺序。如果你用的是resultMap,字段名和列名的对应关系必须由result的column属性指定,typeHandler只负责值转换,不负责列名匹配。有些开发者写错column,导致一直取到null,误以为是typeHandler失效。这个容易被忽略。
4.2 问题二:自定义typeHandler读取时拿到NULL值
这类问题的典型场景是:数据库某列明明有值,但通过自定义Handler读取后Java对象里是null。排查时可以从两个角度入手。
第一点,看数据库列是否为NULL。我们自定义Handler时,getResult里通常调用了rs.getString(columnName)。如果该列确实是NULL,rs.getString返回null,再传给parse方法时没有做空判断,就可能返回null。这里BaseTypeHandler会在getResult返回后调用rs.wasNull()判断原值是否为NULL,但要注意:如果我们在getNullableResult里先调了rs.getString,紧接着又调rs.getString获取其他值,那么rs.wasNull()反映的可能是第二个getString的状态。所以最稳妥的是在使用该值的紧邻处处理空判断。
第二点,看Handler是否因为SQL语句中没有select到该列,导致ResultSet里根本没有这个列。比如用了select *,数据库查询返回很多列,但实体类映射时类型不匹配,MyBatis可能匹配不到。这种情况要打日志或调试看ResultSet的列名列表。
我自己的经验是:排查自定义Handler拿到null,先打印SQL和ResultSet内容,再检查getNullableResult的调用路径,很多时候问题不在Handler本身,而在SQL或者resultMap配置。
4.3 问题三:自定义Handler导致查询性能下降
typeHandler是逐列调用的。如果某个查询返回一万行数据,每行都有一个JSON字段经过Jackson序列化/反序列化,性能开销不容小觑。有一个阶段的监控告警查询,我加了JSON转换Handler之后,接口耗时从100ms涨到了800ms。
排查后发现两个瓶颈。第一个瓶颈是没有缓存ObjectMapper,每次setNonNullParameter和getNullableResult都new ObjectMapper,而ObjectMapper是重量级对象,初始化很耗时。我改成类级单例后就缓解了大部分压力。第二个瓶颈是异常处理中创建RuntimeException时,会把原始SQLException的堆栈带出来,当大量数据出现异常时,日志量巨大,阻塞了输出。后来把日志级别调低,并统一在Service层处理转换异常。
补充一个小优化:对于JSON字段,如果查询场景足够单一,比如只查列表页且只用到了其中3个字段,可以考虑不把整个JSON字段反序列化成完整对象,而是用Map结构直接读取。这属于业务层优化,和typeHandler本身无关,但往往比Handler优化更有效。
4.4 问题四:跨数据库兼容问题
typeHandler内实现时大量依赖JDBC接口,比如rs.getString、ps.setString,在主流关系型数据库上都能跑。但如果你同时要支持多种数据库,就要注意一些数据库特有类型。比如PostgreSQL有JSON和JSONB类型,MySQL(低版本)没有原生JSON类型,只能存TEXT或VARCHAR。这个差异会直接影响Handler实现。
我之前在某项目里遇到过:写MySQL时一切正常,切到PostgreSQL后,同样的Handler,查JSON列直接报错。原因是MySQL里VARCHAR能兼容写入JSON字符串,但PostgreSQL会严格区分JSON和TEXT,如果列定义是JSON类型,我们需要用ps.setObject(i, jsonString, Types.OTHER)的方式写入,而不是ps.setString。
| 数据库 | JSON字段常见存储类型 | 推荐写入方式 | 读取方式 |
|---|---|---|---|
| MySQL | JSON/TEXT/VARCHAR | ps.setString | rs.getString |
| PostgreSQL | JSON/JSONB | ps.setObject(..., Types.OTHER) | rs.getString |
所以,如果你打算自定义Handler并把它用到一个多数据库项目中,建议在Handler内部判断当前数据库类型,或者干脆用不同的Handler对应不同的jdbc类型,注册表里通过jdbcType区分。这样虽然配置项多了一些,但兼容性最稳妥。
5. 我从typeHandler源码里学到的一些“方法论”
这一节不算严格的总结,更多是分享我在反复读typeHandler源码和踩坑过程中的一些体会。
第一,理解MyBatis源码不必从最复杂的类开始。typeHandler这条线,从TypeHandler接口到BaseTypeHandler再到IntegerTypeHandler,是一条非常清晰的主干道。顺着这条线读下来,你会顺带理解TypeHandlerRegistry的设计、ResultSetHandler对Handler的调用逻辑,以及Configuration里各种注册配置的用途。比起直接啃Configuration或者SqlSession,这条线对新手友好得多。
第二,模板方法模式在框架源码里非常普遍。BaseTypeHandler就是一个典型例子,父类处理公共流程,子类只负责细节。理解了这个模式,你不仅看typeHandler快,看Spring的JdbcTemplate、看Netty的ChannelHandler也会有似曾相识的感觉。
第三,自定义typeHandler的价值不在于炫技,而在于把业务中的类型转换规则收敛到一个地方,避免重复代码和遗漏。但也要记住,typeHandler不是银弹。如果某个字段的转换逻辑只在一个查询里出现一次,直接写转换代码也许更简单,强行抽Handler反而增加维护成本。我的原则是:一个类型转换规则只要在2个以上的Mapper里重复出现,我就考虑抽成Handler。
第四,调试typeHandler其实不难,难的是定位“到底是谁在调用Handler”。直接在Handler的setNonNullParameter或getNullableResult方法里打上System.out或者log,然后执行一条SQL,看有没有走进去,往往是最高效的排查方式。比起网上各种玄学排查,这一步能节约几小时。
最后再分享一个实用小技巧:如果你用的是Spring Boot + MyBatis(比如mybatis-spring-boot-starter的2.x版本),自定义Handler扫描注册时,记得把type-handlers-package放在mybatis配置根下,而不是某Mapper的namespace下。我曾经因为把它写在了某个Mapper XML的package里,导致其他Mapper一直找不到Handler,排查了很久。配置路径对了,Handler注册才会自动生效。
希望这篇文章能帮大家把typeHandler这条线彻底搞明白。如果有错误或遗漏,欢迎评论区留言指正,一起交流。