當mybatis遇到了kotlin


本文為轉載 [原文地址](https://www.dazhuanlan.com/2019/11/04/5dbf0fd13705f/)

引子

Kotlin是個好東西,寫起來快得多,代碼少敲很多的同時也帶來了一些回避不了的問題——那就是第三方庫的兼容問題,而本身這些問題其實蠻可以不用存在的,而這些問題的焦點基本上都集中在了它的兩個特性上

  1. class 默認是 final 的
  2. data class 好用,但是它無法給出默認的空構造器

這兩個問題導致了很多第三方的包的反射機能失效,解決的方法也很簡單,如果自己寫,那么就是到處都open起來,因為近年來kotlin自身的發展,第三方也紛紛給與兼容和支持。

但是問題是!這些兼容和支持大多都沒有寫入到文檔中,都是在issue中存在的。當然,也有好的,比如Spring官方就會專門為Kotlin的兼容和特性寫過文章(鏈接),說明了它在這個方面是下了功夫的,於是Spring配合Kotlin基本上就是沒啥問題的。

相比之下mybatis就比較坑了,它有么有做相關的工作呢?我敢肯定,它鐵定是做了的,但是它文檔非常的落后,很多工作默默做了,但是卻憋着不說,基本上只能靠大家關注社區、重新看doc、看代碼來解決。

於是把工作中遇到的坑在這里羅列一下,算是對自己踩過的坑留個印記吧

mybatis 結果集自動映射 data class

老生常談的問題了,問題原因在於空構造器問題,網上給出的都是老的解決方案,即通過給所有的data class的屬性增加默認值的方式,這樣kotlin在編譯出JVM的字節碼的時候會加上一個空構造器,方便做反射使用——但是,我不想寫這些莫名其妙的默認值怎么辦呢?

其實也有辦法,mybatis自3.4之后加入了這樣一個注解:

/**
 * The marker annotation that indicate a constructor for automatic mapping.
 *
 * @author Tim Chen
 * @since 3.4.3
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.CONSTRUCTOR})
public @interface AutomapConstructor {
}

可以指定constructor了,於是,把data class改成這樣好了

data class User @AutomapConstructor constructor(
    val uid: Int,
    val name: String
)

為什么會產生這種詭異的情況呢?

這還是需要從MyBatis的代碼說起

  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, columnPrefix);
    }
    throw new ExecutorException("Do not know how to create an instance of " + resultType);
  }

MyBatis的代碼都比較簡單粗暴,而且是基本上沒有注釋的,看慣了JDK的代碼,再看它無疑能感受到差距——好吧,吐槽的先不說,先看看代碼

核心是那一段 if 和 else if 的集合體,這個是用來選擇到底要使用什么方法來構造目標對象的核心

首先它先判斷是否有明確定義的 TypeHandler,這個我一般都省略不寫,如果真的寫了反倒沒那么多事情了。

其次判斷是否有明確定義的構造器 Mapping,這個一般我也沒寫,畢竟追求的是自動構造,

接下來判斷結果是否是一個 Interface 或者它有一個空構造器,那么進入 objectFactory.create(resultType) 這個方法,否則則進入 createByConstructorSignature 這個方法。我們的 data class 默認是沒有空構造器的,所以一定會進入使用構造函數的方式。

構造函數的構造方法如下:

  private Object createByConstructorSignature(ResultSetWrapper rsw, Class<?> resultType, List<Class<?>> constructorArgTypes, List<Object> constructorArgs,
                                              String columnPrefix) throws SQLException {
    final Constructor<?>[] constructors = resultType.getDeclaredConstructors();
    final Constructor<?> annotatedConstructor = findAnnotatedConstructor(constructors);
    if (annotatedConstructor != null) {
      return createUsingConstructor(rsw, resultType, constructorArgTypes, constructorArgs, columnPrefix, annotatedConstructor);
    } else {
      for (Constructor<?> constructor : constructors) {
        if (allowedConstructor(constructor, rsw.getClassNames())) {
          return createUsingConstructor(rsw, resultType, constructorArgTypes, constructorArgs, columnPrefix, constructor);
        }
      }
    }
    throw new ExecutorException("No constructor found in " + resultType.getName() + " matching " + rsw.getClassNames());
  }

首先判明它有構造函數被加上了 @AutomapConstructor 這個注解了么?如果有,進入 createUsingConstructor 方法,如果沒有,遍歷所有的構造方法。

於是我們先來看看不加任何注解的情況,其實這個函數本身並不重要,重要的是它確定這個構造器可用的那個判斷,也就是 allowedConstructor :

  private boolean allowedConstructor(final Constructor<?> constructor, final List<String> classNames) {
    final Class<?>[] parameterTypes = constructor.getParameterTypes();
    if (typeNames(parameterTypes).equals(classNames)) return true;
    if (parameterTypes.length != classNames.size()) return false;
    for (int i = 0; i < parameterTypes.length; i++) {
      final Class<?> parameterType = parameterTypes[i];
      if (parameterType.isPrimitive() && !primitiveTypes.getWrapper(parameterType).getName().equals(classNames.get(i))) {
        return false;
      } else if (!parameterType.isPrimitive() && !parameterType.getName().equals(classNames.get(i))) {
        return false;
      }
    }
    return true;
  }

判斷的流程也是非常粗暴直接

  1. 拿到所有的JDBC返回的參數和構造器的參數作比較,如果完全一致,直接返回 true
  2. 如果參數數量都不一樣,直接返回 false ,也就是跳到下一個
  3. 如果數量相同,那么一個個的作比較,如果是原始數據類型,那么去拿它的包裝數據類型比一下,如果不是,則直接比類名

這里就有一個問題來了。

其實這里的數據類型比較其實是無視了 Type Mapper 的,哪怕是你定義了全局的 Type Mapper, 它其實是無視的。 這就是很多人被卡住的原因

那么為什么加了 @AutomapConstructor 的注解就可以了呢?繼續看代碼

private Object createUsingConstructor(ResultSetWrapper rsw, Class<?> resultType, List<Class<?>> constructorArgTypes, List<Object> constructorArgs, String columnPrefix, 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(), prependPrefix(columnName, columnPrefix));
      constructorArgTypes.add(parameterType);
      constructorArgs.add(value);
      foundValues = value != null || foundValues;
    }
    return foundValues ? objectFactory.create(resultType, constructorArgTypes, constructorArgs) : null;
  }

其實加與不加只是多了一個判斷而已,而其實最終都會走到這個 createUsingConstructor 方法上來, 這個時候我們看到了熟悉的 Type Handler,因為它確認你一定是有把握才會加這個注釋的,所以它跨越了各種判斷,直接就對着這個構造器嘗試去做賦值,這樣就可以拿到正確的結果

但是其實這里也有一個問題就是接下來要遇到的問題

mybatis 綁定帶默認值的 data class

如果一個 data class 所有的屬性都帶了默認值,那么不加注解也是沒問題的,但是如果你加了注解,並且又有一部分的屬性是有默認值的,這個時候又會出現問題

一般問題長這樣

Cause: java.lang.IndexOutOfBoundsException: Index 3 out-of-bounds for length 3] with root cause

java.lang.IndexOutOfBoundsException: Index 3 out-of-bounds for length 3

具體是3還是多少,取決於表的colum的多少,意思就是我給了3個闡述超過了你能容納的最大長度了

這問題原因還是在於構造器,當出現了默認值之后,它理論上會生成多個構造器,這樣結合之前看到的MyBatis的代碼,就知道 MyBatis 這個時候就會傻了,因為它直覺的認為不應該產生多個構造器這種情況,它對它並沒有使用做 for 循環去判斷真正需要使用哪個,甚至沒有簡單的去對參數數量去做判斷。

於是幾種方法來解決這個問題

  1. 使用給data class 的每個字段都增加默認值的方法,讓它產生一個空構造器,這個是一勞永逸的解法,適用於任何場景。
  2. 使用 @JvmOverloads 注解,這個注解會對默認值產生更多的重載方法,這個其實解決得需要一點運氣,通過下一節來詳細解釋

mybatis 處理帶默認值的函數

如果在Dao中這么寫

fun getUserById(userId: Int=0): UserDo

在XML中這么寫

<select id="getUserById" resultType="UserDo">
    SELECT * FROM `user` WHERE #{userId}
</select>

結果就會報告找不到userId這個變量了,原因很簡單,這個函數的函數名被Kotlin給改掉了,它編譯之后生成的代碼為:

UserDo getUserById(int var1);

@Nullable
public static UserDo getUserById$default(UserDao var0, int var1, int var2, Object var3) {
    if (var3 != null) {
      throw new UnsupportedOperationException("Super calls with default arguments not supported in this target, function: getUserById");
    } else {
      if ((var2 & 1) != 0) {
          var1 = 0;
      }

      return var0.getUserById(var1);
    }
}

變量名被改成了var1了自然就識別不出來啦,這么寫就沒問題了

fun getUserById(@Param("userId") userId: Int=0): UserDo

Kotlin + Spring編程的一般問題解決之道

很多時候都會遇到一些kotlin產生的兼容問題,它們可能很難發現,也可能埋藏得很深。簡單的Debug的方式當然是看它如何生成的Java源碼——畢竟我們寫Java的時候其實是不會遇到這么多事情的

所以Debug的方法其實也挺簡單的,IDEA自帶了就有。

選擇 Tools - Kotlin - Show Kotlin ByteCode 這樣 IDE 的右側會出現 Kotlin 的編譯之后的字節碼,當然這個我們是很難看懂的,沒關系,上面有個按鈕,Decompile,反編譯這些字節碼生成Java源代碼,這下我們就看得懂了。

再回到上一個問題的遺留。

反編譯一個簡單的包含三個屬性的Data class之后,它生成了3個構造函數每個的頭頂上都是頂着 @JvmOverloads 的,說明是這個注解生成的對應的構造函數,分別是1~3個參數,在每個參數沒有值的時候給出默認值(如果有提供默認值的話),然后這三個構造函數,腦袋頂上都頂着 @AutomapConstructor 這個注解,分別是:

   @AutomapConstructor
   @JvmOverloads
   public BackendRoleUriDo(@Nullable Integer roleId, @NotNull String roleUri, @NotNull String roleName) {
      Intrinsics.checkParameterIsNotNull(roleUri, "roleUri");
      Intrinsics.checkParameterIsNotNull(roleName, "roleName");
      super();
      this.roleId = roleId;
      this.roleUri = roleUri;
      this.roleName = roleName;
   }

   // $FF: synthetic method
   @AutomapConstructor
   @JvmOverloads
   public BackendRoleUriDo(Integer var1, String var2, String var3, int var4, DefaultConstructorMarker var5) {
      if ((var4 & 1) != 0) {
         var1 = (Integer)null;
      }

      this(var1, var2, var3);
   }

   @AutomapConstructor
   @JvmOverloads
   public BackendRoleUriDo(@NotNull String roleUri, @NotNull String roleName) {
      this((Integer)null, roleUri, roleName, 1, (DefaultConstructorMarker)null);
   }

中間一個是核心的構造函數,第一個是全覆蓋的構造函數,最后一個是帶默認值的

如果去掉的時候呢?

@AutomapConstructor
   public BackendRoleUriDo(@Nullable Integer roleId, @NotNull String roleUri, @NotNull String roleName) {
      Intrinsics.checkParameterIsNotNull(roleUri, "roleUri");
      Intrinsics.checkParameterIsNotNull(roleName, "roleName");
      super();
      this.roleId = roleId;
      this.roleUri = roleUri;
      this.roleName = roleName;
   }

   // $FF: synthetic method
   @AutomapConstructor
   public BackendRoleUriDo(Integer var1, String var2, String var3, int var4, DefaultConstructorMarker var5) {
      if ((var4 & 1) != 0) {
         var1 = (Integer)null;
      }

      this(var1, var2, var3);
   }

最下面那個提供了默認值的構造器沒有了,繼續回到 MyBatis 尋找帶注釋的構造器的那個代碼

  private Constructor<?> findAnnotatedConstructor(final Constructor<?>[] constructors) {
    for (final Constructor<?> constructor : constructors) {
      if (constructor.isAnnotationPresent(AutomapConstructor.class)) {
        return constructor;
      }
    }
    return null;
  }

可以看到,其實它只是在所有的裝飾器中去尋找第一個帶有注解的。只不過很不碰巧,這個自動生成的裝飾器它總是排在第一個。但是當使用了 @JvmOverloads 這個注解之后呢? 問題發生了一點變化,它的字節碼里面默認的那個可能排到了第一個,就會莫名其妙的正確了

所以總結一下,當你需要使用 data class 來裝載 MyBatis 的結果時:

  1. 如果都是原始數據類型,那么不需要做任何操作,可以運行得很好
  2. 如果有字段是需要 Type Mapper 來進行映射的,如果沒有任何字段需要默認值,那么加上 @AutomapConstructor 就可以運行得很好了。如果有字段需要默認值,那么給所有的字段都加上默認值


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM