踩坑笔记:Lombok的Builder注解和Data注解撞上MyBatis

4,310 阅读8分钟

背景

这两天碰到了这么一个问题:在使用MyBatis(3.5.2版本)查询数据的时候,数据库返回的数据在映射成实体对象的属性的时候类型匹配异常导致程序异常。

来看看具体的代码

首先是数据库的字段和类型:

实体类的定义如下:

@Data@Builderpublic class TestEntity {    private Long id;    private String teacherName;    private Long teacherId;    private LocalDateTime createTime;    private LocalDateTime updateTime;    private int level;}

实体类定义的时候使用了Lombok的DataBuilder注解(如果不了解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_ERRORorg.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)	atCaused 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: ErrorCaused 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属性,而teacherIdLong类型,所以进行类型转换的时候失败,从而抛出Caused by: java.lang.NumberFormatException: For input string: "Slogen"异常。

总结一下,由于实体类TestEntity同时使用了Data注解和Builder注解导致没有默认无参构造函数,只有一个全属性参数构造函数,参数顺序就是属性定义的顺序。MyBatis在把数据库字段映射到实体类的时候发现实体类没有默认无参构造函数的话,就会把数据库中的字段按照构造函数参数的顺序依次赋值给实体类的属性。一旦实体类的属性定义顺序与数据库中字段顺序不一致的话且类型没法兼容(即不能互相转换)就会出错。

解法

解法一

实体类中属性定义的顺序与数据库中字段顺序保持一致即可。

本例中把TestEntity类的定义改成如下所示即可正常使用。

@Data@Builderpublic 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@AllArgsConstructorpublic class TestEntity {    private Long id;    private String teacherName;    private Long teacherId;    private LocalDateTime createTime;    private LocalDateTime updateTime;    private int level;}