Mybatis原始碼分析(三)通過例項來看typeHandlers

清幽之地發表於2019-03-10

一、案例分析

在日常開發中,我們肯定有對日期型別的操作。比如訂單時間、付款時間等,通常這一類資料在資料庫以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版本解決。如果不能動版本,再考慮自定義的型別處理器。

相關文章