背景
这两天碰到了这么一个问题:在使用MyBatis(3.5.2版本)查询数据的时候,数据库返回的数据在映射成实体对象的属性的时候类型匹配异常导致程序异常。
来看看具体的代码
首先是数据库的字段和类型:
实体类的定义如下:
@Data
@Builder
public class TestEntity {
private Long id;
private String teacherName;
private Long teacherId;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private int level;
}
实体类定义的时候使用了Lombok的Data
和Builder
注解(如果不了解Lombok的同学欢迎Google下,Lombok能够减少很多样板代码,极大提高开发效率。)
接下来看Mapper接口和定义:
public interface TestMapper {
TestEntity queryById(Long id);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.scheduler.resource.mapper.TestMapper">
<resultMap id="testMap" type="com.scheduler.resource.entity.TestEntity">
<id column="id" property="id"/>
<result column="teacher_id" property="teacherId"/>
<result column="teacher_name" property="teacherName"/>
<result column="level" property="level"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
</resultMap>
<sql id="tableName">
test_table
</sql>
<sql id="allFields">
id,teacher_id,teacher_name,level,create_time,update_time
</sql>
<select id="queryById" resultMap="testMap">
SELECT
<include refid="allFields"/>
FROM
<include refid="tableName"/>
WHERE
id = #{id}
</select>
</mapper>
在resultMap
中配置了数据库字段到实体类的映射关系。数据库里面提前准备好mock数据:
接下来调用queryById()
方法查询数据:
public Response test() {
TestEntity entity = testMapper.queryById(1111L);
return Response.successResponse(entity);
}
结果程序异常,异常堆栈如下(已经省略了一些不必要的异常堆栈):
[ERROR][2020-04-02T21:34:58.611+0800][http-nio-8222-exec-1:ErrorHandler.java:53] SERVICE_RUN_ERROR
org.springframework.jdbc.UncategorizedSQLException: Error attempting to get column 'teacher_name' from result set. Cause: java.sql.SQLException: Error
; uncategorized SQLException; SQL state [null]; error code [0]; Error; nested exception is java.sql.SQLException: Error
at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:89)
at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:81)
at java.lang.Thread.run(Thread.java:748)
Caused by: java.sql.SQLException: Error
at com.alibaba.druid.pool.DruidDataSource.handleConnectionException(DruidDataSource.java:1718)
at com.alibaba.druid.pool.DruidPooledConnection.handleException(DruidPooledConnection.java:133)
at com.alibaba.druid.pool.DruidPooledStatement.checkException(DruidPooledStatement.java:82)
at com.alibaba.druid.pool.DruidPooledResultSet.checkException(DruidPooledResultSet.java:55)
at com.alibaba.druid.pool.DruidPooledResultSet.getLong(DruidPooledResultSet.java:304)
at org.apache.ibatis.type.LongTypeHandler.getNullableResult(LongTypeHandler.java:37)
at org.apache.ibatis.type.LongTypeHandler.getNullableResult(LongTypeHandler.java:26)
at org.apache.ibatis.type.BaseTypeHandler.getResult(BaseTypeHandler.java:81)
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createUsingConstructor(DefaultResultSetHandler.java:671)
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createByConstructorSignature(DefaultResultSetHandler.java:654)
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createResultObject(DefaultResultSetHandler.java:618)
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createResultObject(DefaultResultSetHandler.java:591)
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.getRowValue(DefaultResultSetHandler.java:397)
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValuesForSimpleResultMap(DefaultResultSetHandler.java:354)
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValues(DefaultResultSetHandler.java:328)
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleResultSet(DefaultResultSetHandler.java:301)
at
Caused by: java.lang.NumberFormatException: For input string: "Slogen"
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at com.mysql.cj.protocol.a.MysqlTextValueDecoder.getDouble(MysqlTextValueDecoder.java:238)
at com.mysql.cj.result.AbstractNumericValueFactory.createFromBytes(AbstractNumericValueFactory.java:57)
at com.mysql.cj.protocol.a.MysqlTextValueDecoder.decodeByteArray(MysqlTextValueDecoder.java:132)
at com.mysql.cj.protocol.result.AbstractResultsetRow.decodeAndCreateReturnValue(AbstractResultsetRow.java:133)
at com.mysql.cj.protocol.result.AbstractResultsetRow.getValueFromBytes(AbstractResultsetRow.java:241)
at com.mysql.cj.protocol.a.result.ByteArrayRow.getValue(ByteArrayRow.java:91)
at com.mysql.cj.jdbc.result.ResultSetImpl.getObject(ResultSetImpl.java:1290)
at com.mysql.cj.jdbc.result.ResultSetImpl.getLong(ResultSetImpl.java:812)
at com.mysql.cj.jdbc.result.ResultSetImpl.getLong(ResultSetImpl.java:818)
at com.alibaba.druid.pool.DruidPooledResultSet.getLong(DruidPooledResultSet.java:302)
... 88 common frames omitted
程序异常原因是
Error attempting to get column 'teacher_name' from result set. Cause: java.sql.SQLException: Error
; uncategorized SQLException; SQL state [null]; error code [0]; Error; nested exception is java.sql.SQLException: Error
Caused by: java.lang.NumberFormatException: For input string: "Slogen"
简单来说就是程序尝试把"Slogen"
这个字符串转换成数字的时候出错了。
mock数据的时候明明是设置了teacher_name
字段等于"Slogen"
,在xml映射文件中把数据库的teacher_name
列映射到实体类的teacherName
属性上,而teacherName
属性是String
类型的,所以按道理说不应该进行类型转换的。
那么为什么程序会尝试把字符串"Slogen"
转换成数字呢?
源码面前,了无秘密。
还是从源码中找答案吧。
原因分析
MyBatis在数据库返回数据的时候会按照resultMap
的映射转换成Java实体对象,这个过程是在DefaultResultSetHandler
类的getRowValue()
方法中进行的
//
// GET VALUE FROM ROW FOR SIMPLE RESULT MAP
//
private Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, String columnPrefix) throws SQLException {
final ResultLoaderMap lazyLoader = new ResultLoaderMap();
// 把数据库返回的数据转换成实体类对象
// resultMap中保存的就是在xml文件中配置的映射,rsw对象中保存了数据库返回的数据
Object rowValue = createResultObject(rsw, resultMap, lazyLoader, columnPrefix);
if (rowValue != null && !hasTypeHandlerForResultObject(rsw, resultMap.getType())) {
final MetaObject metaObject = configuration.newMetaObject(rowValue);
boolean foundValues = this.useConstructorMappings;
if (shouldApplyAutomaticMappings(resultMap, false)) {
foundValues = applyAutomaticMappings(rsw, resultMap, metaObject, columnPrefix) || foundValues;
}
foundValues = applyPropertyMappings(rsw, resultMap, metaObject, lazyLoader, columnPrefix) || foundValues;
foundValues = lazyLoader.size() > 0 || foundValues;
rowValue = foundValues || configuration.isReturnInstanceForEmptyRow() ? rowValue : null;
}
return rowValue;
}
其中rsw
对象保存了数据库中返回的数据,resultMap
中保存了xml文件中配置的映射。
getRowValue()
中会调用createResultObject()
方法完成具体的转换
//
// INSTANTIATION & CONSTRUCTOR MAPPING
//
private Object createResultObject(ResultSetWrapper rsw, ResultMap resultMap, ResultLoaderMap lazyLoader, String columnPrefix) throws SQLException {
this.useConstructorMappings = false; // reset previous mapping result
final List<Class<?>> constructorArgTypes = new ArrayList<>();
final List<Object> constructorArgs = new ArrayList<>();
Object resultObject = createResultObject(rsw, resultMap, constructorArgTypes, constructorArgs, columnPrefix);
// 省略代码
this.useConstructorMappings = resultObject != null && !constructorArgTypes.isEmpty(); // set current mapping result
return resultObject;
}
继续跟踪到createResultObject()
中:
private Object createResultObject(ResultSetWrapper rsw, ResultMap resultMap, List<Class<?>> constructorArgTypes, List<Object> constructorArgs, String columnPrefix)
throws SQLException {
final Class<?> resultType = resultMap.getType();
final MetaClass metaType = MetaClass.forClass(resultType, reflectorFactory);
final List<ResultMapping> constructorMappings = resultMap.getConstructorResultMappings();
if (hasTypeHandlerForResultObject(rsw, resultType)) {
// 目标类型有没有特定的类型处理器
return createPrimitiveResultObject(rsw, resultMap, columnPrefix);
} else if (!constructorMappings.isEmpty()) {
// 目标类型构造函数映射是否为空
return createParameterizedResultObject(rsw, resultType, constructorMappings, constructorArgTypes, constructorArgs, columnPrefix);
} else if (resultType.isInterface() || metaType.hasDefaultConstructor()) {
// 目标类型是否是接口或者有默认构造函数
return objectFactory.create(resultType);
} else if (shouldApplyAutomaticMappings(resultMap, false)) {
//可以自动映射,则按照构造函数来生成
return createByConstructorSignature(rsw, resultType, constructorArgTypes, constructorArgs);
}
throw new ExecutorException("Do not know how to create an instance of " + resultType);
}
在createResultObject()
方法中会判断应该怎么进行目标对象的实例化:判断规则如下:
如果目标类型有特定的类型处理器,则调用 createPrimitiveResultObject()
进行转换成对象。如果目标类型的构造函数映射不为空,则调用 createParameterizedResultObject()
进行转换成对象。如果目标类型是接口或者有默认构造函数,则由对象工厂生成。 如果可以自动映射,则按照有参数构造函数进行转换成对象。
本例中的目标类型是TestEntity
类,在TestEntity
中我们没有写任何构造函数和get/set
方法,而是由Lombok的Data注解和Builder注解来实现。
Data注解会给被注解的类的所有属性生成get/set
方法以及实现了hashCode()
、toString()
和equals()
方法,并且生成一个无参数的构造函数。Builder注解除了会生成相应的建筑者模式的代码外,还会生成一个全属性参数的构造函数,参数的顺序就是属性定义的顺序。
但是如果Data注解和Builder注解一块使用的话就只会生成全属性参数构造函数,不会有默认无参构造函数。
也即是说,本例中,TestEntity会生成一个函数签名为com.scheduler.resource.entity.TestEntity(java.lang.Long,java.lang.String,java.lang.Long,java.time.LocalDateTime,java.time.LocalDateTime,int)
的构造函数。
因此,对于TestEntity
来说,既没有定义特定的类型处理器,构造函数映射为空,且没有无参构造函数,也不是接口,所以执行的是createByConstructorSignature(rsw, resultType, constructorArgTypes, constructorArgs)
这个方法。
private Object createByConstructorSignature(ResultSetWrapper rsw, Class<?> resultType, List<Class<?>> constructorArgTypes, List<Object> constructorArgs) throws SQLException {
// 找到实体类中的构造函数,选取第一个
final Constructor<?>[] constructors = resultType.getDeclaredConstructors();
final Constructor<?> defaultConstructor = findDefaultConstructor(constructors);
if (defaultConstructor != null) {
// 通过构造函数生成对象
return createUsingConstructor(rsw, resultType, constructorArgTypes, constructorArgs, defaultConstructor);
} else {
for (Constructor<?> constructor : constructors) {
if (allowedConstructorUsingTypeHandlers(constructor, rsw.getJdbcTypes())) {
return createUsingConstructor(rsw, resultType, constructorArgTypes, constructorArgs, constructor);
}
}
}
throw new ExecutorException("No constructor found in " + resultType.getName() + " matching " + rsw.getClassNames());
}
createByConstructorSignature()
方法首先找到实体类中定义的构造函数,选取第一个,本例中就只有一个全属性参数构造函数,然后使用这个构造函数调用createUsingConstructor()
方法。
private Object createUsingConstructor(ResultSetWrapper rsw, Class<?> resultType, List<Class<?>> constructorArgTypes, List<Object> constructorArgs, Constructor<?> constructor) throws SQLException {
boolean foundValues = false;
for (int i = 0; i < constructor.getParameterTypes().length; i++) {
// 遍历构造函数的参数,依次与数据库中的字段映射
Class<?> parameterType = constructor.getParameterTypes()[i];
String columnName = rsw.getColumnNames().get(i);
TypeHandler<?> typeHandler = rsw.getTypeHandler(parameterType, columnName);
Object value = typeHandler.getResult(rsw.getResultSet(), columnName);
constructorArgTypes.add(parameterType);
constructorArgs.add(value);
foundValues = value != null || foundValues;
}
return foundValues ? objectFactory.create(resultType, constructorArgTypes, constructorArgs) : null;
}
createUsingConstructor()
方法主要的作用就是按照构造函数入参的顺序,把数据库中对应索引的字段赋值给对应的属性。
数据库字段顺序 | 实体类字段顺序 |
---|---|
id | id |
teacher_id | teacherName |
teacher_name | teacherId |
level | createTime |
createTime | updateTime |
updateTime | level |
因此,createUsingConstructor()
方法会把teacher_name
字段的值'Slogen'
赋值给teacherId
属性,而teacherId
是Long
类型,所以进行类型转换的时候失败,从而抛出Caused by: java.lang.NumberFormatException: For input string: "Slogen"
异常。
总结一下,由于实体类TestEntity
同时使用了Data
注解和Builder
注解导致没有默认无参构造函数,只有一个全属性参数构造函数,参数顺序就是属性定义的顺序。MyBatis在把数据库字段映射到实体类的时候发现实体类没有默认无参构造函数的话,就会把数据库中的字段按照构造函数参数的顺序依次赋值给实体类的属性。一旦实体类的属性定义顺序与数据库中字段顺序不一致的话且类型没法兼容(即不能互相转换)就会出错。
解法
解法一
实体类中属性定义的顺序与数据库中字段顺序保持一致即可。
本例中把TestEntity
类的定义改成如下所示即可正常使用。
@Data
@Builder
public class TestEntity {
private Long id;
private Long teacherId;
private String teacherName;
private int level;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
解法二
根据前面的分析,只有在实体类没有默认无参构造函数的情况下才会按照上面分析的那种方式进行构造。如果实体类有默认无参构造函数,则是直接调用对象工厂objectFactory.create(resultType)
(底层就是反射)直接生成一个对象。
然后在getRowValue()
方法中,调用applyPropertyMappings()
方法按照xml文件中配置好的映射进行属性赋值。
private Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, String columnPrefix) throws SQLException {
final ResultLoaderMap lazyLoader = new ResultLoaderMap();
Object rowValue = createResultObject(rsw, resultMap, lazyLoader, columnPrefix);
if (rowValue != null && !hasTypeHandlerForResultObject(rsw, resultMap.getType())) {
// ... 省略代码
// 按照xml文件中配置好的映射进行属性赋值
foundValues = applyPropertyMappings(rsw, resultMap, metaObject, lazyLoader, columnPrefix) || foundValues;
foundValues = lazyLoader.size() > 0 || foundValues;
rowValue = foundValues || configuration.isReturnInstanceForEmptyRow() ? rowValue : null;
}
return rowValue;
}
因此,此时只需要给实体类加上@NoArgsConstructor
和@AllArgsConstructor
注解,让实体类有无参构造函数即可。
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TestEntity {
private Long id;
private String teacherName;
private Long teacherId;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private int level;
}