TypeHandler 基礎知識
TypeHandler 引入
Java 領域的持久層框架中,由於 Hibernate 不夠靈活,目前使用最多的是 MyBatis 或 Spring-JDBC,這兩個框架都可以編寫 SQL ,配置數據庫表字段和 Java 類字段之間的映射關系。
處理映射關系時,除了考慮字段名稱之間的映射,還需要考慮數據庫表字段類型與 Java 字段類型之間的轉換關系。
MyBatis 中,數據庫類型和 Java 類型之間的轉換由 TypeHandler 來處理。TypeHandler 可以以合適的方式向 PreparedStatement 中設置參數,或從 ResultSet 中將數據庫字段值轉換為合適的 Java 類型值。
MyBatis 已經內置了一些常用的類型之間的轉換關系,而自定義的 Java 類與數據庫類型之間的轉換則需要用戶向 MyBatis 中注冊自定義的 TypeHandler。
TypeHandler 注冊
向 MyBatis 注冊 TypeHandler 時需要提供一個實現了 TypeHandler 的類。
先看 TypeHandler 接口的定義:
public interface TypeHandler<T> {
// 向 PreparedStatement 設置參數
void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException;
// 從 ResultSet 中獲取參數
T getResult(ResultSet rs, String columnName) throws SQLException;
T getResult(ResultSet rs, int columnIndex) throws SQLException;
T getResult(CallableStatement cs, int columnIndex) throws SQLException;
}
TypeHandler 中提供了兩種類型的方法,一類是向 PreparedStatement 中設置參數,一類是從 ResultSet 中獲取值。以 MyBatis 內置的 StringTypeHandler 實現為例進行分析:
public class StringTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
throws SQLException {
ps.setString(i, parameter);
}
@Override
public String getNullableResult(ResultSet rs, String columnName)
throws SQLException {
return rs.getString(columnName);
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex)
throws SQLException {
return rs.getString(columnIndex);
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex)
throws SQLException {
return cs.getString(columnIndex);
}
}
StringTypeHandler 實現了 BaseTypeHandler 類,BaseTypeHandler 類是一個 TypeHandler 的基類,它對 TypeHandler 做了簡單的封裝,我們自定義的 TypeHandler 實現 BaseTypeHandler 類即可。
自定義 TypeHandler
假設我們數據庫表使用 VARCHAR 形式保存了 properties 形式的配置,為了在 VARCHAR 和 Properties 之間進行轉換,我們可以自定義如下的 TypeHandler。
public class PropertiesTypeHandler extends BaseTypeHandler<Properties> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Properties parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, this.prop2Str(parameter));
}
@Override
public Properties getNullableResult(ResultSet rs, String columnName) throws SQLException {
return this.str2prop(rs.getString(columnName));
}
@Override
public Properties getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return this.str2prop(rs.getString(columnIndex));
}
@Override
public Properties getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return this.str2prop(cs.getString(columnIndex));
}
private String prop2Str(Properties properties) {
// 省略 Properties 轉 String 代碼
return null;
}
private Properties str2prop(String str) {
// 省略 String 轉 Properties 代碼
return null;
}
}
注冊自定義 TypeHandler
非 SpringBoot 環境下,我們需要在 xml 配置文件中注冊 TypeHandler,具體示例如下。
<typeHandlers>
<!--配置方式一:指定 TypeHandler 及處理的 Java 類型、JDBC 類型-->
<typeHandler handler="com.zzuhkp.blog.typehandler.PropertiesTypeHandler" javaType="java.lang.String" jdbcType="VARCHAR"/>
<!--配置方式二:指定 TypeHandler 所在的包名-->
<package name="com.zzuhkp.blog.typehandler"/>
</typeHandlers>
通過 xml 配置 TypeHandler 有兩種方式,第一種方式可以指定具體的 TypeHandler,第二種方式指定 TypeHandler 所在的包名即可。
那么不免會有一些疑問,僅提供包名 MyBatis 如何知道這個包下的 TypeHandler 可以處理哪些 Java 類型與 JDBC 類型呢?
事實上 MyBatis 提供了兩個注解 @MappedJdbcTypes、@MappedTypes 分別用來指定 TypeHandler 處理的 JDBC 類型與 Java 類型,將這兩個注解添加到自定義的 TypeHandler 類上即可。
xml 中配置的 javaType/jdbcType 優先級高於注解,由於 MyBatis 可以獲取泛型中的實際類型,因此在 TypeHandler 只使用 @MappedJdbcTypes 也是沒有問題的。 因此,如果通過提供包名的方式注冊 TypeHandler,可以修改我們自定義的 TypeHandler 如下。
@MappedJdbcTypes(JdbcType.VARCHAR)
public class PropertiesTypeHandler extends BaseTypeHandler<Properties> {
// 省略部分代碼
}
在 SpringBoot 環境下,我們可以引入 mybatis-spring-boot-starter 依賴,此時直接在 Spring 的 application.proerties 配置文件中進行如下配置:
mybatis.type-handlers-package=com.zzuhkp.blog.typehandler。
問題引出
通過前面的內容,我們知道,如果數據庫字段對應的是一個我們定義的復雜類型,我們就需要向 MyBatis 中注冊 TypeHandler。
假定數據庫有一個用戶表,如下:
CREATE TABLE `user` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`username` varchar(100) '用戶名',
`password` varchar(100) COMMENT '密碼',
`role_ids` varchar(255) COMMENT '角色ID',
`resource_codes` varchar(255) COMMENT '資源編號',
`create_time` datetime COMMENT '創建時間',
PRIMARY KEY (`id`)
)
為了控制權限,我們將資源和角色信息以 json 數組的形式分別保存到 resource_codes、role_ids 字段中。當前數據庫記錄如下。
用戶表對應的 Java 類型如下:
@Data
public class UserPO {
private Integer id;
private String username;
private String password;
private List<Integer> roleIds;
private List<String> resourceCodes;
}
由於用戶類的 roleIds 和 resourceCodes 字段為復雜類型,為了將 List<Integer>、List<String> 與數據庫 VARCHAR 類型之間轉換,我們定義兩個 TypeHandler 類,並將其注冊到 MyBatis 中。
VARCHAR 與 List<Integer> 之間轉換的 TypeHandler 如下:
@MappedJdbcTypes(JdbcType.VARCHAR)
public class IntegerListTypeHandler extends BaseTypeHandler<List<Integer>> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, List<Integer> parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, JSONObject.toJSONString(parameter));
}
@Override
public List<Integer> getNullableResult(ResultSet rs, String columnName) throws SQLException {
return this.str2List(rs.getString(columnName));
}
@Override
public List<Integer> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return this.str2List(rs.getString(columnIndex));
}
@Override
public List<Integer> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return this.str2List(cs.getString(columnIndex));
}
private List<Integer> str2List(String str) {
if (StrUtil.isBlank(str)) {
return null;
}
return JSONObject.parseArray(str, Integer.class);
}
}
VARCHAR 與 List<String> 之間轉換的 TypeHandler 如下:
@MappedJdbcTypes(JdbcType.VARCHAR)
public class StringListTypeHandler extends BaseTypeHandler<List<String>> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, JSONObject.toJSONString(parameter));
}
@Override
public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException {
return this.str2List(rs.getString(columnName));
}
@Override
public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return this.str2List(rs.getString(columnIndex));
}
@Override
public List<String> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return this.str2List(cs.getString(columnIndex));
}
private List<String> str2List(String str) {
if (StrUtil.isBlank(str)) {
return null;
}
return JSONObject.parseArray(str, String.class);
}
}
將用戶相關的數據庫操作,抽象到 UserMapper 類,代碼如下:
public interface UserMapper {
UserPO selectById(@Param("id") Integer id);
}
UserMapper 對應的 xml 文件如下:
<mapper namespace="com.zzuhkp.blog.mybatis.mapper.UserMapper">
<select id="selectById" resultType="com.zzuhkp.blog.mybatis.entity.UserPO">
select * from user where id = #{id}
</select>
</mapper>
我們可能會有根據ID查詢用戶的需求,測試代碼如下:
public class App {
public static void main(String[] args) throws IOException {
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
SqlSession sqlSession = sqlSessionFactory.openSession();
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
UserPO userPO = userMapper.selectById(1);
System.out.println(JSONObject.toJSONString(userPO));
System.out.printf("roleId type %s \n", userPO.getRoleIds().get(0).getClass());
System.out.printf("resourceCode type %s \n", userPO.getResourceCodes().get(0).getClass());
}
}
控制台打印代碼如下:
{"id":1,"password":"123456","resourceCodes":["resource1","resource2"],"roleIds":["1","2"],"username":"hkp"}
Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
at com.zzuhkp.blog.mybatis.App.main(App.java:29)
問題出現了,用戶類中的我們定義的角色ID列表存儲的類型為整型,通過打印內容我們發現變成了字符串類型,並且我們試圖獲取角色ID列表存儲的角色ID時拋出了ClassCastException異常,也就是說我們希望使用 IntegerListTypeHandler 處理 VARCHAR 與 List<Integer> 之間的轉換,而MyBatis 錯誤的選擇了StringListTypeHandler。
問題分析
為了解決問題,下一步我們就需要分析 MyBatis 為何選擇了錯誤的 TypeHandler ? MyBatis 到底是如何選擇 TypeHandler 的呢?是未正常注冊還是注冊后選擇錯誤?
在 StringListTypeHandler 打斷點后可以看到調用棧如下:
由於我們在 xml mapper 文件中配置的是 resultType,因此 MyBatis 只會選用自動映射的方式處理數據庫字段與 Java 字段之間的映射。跟蹤調用棧中的自動映射方法DefaultResultSetHandler#applyAutomaticMappings 如下:
TypeHandler 由DefaultResultSetHandler#createAutomaticMappings 方法返回的 UnMappedColumnAutoMapping 列表指定。跟蹤此方法如下:
至此,我們可以發現 TypeHandler 是根據 Class 和 JdbcType 從注冊中心獲取,而 Class 是一個原始類型,並不包含自身的泛型參數的具體類型。可以推測,MyBatis 在注冊 TypeHandler 時也是使用原始類型 Class 和 JdbcType 進行注冊,因此后注冊的 StringListTypeHandler 覆蓋了先注冊的 IntegerListTypeHandler,從而導致 MyBatis 錯誤的選擇了 TypeHandler。
問題解決
由於自動映射處理時從 TypeHandlerRegistry 獲取 TypeHandler 丟失了泛型信息,因此無法正常找到正確的 TypeHandler。MyBatis 作為一個成熟的開源框架用戶量應該比較大,因此第一反應是從百度查詢是否有其他人遇到過相同問題,然而百度也未給出答案。這時候我把目光轉向了 github 上 mybatis 的 issue。通過查詢 issue 發現其他人確實遇到過相同問題。issue 部分內容截圖如下:
這個 issue 在21年2月25提交,MyBatis 項目的成員之一 harawata 在3月11回復表示這是一個已知的缺陷,然而最近沒有時間修改。截止到發文時間,這個 issue 仍然處於 open 狀態。
修改 MyBatis 源碼必然可以解決問題,然而為了使用 TypeHandler 使用修改過的 MyBatis 源碼則顯得小題大做。到底還有沒有其他的解決方案呢?
只要使用自動映射,那么 MyBatis 必然無法正確選擇 TypeHandler 。我們知道,MyBatis 提供了手動映射的方式,只要我們在 mapper xml 文件中配置 resultMap 即可,而 resultMap 中是可以指定使用的 TypeHandler。
修改我們測試使用的 mapper xml 文件如下:
<mapper namespace="com.zzuhkp.blog.mybatis.mapper.UserMapper">
<resultMap id="BaseResultMap" type="com.zzuhkp.blog.mybatis.entity.UserPO">
<result column="role_ids" property="roleIds" typeHandler="com.zzuhkp.blog.mybatis.typehandler.IntegerListTypeHandler"/>
<result column="resource_codes" property="resourceCodes" typeHandler="com.zzuhkp.blog.mybatis.typehandler.StringListTypeHandler"/>
</resultMap>
<select id="selectById" resultMap="BaseResultMap">
select * from user where id = #{id}
</select>
</mapper>
再次執行我們的測試方法,打印結果如下:
{"id":1,"password":"123456","resourceCodes":["resource1","resource2"],"roleIds":[1,2],"username":"hkp"}
roleId type class java.lang.Integer
resourceCode type class java.lang.String
此時,MyBatis 使用了手動指定的 TypeHandler,問題得到解決。
那么此時 MyBatis 為什么又能找到正確的 TypeHandler 呢?分析 MyBatis 解析 mapper xml 文件的源碼,發現 MyBatis 調用了如下的方法來獲取 TypeHandler。
public abstract class BaseBuilder {
protected TypeHandler<?> resolveTypeHandler(Class<?> javaType, Class<? extends TypeHandler<?>> typeHandlerType) {
if (typeHandlerType == null) {
return null;
}
// javaType ignored for injected handlers see issue #746 for full detail
TypeHandler<?> handler = typeHandlerRegistry.getMappingTypeHandler(typeHandlerType);
if (handler == null) {
// not in registry, create a new one
handler = typeHandlerRegistry.getInstance(javaType, typeHandlerType);
}
return handler;
}
}
這時是根據我們指定的 TypeHandler 的具體類型獲取,TypeHandlerRegistry 會將所有注冊過的 TypeHandler 以 TypeHandler 對應的 Class 作為 key,TypeHandler 實例作為 value 緩存到類型為 Map 的字段 allTypeHandlersMap 中。如果已注冊則從注冊的 TypeHandler 直接獲取,否則則通過反射實例化獲取 TypeHandler 實例。
總結
如果我們為同一個泛型類型注冊了不同的 TypeHandler,那么在使用自動映射時后注冊的 TypeHandler 會將先注冊的 TypeHandler 覆蓋。此時我們可以手動在 resultMap 中指定 typeHandler ,並使用 resultMap 替代 resultType 來臨時解決,相信在未來的版本中 MyBatis 內部將會對 TypeHandler 不支持泛型類型的問題進行處理。
參考: |