mybatis plus框架的@TableField註解不生效問題總結

狂盜一枝梅發表於2022-03-04

一、問題描述

最近遇到一個mybatis plus的問題,@TableField註解不生效,導致查出來的欄位反序列化後為空

資料庫表結構:

CREATE TABLE `client_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
  `name` varchar(64) NOT NULL COMMENT '角色的唯一標識',
  `desc` varchar(64) DEFAULT NULL COMMENT '角色描述',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'

對應的實體類

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("client_role")
@ApiModel(value = "ClientRole物件", description = "角色表")
public class ClientRole implements Serializable {
    private static final long serialVersionUID = 1L;

    /**
     * 自增主鍵
     */
    @ApiModelProperty(value = "自增主鍵")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 角色的唯一標識
     */
    @NotEmpty
    @ApiModelProperty(value = "角色的唯一標識")
    @TableField("name")
    private String name;

    /**
     * 角色描述
     */
    @ApiModelProperty(value = "角色描述")
    @TableField("`desc`")
    private String description;

}

就是description欄位為空的問題,查詢sql如下

  <select id="selectOneByName" resultType="com.kdyzm.demo.springboot.entity.ClientRole">
    select *
    from client_role
    where name = #{name};
  </select>

然而,如果不手寫sql,使用mybatis plus自帶的LambdaQuery查詢,則description欄位就有值了。

ClientRole admin = iClientRoleMapper.selectOne(
    new LambdaQueryWrapper<ClientRole>().eq(ClientRole::getName, "admin")
);

真是活見鬼,兩種方法理論上結果應該是一模一樣的,最終卻發現@TableField欄位在手寫sql這種方式下失效了。

二、解決方案

定義ResultMap,在xml檔案中定義如下

  <resultMap type="com.kdyzm.demo.springboot.entity.ClientRole" id="ClientRoleResult">
    <result property="id" column="id"/>
    <result property="name" column="name"/>
    <result property="description" column="desc"/>
  </resultMap>

  <select id="selectOneByName" resultMap="ClientRoleResult">
    select *
    from client_role
    where name = #{name};
  </select>

select標籤中resultType改成resultMap,值為resultMap標籤的id,這樣description欄位就有值了。

問題很容易解決,但是有個問題需要問下為什麼:為什麼@TableField註解在手寫sql的時候就失效了呢?

三、關於@TableField註解失效原因的思考

當資料庫欄位和自定義的實體類中欄位名不一致的時候,可以使用@TableField註解實現矯正,以上面的程式碼為例,

ClientRole admin = iClientRoleMapper.selectOne(
    new LambdaQueryWrapper<ClientRole>().eq(ClientRole::getName, "admin")
);

這段程式碼被翻譯成sql,它被翻譯成這樣

image-20220120155730080

好傢伙,原來@TableField註解功能是通過加別名實現的。

那如果是手寫sql的話,它如何把別名加上去呢?答案就是沒辦法加上去,因為手寫sql太靈活了,不在mybatis plus功能框架內,那是屬於原生mybatis的功能範疇,不支援也就正常了。

四、Mapper介面LambdaQuery方法呼叫過程梳理

進一步探討,@TableField註解是如何生成別名的呢,那就要研究下原始碼了。

1、Mapper介面呼叫實際上使用的是動態代理技術

mybatis定義的都是一堆的介面,並沒有實現類,但是卻能正常呼叫,這很明顯使用了動態代理技術,實際上注入spring的時候介面被包裝成了代理物件,這就為debug原始碼提供了突破口。

image-20220120161917753

可以看到,這個代理物件實際的類名為com.baomidou.mybatisplus.core.override.MybatisMapperProxy,它實現了InvocationHandler介面,確定是JDK動態代理無疑了,那麼所有的邏輯都會走com.baomidou.mybatisplus.core.override.MybatisMapperProxy#invoke方法

2、mybatis plus對查詢的單獨處理

根據上面一步找到原始碼的入口,一步一步走下去,介面呼叫到了com.baomidou.mybatisplus.core.override.MybatisMapperMethod#execute方法

public Object execute(SqlSession sqlSession, Object[] args) {
        Object result;
        switch (command.getType()) {
            case INSERT: {
                Object param = method.convertArgsToSqlCommandParam(args);
                result = rowCountResult(sqlSession.insert(command.getName(), param));
                break;
            }
            case UPDATE: {
                Object param = method.convertArgsToSqlCommandParam(args);
                result = rowCountResult(sqlSession.update(command.getName(), param));
                break;
            }
            case DELETE: {
                Object param = method.convertArgsToSqlCommandParam(args);
                result = rowCountResult(sqlSession.delete(command.getName(), param));
                break;
            }
            case SELECT:
                if (method.returnsVoid() && method.hasResultHandler()) {
                    executeWithResultHandler(sqlSession, args);
                    result = null;
                } else if (method.returnsMany()) {
                    result = executeForMany(sqlSession, args);
                } else if (method.returnsMap()) {
                    result = executeForMap(sqlSession, args);
                } else if (method.returnsCursor()) {
                    result = executeForCursor(sqlSession, args);
                } else {
                    // TODO 這裡下面改了
                    if (IPage.class.isAssignableFrom(method.getReturnType())) {
                        result = executeForIPage(sqlSession, args);
                        // TODO 這裡上面改了
                    } else {
                        Object param = method.convertArgsToSqlCommandParam(args);
                        result = sqlSession.selectOne(command.getName(), param);
                        if (method.returnsOptional()
                            && (result == null || !method.getReturnType().equals(result.getClass()))) {
                            result = Optional.ofNullable(result);
                        }
                    }
                }
                break;
            case FLUSH:
                result = sqlSession.flushStatements();
                break;
            default:
                throw new BindingException("Unknown execution method for: " + command.getName());
        }
        if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
            throw new BindingException("Mapper method '" + command.getName()
                + " attempted to return null from a method with a primitive return type (" + method.getReturnType() + ").");
        }
        return result;
    }

這段程式碼特點在於它對於非查詢型別的請求(比如插入、更新和刪除),都直接委託給了sqlSeesion的相應的方法呼叫,而對於查詢請求,則邏輯比較複雜,畢竟sql最複雜的地方就是查詢了;還有另外一個特點,針對不同的返回結果型別,也走不同的邏輯;由於我這裡查詢返回的是一個實體物件,所以最終走到了如下斷點

image-20220120163802462

從程式碼上來看,也只是委託給了SqlSessionTemplate物件處理了,然而SqlSessionTemplate的全包名是org.mybatis.spring.SqlSessionTemplate,它是mybatis整合spring的官方功能,和mybatis plus沒關係,就這如何能讓@TableField註解發揮作用呢?

3、findOne實際上還是要查詢List

繼續debug幾次,到了一個有趣的方法org.apache.ibatis.session.defaults.DefaultSqlSession#selectOne(java.lang.String, java.lang.Object)

image-20220120164358105

原來單獨查詢一個物件,還是要查詢List,然後取出第一個物件返回;如果查詢出多個物件,則直接丟擲TooManyResultsException,建表的時候不做唯一索引查出來多個物件的時候丟擲的異常就是在這裡做的。

有意思的是,方法執行到這裡,傳參只有兩個,一個是方法名,另外一個是查詢引數

image-20220120164927954

總之還是要繼續檢視selectList的邏輯,才能搞清楚邏輯

4、mybatis介面上下文資訊MappedStatement

上一步說到selectList方法呼叫只傳遞了兩個引數,一個是方法名,一個是方法引數,只是這兩個引數是無法滿足查詢的請求的,畢竟最重要的sql語句都沒傳,debug下去,到了一處比較重要的地方,就解開了我的疑問:org.apache.ibatis.session.defaults.DefaultSqlSession#selectList(java.lang.String, java.lang.Object, org.apache.ibatis.session.RowBounds)

image-20220120165708696

在這個方法裡,根據statement也就是方法名獲取到了MappedStatement物件,這個物件裡儲存著這個關於本次查詢需要的上下文資訊,繼續debug,來到一個方法com.baomidou.mybatisplus.core.executor.MybatisCachingExecutor#query(org.apache.ibatis.mapping.MappedStatement, java.lang.Object, org.apache.ibatis.session.RowBounds, org.apache.ibatis.session.ResultHandler)

image-20220120170058531

它呼叫了MappedStatement物件的getBoundSql方法,便得到了帶有別名的sql字串,也就是說,這個getBoundSql方法形成了這段sql字串,debug進去看看

5、mybatis plus別名自動設定的邏輯

debug ms.getBoundSql方法,最終到了方法:org.apache.ibatis.scripting.xmltags.MixedSqlNode#apply,該方法入參是org.apache.ibatis.scripting.xmltags.DynamicContext型別,其內部維護了一個java.util.StringJoiner物件,專門用於拼接sql

image-20220120171128026

contents物件是個List類表,其有八個元素,經過八個元素的apply方法呼叫之後,DynamicContext的sqlBuilder物件就有了值了

image-20220120171614214

原來別名是在這裡設定的;這裡先暫且不談,查詢流程還沒結束,先看整個的流程。

6、mybatis plus的sql日誌列印

我們看到的sql日誌是如何列印出來的?上一步已經獲取到了sql,接下來繼續debug,就會看到sql列印的程式碼:org.apache.ibatis.logging.jdbc.ConnectionLogger#invoke

image-20220120175443142

7、最終查詢的執行

我們知道,無論是mybatis還是其它框架,最終執行查詢都要遵循java api規範,上一步已經獲取到了PreparedStatement,最終在這個方法執行了查詢

org.apache.ibatis.executor.statement.PreparedStatementHandler#query

image-20220120180229853

8、結果集處理

查詢完之後要封裝結果集,封裝邏輯的起始方法:org.apache.ibatis.executor.resultset.DefaultResultSetHandler#handleResultSets

image-20220120182748365

可以看到,這段邏輯就是在從Satement物件中迴圈取資料,然後呼叫org.apache.ibatis.executor.resultset.DefaultResultSetHandler#handleResultSet方法處理每一條資料

9、每一條資料的單獨處理

繼續debug,可以看到對每一條結果資料的單獨處理的邏輯:org.apache.ibatis.executor.resultset.DefaultResultSetHandler#getRowValue(org.apache.ibatis.executor.resultset.ResultSetWrapper, org.apache.ibatis.mapping.ResultMap, java.lang.String)

image-20220120183255026

這裡首先使用自動欄位名對映的方式填充返回值,然後使用resultMap繼續填充返回值,最後返回rowValue作為最終反序列化完成的值。

至此,整個查詢過程基本上就結束了。

五、@TableField註解生效原理

1、別名sql在mapper方法執行前就已經確定

上一步在梳理Mapper介面呼叫過程的時候在第5點說過,DynamicContext內部維護了一個StringJoiner物件用於拼接sql,在經過MixedSqlNode內部的8個SqlNode處理之後,StringJoiner就有了完整的sql語句。我們知道@TableField生效的原理是設定別名,那麼別名是這時候設定上去的嗎?

image-20220121094605767

SqlNode有很多實現類,目測mybatis通過實現SqlNode介面實現對XML語法的支援。裡面最簡單的SqlNode就是StaticTextSqlNode了

image-20220121094809348

可以看到這個類內部維護了一個text字串,然後將這個text字串掛到DynamicContext的StringJoiner,就是這麼簡單的邏輯,然而別名sql就是在這裡設定上去的:

image-20220121095034914

答案已經一目瞭然了,程式碼在執行到這裡的時候,這個StaticTextSqlNode裡面的text就已經準備好了sql了,等到它執行apply方法的時候直接就給掛到了DynamicConetxt的StringJoiner,這說明了別名sql的設定在Mapper方法執行之前就已經確定了,而非是程式碼執行過程中動態的解析

2、@TableField註解的外層解析

@TableFied註解何時被解析?可以推測肯定是mybatis plus starter搞的鬼,但是入口方法呼叫鏈很長,找到解析點會比較困難,最直接的方法就是在藉助intelij工具,右鍵註解,findUseage,自然就找到了這個解析方法:com.baomidou.mybatisplus.core.metadata.TableInfoHelper#initTableFields。在該方法上打上斷點,debug模式啟動服務,就找到了呼叫鏈

image-20220121101418821

可以看到,一切的起點就在com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration配置類,在方法com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration#sqlSessionFactory中建立SqlSessionFactory時開啟整個的解析流程,整個流程非常複雜,最終會呼叫到com.baomidou.mybatisplus.core.injector.AbstractSqlInjector#inspectInject方法,在執行完成com.baomidou.mybatisplus.core.metadata.TableInfoHelper#initTableInfo方法之後,TableInfo物件中的fiedList就已經儲存了資料庫欄位和實體欄位的對映關係:

image-20220121102445888

initTableInfo方法內部解析了@TableField註解,並且生成了資料庫欄位和實體欄位的對映關係,並最終儲存到了TableInfo物件。

然而,這個實體物件無法直接使用,因為在前面Mapper介面呼叫梳理的過程中就知道了,在拼接sql的時候別名已經以sql的形式儲存在了StaticTextSqlNode,還要繼續debug尋找轉換點

3、MappedStatement物件建立和儲存

緊接著要執行的程式碼在迴圈注入自定義方法這塊,上一步解析好的TableInfo會被應用到以下十七種內建方法,這和我們常用的com.baomidou.mybatisplus.core.mapper.BaseMapper介面中的方法數量是相同的,當然也就不包括手寫sql的那個自定義方法。

image-20220121103550475

在迴圈體上打上斷點,看看這個inject方法做了什麼事情,由於我們只關心com.baomidou.mybatisplus.core.injector.methods.SelectOne,所以直接進入SelectOne的inject方法打上斷點

image-20220121105138029

好傢伙,這個sqlSource可太眼熟了,基本上可以確定這個和上面分析的5、mybatis plus別名自動設定的邏輯中的DynamicSqlSource是同一個物件,如果將其放到MappedStatement物件內,那就和Mapper介面方法執行的流程對的上了,從接下來執行的方法addSelectMappedStatementForTable名字上來看,做的也正是這個事情,繼續debug,最終到了方法org.apache.ibatis.builder.MapperBuilderAssistant#addMappedStatementimage-20220121110215068

該方法建立了MappedStatement物件,並且儲存到了全域性Configuration物件。這樣,在執行Mapper介面方法的時候,根據上面梳理的執行流程中的4、mybatis介面上下文資訊MappedStatement,就可以在configuration物件中取出MappedStatement物件用於查詢了,這樣,就整個串通了@TableFied註解的作用過程。

image-20220120165708696

4、一些疑問

上面梳理了LambdaQuery介面執行的過程以及確定了@TableField註解在這個過程中是通過給欄位起別名的方式實現了資料庫欄位和實體欄位的對映。其實還有幾處疑問需要解決

1、為啥手寫sql@TableField註解就失效了呢?雖然在三、關於@TableField註解失效原因的思考中大體上明白了失效的合理性,但是從技術層面上來講只是搞明白了內建方法對@TableFied註解的支援,還沒搞明白手寫sql為啥不支援@TableFied註解。再具體點,手寫sql肯定是沒有別名的,那它的DynamicSqlSource和內建方法的DynamicSqlSource有何不同?手寫sql需要定義ResultMap,ResultMap在何時生效的?退一步說,手寫sql和內建方法的查詢是否走的同一個查詢流程呢?

2、使用LambdaQuery的內建方法通過下面的程式碼生成MappedStatement物件並且儲存到Configuration全域性配置中,手寫的sql並不在這個列表中,手寫sql的介面方法何時建立的MappedStatement物件的呢?

image-20220121103550475

六、Mapper介面手寫sql方法呼叫過程梳理

整個流程基本上和四、Mapper介面LambdaQuery方法呼叫過程梳理一樣,這裡只是說下不同之處

1、生成sql的方式不同

在LambdaQuery中,生成sql的方式是使用DynamicSqlSource

image-20220121132327002

其內部維護了一個rootSqlNode用於解析sql語句,其中查詢列包含別名被放到了一個StaticTextSqlNode中;

但是在手寫sql的時候,不再是DynamicSqlSource,而是RawSqlSource:

image-20220121132621798

內部不再維護MixedSqlNode,而是直接使使用一個sql字串,該字串正是xml檔案中手寫的sql:

<select id="selectOneByName" resultMap="ClientRoleResult">
    select *
    from client_role
    where  name = #{name};
  </select>

很明顯,這裡確實是原生的sql,沒有任何的mybatis標籤混雜在裡面。

假如我稍微改一下這段sql又如何?改成如下形式

<select id="selectOneByName" resultMap="ClientRoleResult">
    select *
    from client_role
    <where>
      name = #{name};
    </where>
  </select>

兩段程式碼邏輯上是完全一樣的,再次執行debug到此處

image-20220121134245981

可以看到,sqlSource已經變成了DynamicSqlSource,只是它相對於LambdaQuery的查詢方式,少了很多個SqlNode節點。雖然變成了DynamicSqlSource,但是可以看到還是沒有設定別名,StaticTextSqlNode中儲存了xml檔案中寫的原始的sql字串。

這樣可以得出結論:如果xml檔案中寫的sql沒有使用任何mybatis的標籤,則會使用RawSqlSource,如果使用了例如<where></where>等標籤,則會使用DynamicSqlSource;同樣使用的都是DynamicSqlSource的情況下,手寫Sql的DynamicSqlSource查詢列不會自動增加別名,查詢列取決於手寫sql的程式碼。

需要注意的是執行這段程式碼的是org.apache.ibatis.mapping.MappedStatement物件,它是在服務啟動的時候建立並儲存到全域性MybatisConfiguration中的,也就是說,在服務啟動的時候就已經決定了在這裡查詢的時候使用的是DynamicSqlSource還是RawSqlSource。

2、結果集處理方式不同

之前說過,即使是查詢一個元素,底層還是會查詢List,然後對每個元素單獨反序列化封裝成實體類物件,這個操作在org.apache.ibatis.executor.resultset.DefaultResultSetHandler#getRowValue(org.apache.ibatis.executor.resultset.ResultSetWrapper, org.apache.ibatis.mapping.ResultMap, java.lang.String)方法中。

image-20220121140008998

需要注意的是402行的applyAutomaticMappings方法執行以及404行的applyPropertyMappings方法執行

當使用LambdaQuery查詢的時候,402行程式碼返回的foundValues值為true,方法執行完成,rowValue就有值了,見下圖:

image-20220121140516305

404行的applyPropertyMappings方法執行則會直接跳過執行,因為不滿足執行條件;

而當手寫sql方法呼叫時,402行的applyAutomaticMappings方法執行會返回false,執行完成之後rowValue欄位屬性並沒有填充,見下圖:

image-20220121140820033

而404行的applyPropertyMappings方法滿足了執行條件,執行完成之後foundValues的值變成了true,而rawValue也有值了。

為啥呢?

applyAutomaticMappings方法和applyPropertyMappings方法兩個方法從方法名字上來看似乎是對立的兩個方法如果未指定PropertieyMapping,則走applyAutomacitMapping,如果指定了則走applyPropertyMapping,但是會不會同時存在兩個方法都走一遍呢?那是肯定的,因為applyPropertyMapping並沒有放在else塊中,它是強制執行的,為了驗證這個問題,修改下Xml檔案中定義的ResultMap,原來ResultMap長這樣子

  <resultMap type="com.kdyzm.demo.springboot.entity.ClientRole" id="ClientRoleResult">
    <result property="id" column="id"/>
    <result property="name" column="name"/>
    <result property="description" column="desc"/>
  </resultMap>

現在我改成這個樣子

  <resultMap type="com.kdyzm.demo.springboot.entity.ClientRole" id="ClientRoleResult">
    <result property="description" column="desc"/>
  </resultMap>

刪掉表欄位和實體欄位同名的對映關係,只留下不同的對映關係,再次執行手寫sql的介面查詢。

執行完成applyAutomaticMappings方法之後,未在ResultMap中指定對映關係的id和name兩個屬性填充上了值,如下圖:

image-20220121143938734

執行完成applyPropertyMappings方法之後,在ResultMap中定了對映關係的description欄位填充上了值,如下圖:

image-20220121144149855

說明了一個問題:只要在ResultMap中沒定義對映關係,就會被applyAutomaticMappings方法處理屬性填充;如果在ResultMap中定義了對映關係,則會被applyPropertyMappings方法處理屬性填充;另外,說明了ResultMap不需要全部都寫上關係對映,只需要寫資料庫欄位名和實體類欄位不一致的對映即可。

那麼如何區分出來哪些欄位該走applyAutomaticMappings方法屬性填充,哪些欄位該走applyPropertyMappings屬性填充呢?

答案就在傳過來的resultMap物件中,它有個屬性叫ResultMapping,儲存著解析XML檔案中ResultMap的對映,如下圖所示:

image-20220121144924349

凡是在resultMapping中的屬性,都走applyPropertyMappings方法,否則走applyAutomaticMappings方法。

3、手寫sql介面方法@TableFied註解失效的原因

一開始未在xml檔案中定義ResultMapping,且使用的是手寫sql。根據上面的原始碼分析,在未定義ResultMap的情況下,所有的屬性填充都會走org.apache.ibatis.executor.resultset.DefaultResultSetHandler#applyAutomaticMappings方法,其邏輯也比較清晰

image-20220121150831522

  1. 首先查詢出所有未在xml檔案中定義的ResultMap對映表欄位集合
  2. 對這些表欄位進行處理,比如如果開啟了mapUnderscoreToCamelCase,則會將表欄位從下換線變成駝峰命名
  3. 嘗試從實體類中尋找轉換好的欄位,如果找到了,則全部放到List<UnMappedColumnAutoMapping> autoMapping
  4. 從mapping尋找適合的typeHandler解析屬性值,比如Long型別的值會呼叫LongTypeHandler進行屬性值解析
  5. 屬性值填充到rawValue

套用上述流程,看看description欄位為啥沒填充上去:

  1. 首先查詢出所有未在xml檔案中定義的ResultMap對映表欄位集合,找到了id,name,desc三個表欄位
  2. 對這些表欄位進行處理,比如如果開啟了mapUnderscoreToCamelCase,則會將表欄位從下換線變成駝峰命名,三個欄位都無變化
  3. 嘗試從實體類尋找轉換好的欄位,如果找到了,則全部放到List<UnMappedColumnAutoMapping> autoMapping,實體類有三個欄位id,name,description,id,name都找到了,由於desc和description長得不一樣,所以就沒填充到List<UnMappedColumnAutoMapping> autoMapping,最終上圖中只有id和name兩個屬性值被add到了autoMapping。
  4. 從mapping尋找適合的typeHandler解析屬性值,這裡只解析了id和name兩個欄位的屬性值
  5. 屬性值填充到rawValue,這裡只填充了id和name兩個欄位的屬性值

總結下,desc欄位因為沒有在ResultMap中定義,所以不會被applyPropertyMappings方法處理;本來應該被applyAutomaticMappings處理的,又因為和description實體類欄位名長得不一樣,就被applyAutomaticMappings方法忽略了,成了一個兩不管的狀態,所以最終只能是預設值填充,那就是null了。

那麼@TableFied欄位真的一點用就沒了嗎,上述流程中程式碼中怎麼知道資料庫表欄位的呢?

表欄位都被封裝到了ResultSetWrapper物件中,如下圖所示

image-20220121153116922

這些表欄位是從執行結果ResultSet中的後設資料獲取到的,最終通過構造方法填充屬性值,如下圖所示

image-20220121153503473

所以,當手寫sql的時候,@TableField註解就真的完全沒用了。

下面說下手寫sqlmapper方法建立對應MappedStatement物件的過程。

4、手寫SQL的MappedStatement物件的建立

同樣的,手寫sql的MappedStatement物件的建立也是在SqlSessionFactoryBean物件建立的過程中建立的。但是手寫SQL的MappedStatement物件建立的時間遠比mybatis plus內建方法的建立早的多。

建立SqlSessionFactoryBean的入口方法:com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean#buildSqlSessionFactory

image-20220121165926183

這段程式碼會解析所有的xml檔案並且最終在org.apache.ibatis.builder.MapperBuilderAssistant#addMappedStatement方法中建立手寫sql的MapperStatement並儲存到Configuration上下文中。

七、手寫SQL如何讓@TableFiled生效

如果,我就是想手寫SQL,還不想寫ResultMap而且還想@TableField註解生效,又該怎麼做呢?

image-20220121155743024

先說下結論:理論上可行,實踐很困難。下面逐一分析各種方法的可行性。

1、方法一:新增ResultMapping

通過上面的原始碼分析,知道了mybatis針對每個Mapper介面都建立了一個MappedStatement物件,該物件實際上儲存了該介面的上下文資訊,無論是執行的sql還是結果型別、欄位Mapping等都在裡面(不包含ResultSet返回的行動態AutoMapping),在反序列化之前修改該物件,根據@TableFied註解新增資料庫欄位和實體類欄位的對映關係,就應該能影響反序列化結果。

然而,我發現所有相關的屬性都被修飾成了不可修改的集合,這裡有個最關鍵的resultMappings集合,也被修飾成了不可修改的集合,看起來官方並不想我們動他們的資料,畢竟萬一出了問題,就很難排查是誰導致的了。

image-20220125174112489

所以說,這種方式行不通。

2、方法二:使用外掛填充未被設定值的屬性

如果沒設定ResultMap,會使用自動對映的方式填充實體類物件,desc和descriptin欄位的對映則會失敗,最終到實體類物件裡descriptin欄位就為空。若是基於此結果,再做處理,將為空的值嘗試使用@TableFiled註解做對映再次填充,理論上也是可行的,所以我使用mybatis外掛的方式重新處理了結果:


import com.baomidou.mybatisplus.annotation.TableField;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.resultset.DefaultResultSetHandler;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.executor.resultset.ResultSetWrapper;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ResultMap;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.TypeHandler;
import org.springframework.util.CollectionUtils;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.*;

/**
 * @author kdyzm
 * @date 2022/1/24
 */
@Slf4j
@Intercepts({
    @Signature(
        type = ResultSetHandler.class,
        method = "handleResultSets",
        args = {Statement.class}
    )
})
public class ResultSetHandlerPlugin implements Interceptor {

    private final Map<String, List<UnMappedColumnMapping>> unMappedColumnMappingCache = new HashMap<>();

    private ResultSetWrapper getFirstResultSet(Statement stmt, Configuration configuration) throws SQLException {
        ResultSet rs = stmt.getResultSet();
        while (rs == null) {
            // move forward to get the first resultset in case the driver
            // doesn't return the resultset as the first result (HSQLDB 2.1)
            if (stmt.getMoreResults()) {
                rs = stmt.getResultSet();
            } else {
                if (stmt.getUpdateCount() == -1) {
                    // no more results. Must be no resultset
                    break;
                }
            }
        }
        return rs != null ? new ResultSetWrapper(rs, configuration) : null;
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        //通過StatementHandler獲取執行的sql
        DefaultResultSetHandler statementHandler = (DefaultResultSetHandler) invocation.getTarget();
        MappedStatement mappedStatement = getMappedStatement(statementHandler);
        Configuration configuration = mappedStatement.getConfiguration();
        Object[] args = invocation.getArgs();
        Method method = invocation.getMethod();
        Statement statement = (Statement) invocation.getArgs()[0];
        ResultSetWrapper firstResultSet = getFirstResultSet(statement, configuration);
        List result = (List) invocation.proceed();
        //獲得結果集
        ResultMap resultMap = mappedStatement.getResultMaps().get(0);
        List<UnMappedColumnMapping> unMappedColumnMappings = getUnMappedColumnMapping(firstResultSet, resultMap);
		//TODO 
        return result;
    }

    private List<UnMappedColumnMapping> getUnMappedColumnMapping(ResultSetWrapper firstResultSet, ResultMap resultMap) {
        Class clazz = resultMap.getType();
        List<UnMappedColumnMapping> unMappedColumnMappings = this.unMappedColumnMappingCache.get(clazz.getName());
        if (!CollectionUtils.isEmpty(unMappedColumnMappings)) {
            return unMappedColumnMappings;
        }
        unMappedColumnMappings = new ArrayList<>();

        Set<String> mappedProperties = resultMap.getMappedProperties();
        Field[] fields = clazz.getDeclaredFields();
        for (Field field : fields) {
            if (Modifier.isFinal(field.getModifiers())
                || Modifier.isStatic(field.getModifiers())
                || Modifier.isVolatile(field.getModifiers())
            ) {
                continue;
            }
            String fieldName = field.getName();
            boolean contains = mappedProperties.contains(fieldName);
            if (contains) {
                continue;
            }
            TableField annotation = field.getAnnotation(TableField.class);
            if (Objects.isNull(annotation)) {
                continue;
            }
            String columnName = annotation.value();
            TypeHandler<?> typeHandler = firstResultSet.getTypeHandler(field.getType(), columnName);
            if (Objects.isNull(typeHandler)) {
                log.error("不支援的欄位反序列化:{}", columnName);
            } else {
                log.info("欄位={}使用的反序列化工具為:{}", columnName, typeHandler);
                UnMappedColumnMapping unMappedColumnMapping = new UnMappedColumnMapping(
                    columnName,
                    field.getName(),
                    typeHandler
                );
                unMappedColumnMappings.add(unMappedColumnMapping);
            }
        }
        this.unMappedColumnMappingCache.put(clazz.getName(), unMappedColumnMappings);
        return unMappedColumnMappings;
    }

    private MappedStatement getMappedStatement(DefaultResultSetHandler statementHandler) throws NoSuchFieldException, IllegalAccessException {
        Field field = statementHandler.getClass().getDeclaredField("mappedStatement");
        field.setAccessible(true);
        MappedStatement mappedStatement = (MappedStatement) field.get(statementHandler);
        return mappedStatement;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Data
    @AllArgsConstructor
    static class UnMappedColumnMapping {

        private String columnName;

        private String propertyName;

        private TypeHandler<?> typeHandler;
    }
}

程式碼寫到上述TODO的地方就寫不下去了。。。原因是Satement物件中的結果只能讀一次,在第一次List result = (List) invocation.proceed();執行過後,再次取結果就取不出來了。

而且coding的過程中發現其它的問題:MappedStatement物件作為DefaultResultSetHandler的成員變數並沒有暴露GET/SET方法,要想獲取到必須通過反射暴力獲取:

    private MappedStatement getMappedStatement(DefaultResultSetHandler statementHandler) throws NoSuchFieldException, IllegalAccessException {
        Field field = statementHandler.getClass().getDeclaredField("mappedStatement");
        field.setAccessible(true);
        MappedStatement mappedStatement = (MappedStatement) field.get(statementHandler);
        return mappedStatement;
    }

在我感覺其實很不爽,畢竟強扭的瓜不甜。。。

總而言之,這種方式也以失敗告終,那隻能用最後一種終極方法了:自定義反序列化的過程。

3、方法三:自定義反序列化過程

這種方式確實可以實現,但是實現起來會很困難,因為不想破壞mybatis和mybaits plus原有的功能,比如:autoMapping、下劃線轉駝峰、resultMap、各種返回型別處理。。。如果自己重新實現,代價就太大了,這是得不償失的做法。如果不破壞這些功能,只是稍微做些修改的話是可以接受的。

4、方法四:增強反序列化過程

首先制定一個反序列化的規則:當手寫sql的時候,自動mapping和resultmap優先順序最高,之後若是有未匹配的屬性,則使用@TableField註解嘗試解決,最終如果還是無法匹配,則直接pass掉不做處理。

這裡處理的核心方法就是在mybatis反序列化處理完單個物件之後額外新增邏輯,核心方法就在:DefaultResultSetHandler#getRowValue(org.apache.ibatis.executor.resultset.ResultSetWrapper, org.apache.ibatis.mapping.ResultMap, java.lang.String)方法中

image-20220304170007308

問題就在於此處程式碼無法應用動態代理或者切面技術,最終,我使用了javassit技術動態修改位元組碼物件解決了該問題,javassit簡介可以參考文章:使用javassist執行時動態修改位元組碼物件

專案原始碼地址:狂盜一枝梅 / mybatis-plus-fix

最終,實現效果上來看,確實解決了@TableFiled註解在手寫sql的情況下失效的問題,但是由於額外執行了一段程式碼,所以執行效率會稍微低一些;而且由於使用了javassit,程式碼的可讀性和可維護性較低,尤其是在debug程式碼的時候會出現靈異現象。。。綜上,作為實驗性的問題解決,雖然能解決問題,但是不建議使用,哈哈

相關文章