MyBatis 对未知数据类型的转换(一)TypeHandler

# 问题

前面说到,我们可以用 JSONObject 替代习惯使用的 JavaBean ,而之所以能用 JSONObject 主要就是因为它实现了 Map<String,Object>

实际使用我们就会发现,MyBatis 使用JSONObject 封装返回结果的时候很“智能”,数据库里字段是 varchar 类型,JSONObject 中返回值就是 String 类型,数据库字段是 int/float 类型,JSONObject 中返回值就是对应的数值类型。甚至通过 debug 发现数据库中保存了datetime 类型的数据,JSONObject 中保存的是 java.sql.timestamp 类型,而timestamp 类型继承了常见的 java.util.Date

为什么 MyBatis 可以用得这么爽呢?我们实现可完全没声明需要此字段的 javaType 呢。而且用得爽了,类型转换会不会导致程序性能大打折扣呢?

今天我们就从深入源码,探究一番 MyBatis 到底是怎样做到对未明确声明的字段处理返回类型的。

# TypeHandler

MyBatis 类型转换的核心就是这个接口,定义的方法可以看做就两种 setParameter 和 getResult ,很好理解,我们传参和接收 sql 结果时就调用这个。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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;

}

抽象类 BaseTypeHandler 部分实现了 TypeHandler ,主要完成了对空值的处理。 非空值的处理全部交给了子类完成。

BaseTypeHandler 子类非常多,对应了数据库的各种数据类型,实现都很简单,比如SqlTimestampTypeHandler 处理 Timestamp 类型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Timestamp parameter, JdbcType jdbcType)
    throws SQLException {
  ps.setTimestamp(i, parameter);
}

@Override
public Timestamp getNullableResult(ResultSet rs, String columnName)
    throws SQLException {
  return rs.getTimestamp(columnName);
}

都是直接调用了 PreparedStatement 和 ResultSet 处理相应类型字段的方法。

很明显,一旦指明了我们需要 MyBatis 给我们返回的此字段类型,MyBatis 肯定就去找到对应的 TypeHandler 实现类去处理。而我们没有指定返回类型的是怎么处理的呢?或者说,对于 Object 类型是怎么处理的呢?

# ObjectTypeHandler

我们先来看看这个类,看名字就会猜可能估计未知类型全靠它了吧。提前预告下,并不是哦,getNullableResult 还算经常使用,入参赋值就没见用了,毕竟入参的 JavaType 我们通过反射还是可以找到的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType)
    throws SQLException {
  ps.setObject(i, parameter);
}

@Override
public Object getNullableResult(ResultSet rs, String columnName)
    throws SQLException {
  return rs.getObject(columnName);
}

一看这实现,居然还是调用的 JDBC 底层的对应方法。事实上 com.mysql.jdbc 在处理Object 类型是也是通过大量的 if-else 或 switch-case 来找到本数据真正的类型的。入参绑定依据 parameterObj instanceof 各种类型,返回结果类型依据 field.getSQLType 的各种类型。

# UnknownTypeHandler

事实上,MyBatis 在很多我们没有指明参数类型的情况下,都是使用 UnknownTypeHandler 来解决类型转换的。UnknownTypeHandler 中的核心resolveTypeHandler 方法,就是查找对应数据的类型解析器(TypeHandler) , 再用这个合适的 typeHandler 进行解析。

resolveTypeHandler 方法的重载有3种,主要的两种就是一种处理入参的,一种处理返回结果的。

# 入参类型解析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private TypeHandler<? extends Object> resolveTypeHandler(Object parameter, JdbcType jdbcType) {
  TypeHandler<? extends Object> handler;
  if (parameter == null) {
    handler = OBJECT_TYPE_HANDLER;
  } else {
    handler = typeHandlerRegistry.getTypeHandler(parameter.getClass(), jdbcType);
    // check if handler is null (issue #270)
    if (handler == null || handler instanceof UnknownTypeHandler) {
      handler = OBJECT_TYPE_HANDLER;
    }
  }
  return handler;
}

在入参为 null 或者实在找不到解析器的情况下,就会返回我们上面讲的 ObjectTypeHandler 。

而这里面关键的方法就是

1
typeHandlerRegistry.getTypeHandler(parameter.getClass(), jdbcType);

typeHandlerRegistry 可以理解为全局共用的各种类型与解析器关系的注册表,后面的文章还会继续深入讲解,我们首先注意这个方法的入参,第一个参数拿到了参数的 class , 第二个参数拿到了 jdbcType 。这不就相当于javaType 和 jdbcType 都有了吗?那即使还没研究 typeHandlerRegistry 到底干了啥,但是条件给的这么充分了,注册表的任务也太轻松了吧!

等等,我们的 DAO 层给入参是 JSONObject 类型,里面 username 字段是 String 类型,money 字段是 float 类型,这些都能通过 getClass() 获取到确实没毛病。但是 jdbcType 是哪来的呢?我们现在可是在处理 PreparedStatement 呢!

通过 debug 我们发现,这里的 jdbcType 我们没有指明的情况下,确实都是 null 。说明 typeHandlerRegistry 里仅仅是通过 javaType 来寻找解析器的。所以 typeHandlerRegistry 还是有很多门道等着我们去探索哦。

# 返回结果类型解析

对 ResultSet 的解析有两种方式,首先查看此结果字段–比如nickname–在 field 中的序号,如果没序号,则直接返回 ObjectTypeHandler 。如果有序号,则进入下面的方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private TypeHandler<?> resolveTypeHandler(ResultSetMetaData rsmd, Integer columnIndex) throws SQLException {
  TypeHandler<?> handler = null;
  //通过下面两个方法获取到jdbcType和javaType
  JdbcType jdbcType = safeGetJdbcTypeForColumn(rsmd, columnIndex);
  Class<?> javaType = safeGetClassForColumn(rsmd, columnIndex);
  //后面的任务就还是交给了typeHandlerRegistry
  if (javaType != null && jdbcType != null) {
    handler = typeHandlerRegistry.getTypeHandler(javaType, jdbcType);
  } else if (javaType != null) {
    handler = typeHandlerRegistry.getTypeHandler(javaType);
  } else if (jdbcType != null) {
    handler = typeHandlerRegistry.getTypeHandler(jdbcType);
  }
  return handler;
}

其中的关键方法还是从 rsmd 中获取 jdbcType 和 javaType ,然后再通过 typeHandlerRegistry 去查找对应的 handler 。

debug 发现几乎每次从 rsmd 中获取 jdbcType 和 javaType 都获取到了,看来玄机都在safeGetJdbcTypeForColumnsafeGetClassForColumn 中了。

两个方法的关键代码分别如下

1
2
3
return JdbcType.forCode(rsmd.getColumnType(columnIndex));

return Resources.classForName(rsmd.getColumnClassName(columnIndex));

# JdbcType

com.mysql.jdbc.ResultSetMetaData 实现了 java.sql.ResultSetMetaData 接口,此处我们调用了其中的

1
2
3
public int getColumnType(int column) throws SQLException {
    return getField(column).getSQLType();
}

序号的作用就体现出来了,根据序号找到此 field ,再找其 SQLType ,根据 SQLType 去JdbcType 类 (enum类型) 内查找对应的 jdbcType。

JdbcType 类内维护了一个 map 类型静态变量 codeLookup ,类加载时为 codeLookup 添加了39个元素,key 值其实就是 SQLType , int 类型,value 就是本 jdbcType 。

因此根据 SQLType 在此处就直接能毫不费力地找出对应的 jdbcType。

1
2
3
4
5
static {
  for (JdbcType type : JdbcType.values()) {
    codeLookup.put(type.TYPE_CODE, type);
  }
}

# javaType

同样需要先找到 SQLType ,以及field 内的另外几个属性值,例如 isUnsigned 等一起进入 getClassNameForJavaType 方法找到对应类名。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static String getClassNameForJavaType(int javaType, boolean isUnsigned, int mysqlTypeIfKnown, boolean isBinaryOrBlob, boolean isOpaqueBinary,
        boolean treatYearAsDate) {
    switch (javaType) {
        case Types.BIT:
        case Types.BOOLEAN:
            return "java.lang.Boolean";
        case Types.TINYINT:
            if (isUnsigned) {
                return "java.lang.Integer";
            }
            return "java.lang.Integer";
        //......大量 case
         default:
                return "java.lang.Object";

而这里的大量的 swtich-case 终于算是解除了我们一部分的疑惑了!底层终归还是通过 swtich-case 这种最原始的操作来把 jdbcType 映射到 java 类里去的!

# 奇怪的现象

上面说到,如果没序号,则直接返回 ObjectTypeHandler。

序号是getColumnType(int column) 用于找到到对应的 field 的关键属性。

那么为什么有可能会没有序号呢?

通过 debug 我们发现,明明 fields 内有 8 个元素,每个字段的原始名和别名都清清楚楚,到 columnIndexLookup 里居然只剩下 6 个? 很显然,问题出在了

1
String name = rsmd.getColumnName(i);

中间有几次取出了重复的 name 。为什么会有重复的 name 呢,我们进入getColumnName 一探究竟。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
   public String getColumnName(int column) throws SQLException {
       if (this.useOldAliasBehavior) {
         //如果设置了使用别名的属性,就获取此field的别名。
           return getField(column).getName();
       }
     //获取此field的name属性,而不是别名。
       String name = getField(column).getNameNoAliases();
       if (name != null && name.length() == 0) {
         //如果连原始字段名都找不到,就还是获取别名
           return getField(column).getName();
       }
       return name;
   }

实际debug发现,我们每次都是通过 getNameNoAliases 找到 name 的。再底层的代码就不需要贴了,看到这里我们就明白了。这里的 name 其实是每个字段的数据库内的字段名,而不是我们定义的别名,所以才会出现重复的情况,比如 user 表有 id 字段,address 表同样会有 id 字段。

# 总结

今天我们分析完了类型转换器,发现对于未知的数据类型,有一部分是通过ObjectTypeHandler 解析,其底层的用了com.mysql.jdbc.ResultSetImpl.getObject 内的依据 Field.SQLType 的 swtich-case 。

另一部分则是通过UnknownTypeHandler 去查找合适的解析器来解析。

关于查找解析器的步骤,我们将进入下一层级TypeHandlerRegistry 来继续学习。