阅读 419

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版本解决。如果不能动版本,再考虑自定义的类型处理器。