引子
Kotlin是個好東西,寫起來快得多,代碼少敲很多的同時也帶來了一些回避不了的問題——那就是第三方庫的兼容問題,而本身這些問題其實蠻可以不用存在的,而這些問題的焦點基本上都集中在了它的兩個特性上
- class 默認是 final 的
- 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;
}
判斷的流程也是非常粗暴直接
- 拿到所有的JDBC返回的參數和構造器的參數作比較,如果完全一致,直接返回 true
- 如果參數數量都不一樣,直接返回 false ,也就是跳到下一個
- 如果數量相同,那么一個個的作比較,如果是原始數據類型,那么去拿它的包裝數據類型比一下,如果不是,則直接比類名
這里就有一個問題來了。
其實這里的數據類型比較其實是無視了 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 循環去判斷真正需要使用哪個,甚至沒有簡單的去對參數數量去做判斷。
於是幾種方法來解決這個問題
- 使用給data class 的每個字段都增加默認值的方法,讓它產生一個空構造器,這個是一勞永逸的解法,適用於任何場景。
- 使用
@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 的結果時:
- 如果都是原始數據類型,那么不需要做任何操作,可以運行得很好
- 如果有字段是需要 Type Mapper 來進行映射的,如果沒有任何字段需要默認值,那么加上
@AutomapConstructor就可以運行得很好了。如果有字段需要默認值,那么給所有的字段都加上默認值
