Mybatis源码分析(三)通过实例来看typeHandlers

一、案例分析

在日常开发中,我们肯定有对日期类型的操作。比如订单时间、付款时间等,通常这一类数据在数据库以datetime类型保存。如果需要在页面上展示此值,在Java中以什么类型接收它呢?

在不执行任何二次操作的情况下:
java.util.Date接收,在页面展示的就是Tue Oct 16 16:05:13 CST 2018
java.lang.String接收,在页面展示的就是2018-10-16 16:10:47.0

显然,我们不能显示第一种。第二种似乎可行,但大部分情况下不能出现毫秒数。当然了,不管哪种方式,在显示的时候format一下当然是可行的。有没有更好的方式呢?

二、typeHandlers

无论是 MyBatis 在预处理语句(PreparedStatement)中设置一个参数时,还是从结果集中取出一个值时, 都会用类型处理器将获取的值以合适的方式转换成 Java 类型。
在数据库中,datetime和timestamp类型含义是一样的,不过timestamp存储空间小, 所以它表示的时间范围也更小。
下面来看几个Mybatis默认的时间类型处理器。

JDBC 类型 Java 类型 类型处理器
DATE java.util.Date DateOnlyTypeHandler
DATE java.sql.Date SqlDateTypeHandler
DATE java.time.LocalDate LocalDateTypeHandler
DATE java.time.LocalTime LocalTimeTypeHandler
TIMESTAMP java.util.Date DateTypeHandler
TIMESTAMP java.time.Instant InstantTypeHandler
TIMESTAMP java.time.LocalDateTime LocalDateTimeTypeHandler
TIMESTAMP java.sql.Timestamp SqlTimestampTypeHandler

它是什么意思呢?如果数据库字段类型为JDBC 类型,同时Java字段的类型为Java 类型,那么就调用类型处理器类型处理器

三、自定义处理器

基于上面这个逻辑,我们可以增加一种处理器来处理我们开头所描述的问题。我们可以在Java中,以String类型接收数据库的DateTime类型数据。因为现在的接口以restful风格居多,用String类型方便传输。
最后的毫秒数通过自定义的处理器统一截取去除即可。

JDBC 类型 Java 类型 类型处理器
TIMESTAMP java.lang.String CustomTypeHandler
<property name="typeHandlers">
    <array>
        <bean class="com.viewscenes.netsupervisor.util.CustomTypeHandler"></bean>
    </array>
</property>

@MappedJdbcTypes注解表示JDBC的类型,@MappedTypes表示Java属性的类型。

@MappedJdbcTypes({ JdbcType.TIMESTAMP })
@MappedTypes({ String.class })
public class CustomTypeHandler 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 substring(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);
    }
    private String substring(String value) {
        if (!"".endsWith(value) && value != null) {
            return value.substring(0, value.length() - 2);
        }
        return value;
    }
}

通过以上方式,我们就可以放心的在Java中以String接收数据库的时间类型数据了。

四、源码分析

1、注册

public final class TypeHandlerRegistry {
    //typeHandler为当前自定义类型处理器
    public <T> void register(TypeHandler<T> typeHandler) {
        boolean mappedTypeFound = false;
        //mappedTypes即String
        MappedTypes mappedTypes = typeHandler.getClass().getAnnotation(MappedTypes.class);
        if (mappedTypes != null) {
            for (Class<?> handledType : mappedTypes.value()) {
                register(handledType, typeHandler);
            }
        }
    }
}
public final class TypeHandlerRegistry {
    private <T> void register(Type javaType, TypeHandler<? extends T> typeHandler) {
        //JDBC的类型,即TIMESTAMP
        MappedJdbcTypes mappedJdbcTypes = typeHandler.getClass().
                getAnnotation(MappedJdbcTypes.class);
        if (mappedJdbcTypes != null) {
            for (JdbcType handledJdbcType : mappedJdbcTypes.value()) {
                //TYPE_HANDLER_MAP是Java类型中的默认处理器。
                //以String为例,它默认可以处理VARCHAR、CHAR、NVARCHAR、CLOB、NCLOB、NULL
                Map<JdbcType, TypeHandler<?>> map = TYPE_HANDLER_MAP.get(javaType);
                //给String添加一种处理器为typeHandler
                map.put(jdbcType, typeHandler);
                //注册处理器实例
                ALL_TYPE_HANDLERS_MAP.put(typeHandler.getClass(), typeHandler);
            }
        }
    }
}

2、调用

注册完毕之后,它在什么地方生效呢?关键在于能否可以找到这个处理器。看完上面的注册过程,查找其实很简单。先从TYPE_HANDLER_MAP根据JavaType,获取String类型的全部处理器,再从中过滤出JDBC类型为TIMESTAMP的即可。

private <T> TypeHandler<T> getTypeHandler(Type type, JdbcType jdbcType) {
    //根据JavaType获取String类型的全部处理器
    Map<JdbcType, TypeHandler<?>> jdbcHandlerMap = getJdbcHandlerMap(type);
    TypeHandler<?> handler = null;
    if (jdbcHandlerMap != null) {
        //再根据jdbcType获取到TIMESTAMP的处理器
        handler = jdbcHandlerMap.get(jdbcType);
    }
    return (TypeHandler<T>) handler;
}

拿到自定义的处理器,我们自己就随便搞喽~

不过,在Mybatis-3.2.7版本中,比较坑。在调用getTypeHandler方法时,它并没有传jdbcType这个参数,所以这个参数默认为NULL了。
那么,在执行jdbcHandlerMap.get(jdbcType)的时候,会找不到自定义的处理器,而是找到了NULL的处理器,即StringHandler。案发现场在下面:

public class ResultSetWrapper {
    public TypeHandler<?> getTypeHandler(Class<?> propertyType, String columnName) {
        //3.4.6
        JdbcType jdbcType = getJdbcType(columnName);
        handler = typeHandlerRegistry.getTypeHandler(propertyType, jdbcType);
        //3.2.7
        handler = typeHandlerRegistry.getTypeHandler(propertyType);
    }
}

五、总结

自定义处理器的应用场景很广泛,比如对某些敏感字段加密、状态值的转换(正常、注销、 已付款、未发货)等。回顾一下你的项目中有哪些地方实现的不太理想,可以考虑用它来做。

六、后续

在笔者写完这篇文章后,在另外一台电脑做测试的时候,发现尽管没有对时间类型做处理,但也不会出现.0的问题。这使我睡觉都没安稳。。。难道自己认知有误,文章写错了?笔者决定先抛开Mybatis,用最原始的JDBC做测试。

public static void main(String[] args) throws Exception {
    Connection conn = getConnection();
    Statement stat = conn.createStatement();
    String sql = "select * from user";
    ResultSet rs = stat.executeQuery(sql);
    while(rs.next()){
        String username = rs.getString("username");
        String createtime = rs.getString("createtime");
        System.out.print("姓名: " + username);
        System.out.print("  创建时间: " + createtime);
        System.out.print("\n");
    }
}

结果让我很意外,用原始的JDBC查询数据,并没有任何其他操作,也没有.0的问题。

姓名: 关小羽 创建时间: 2018-10-15 17:04:11
姓名: 小露娜 创建时间: 2018-10-15 17:10:46
姓名: 亚麻瑟 创建时间: 2018-10-15 17:10:46
姓名: 小鲁班 创建时间: 2018-10-16 16:10:47

上面的代码量很小,显然问题出在ResultSet对象上。通过跟踪源码,最后笔者发现两台机器的mysql-connector-java版本不一样。一个是5.1.31,一个是6.0.6。我们把版本换成5.1.31,执行上面的main方法再看结果。

姓名: 关小羽 创建时间: 2018-10-15 17:04:11.0
姓名: 小露娜 创建时间: 2018-10-15 17:10:46.0
姓名: 亚麻瑟 创建时间: 2018-10-15 17:10:46.0
姓名: 小鲁班 创建时间: 2018-10-16 16:10:47.0

好了,让我们看看它们的差别在哪里吧。其实就是因为5.1.31多做了一步操作,它针对时间类型的数据又处理了一次,导致问题产生。

5.1.31

package com.mysql.jdbc;
public class ResultSetImpl implements ResultSetInternalMethods {
    protected String getStringInternal(int columnIndex, boolean checkDateTypes)
        // JDBC is 1-based, Java is not !?
        int internalColumnIndex = columnIndex - 1;
        Field metadata = this.fields[internalColumnIndex];      
        String stringVal = null;    
        String encoding = metadata.getCharacterSet();
        //stringVal为已经从数据库取到的值2018-10-16 16:10:47
        stringVal = this.thisRow.getString(internalColumnIndex, encoding, this.connection);
        
        // Handles timezone conversion and zero-date behavior
        //Mysql针对时间类型又做了一次处理
        if (checkDateTypes && !this.connection.getNoDatetimeStringSync()) {
            switch (metadata.getSQLType()) {
            case Types.TIME:
                ......略
            case Types.DATE:
                ......略
            case Types.TIMESTAMP:
                //数据库的DateTime类型会走到这里
                //MySQL把它又转成了Timestamp类型,  .0的问题从这里产生
                Timestamp ts = getTimestampFromString(columnIndex,
                        null, stringVal, this.getDefaultTimeZone(), false);
                return ts.toString();
            default:
                break;
            }
        }
        return stringVal;
    }
}

6.0.6

package com.mysql.cj.jdbc.result;

public class ResultSetImpl extends MysqlaResultset 
                implements ResultSetInternalMethods, WarningListener {
    
    public String getString(int columnIndex) throws SQLException {
        
        Field f = this.columnDefinition.getFields()[columnIndex - 1];
        ValueFactory<String> vf = new StringValueFactory(f.getEncoding());
        // return YEAR values as Dates if necessary
        if (f.getMysqlTypeId() == MysqlaConstants.FIELD_TYPE_YEAR && this.yearIsDateType) {
            vf = new YearToDateValueFactory<>(vf);
        }
        String stringVal = this.thisRow.getValue(columnIndex - 1, vf);

        return stringVal;
    }
}

如果大家项目里面有.0问题产生,可以通过升级mysql-java版本解决。如果不能动版本,再考虑自定义的类型处理器。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容