通過方法引用獲取屬性名的底層邏輯是什麼?

很多小夥伴可能都用過 MyBatis-Plus,這裏邊我們構造 where 條件的時候,可以直接通過方法引用的方式去指定屬性名:

LambdaQueryWrapper<Book> qw = new LambdaQueryWrapper<>();
qw.eq(Book::getId, 2);
List<Book> list = bookMapper.selectList(qw);
System.out.println("list = " + list);

Book::getId 這就是方法引用,松哥之前也專門寫過文章介紹相關內容,這裏就不再多說。這裏我們就單純來說說爲什麼 MP 通過 Book::getId 就可以識別出來這裏的屬性名。

1. 源碼分析

這個問題其實好解決,我們順着 qw.eq 這個方法往下看就可以了,這個方法在執行的過程中幾經輾轉會來到 getColumnCache 方法中,這個方法就是解析出來屬性值的地方。

protected ColumnCache getColumnCache(SFunction<T, ?> column) {
    LambdaMeta meta = LambdaUtils.extract(column);
    String fieldName = PropertyNamer.methodToProperty(meta.getImplMethodName());
    Class<?> instantiatedClass = meta.getInstantiatedClass();
    tryInitCache(instantiatedClass);
    return getColumnCache(fieldName, instantiatedClass);
}

首先這裏先將我們傳入的 Lambda 表達式通過 LambdaUtils.extract 方法解析出來一個 LambdaMeta 對象。

public static <T> LambdaMeta extract(SFunction<T, ?> func) {
    // 1. IDEA 調試模式下 lambda 表達式是一個代理
    if (func instanceof Proxy) {
        return new IdeaProxyLambdaMeta((Proxy) func);
    }
    // 2. 反射讀取
    try {
        Method method = func.getClass().getDeclaredMethod("writeReplace");
        method.setAccessible(true);
        return new ReflectLambdaMeta((SerializedLambda) method.invoke(func), func.getClass().getClassLoader());
    } catch (Throwable e) {
        // 3. 反射失敗使用序列化的方式讀取
        return new ShadowLambdaMeta(com.baomidou.mybatisplus.core.toolkit.support.SerializedLambda.extract(func));
    }
}

這塊的重點其實就在反射讀取這塊,這是從我們傳入的 Lambda 中找到了一個名爲 writeReplace 的方法,並且通過反射執行了這個方法,然後將執行結果封裝爲一個 ReflectLambdaMeta 對象返回。

接下來回到 getColumnCache 方法中,繼續通過 String fieldName = PropertyNamer.methodToProperty(meta.getImplMethodName()); 獲取到屬性名稱。

這裏有一個 meta.getImplMethodName() 方法,這個方法的拿到的其實就是我們 Lambda 表達式中的方法名,也就是 getId,然後再通過 PropertyNamer.methodToProperty 對這個方法名進行處理,最終拿到屬性名:

public static String methodToProperty(String name) {
  if (name.startsWith("is")) {
    name = name.substring(2);
  } else if (name.startsWith("get") || name.startsWith("set")) {
    name = name.substring(3);
  } else {
    throw new ReflectionException(
        "Error parsing property name '" + name + "'.  Didn't start with 'is', 'get' or 'set'.");
  }
  if (name.length() == 1 || name.length() > 1 && !Character.isUpperCase(name.charAt(1))) {
    name = name.substring(0, 1).toLowerCase(Locale.ENGLISH) + name.substring(1);
  }
  return name;
}

大家看到,這個解析的過程其實就是把方法名的前綴 get/set/is 這些去掉,然後剩餘的字符串首字母小寫之後返回。

這就是我們傳入 Book::getId,最終能夠拿到 id 這個名稱的原因。

現在的問題變成了 writeReplace 方法究竟是個什麼方法?

2. writeReplace

這個方法其實是系統底層自動生成的。我們可以將 Lambda 表達式在運行時生成的字節碼保存下來,然後進行反編譯,這樣就能夠看到 writeReplace 方法了。

如果需要將 Lambda 運行時生成的字節碼保存,需要在啓動參數中添加如下內容:

-Djdk.internal.lambda.dumpProxyClasses=/Users/sang/workspace/code/mp_demo/lambda/

等於號後面的部分是指定生成的字節碼的保存位置,大家可以根據自己的實際情況去配置。

以本文一開頭的 Lambda 表達式爲例,最終生成的字節碼反編譯之後,內容如下:

final class MpDemo02ApplicationTests$$Lambda$1164 implements SFunction {
    private MpDemo02ApplicationTests$$Lambda$1164() {
    }

    public Object apply(Object var1) {
        return ((Book)var1).getId();
    }

    private final Object writeReplace() {
        return new SerializedLambda(MpDemo02ApplicationTests.class, "com/baomidou/mybatisplus/core/toolkit/support/SFunction", "apply", "(Ljava/lang/Object;)Ljava/lang/Object;", 5, "org/javaboy/mp_demo02/model/Book", "getId", "()Ljava/lang/Integer;", "(Lorg/javaboy/mp_demo02/model/Book;)Ljava/lang/Object;", new Object[0]);
    }
}

大家可以看到,apply 方法實際上是重寫的接口的方法,在這個方法中將傳入的對象強轉爲 Book 類型,然後調用其 getId 方法。

然後大家看到,反編譯之後多了一個 writeReplace 方法,這個方法的返回值是一個 SerializedLambda,這個 SerializedLambda 對象其實就是對 Lambda 表達式的描述。基本上每個參數都能做到見名知意,我這裏說一下第七個參數,值是 getId,這個參數的變量名是 implMethodName,這就是我們 Lambda 表達式中給出來的變量名。這也是第一小節中,meta.getImplMethodName() 所獲取到的值。

這下就清楚了,爲什麼寫了 Book::getId 就能拿到屬性名了。

3. 擴展知識

有的小夥伴注意到,在 qw.eq(Book::getId, 2); 方法中,第一個參數是一個 SFunction 的實例,那就說我直接給一個 SFunction 的實例,不用 Lambda。大家注意,這種寫法不對!

原因在於經過前面的源碼分析之後,我們發現,MP 中根據 Book::getId 去獲取屬性名稱,一個關鍵點是利用 Lambda 在執行的時候生成的字節碼去獲取,如果你都沒有用 Lambda,那也就不會生成所謂的 Lambda 字節碼,也就不存在 writeReplace 方法,按照前文所分析的源碼,就無法獲取到屬性名稱。

還有小夥伴說,既然是 Lambda,那麼我不用方法引用行不行?我像下面這樣寫行不行?

LambdaQueryWrapper<Book> qw = new LambdaQueryWrapper<>();
qw.eq(b -> b.getId(), 2);
List<Book> list = bookMapper.selectList(qw);
System.out.println("list = " + list);

這也是一個 Lambda,但是如果你這樣寫了,運行之後就會報錯。爲什麼呢?我們來看下這個 Lambda 生成的字節碼反編譯之後是什麼樣的:

final class MpDemo02ApplicationTests$$Lambda$1164 implements SFunction {
    private MpDemo02ApplicationTests$$Lambda$1164() {
    }

    public Object apply(Object var1) {
        return MpDemo02ApplicationTests.lambda$test18$3fed5817$1((Book)var1);
    }

    private final Object writeReplace() {
        return new SerializedLambda(MpDemo02ApplicationTests.class, "com/baomidou/mybatisplus/core/toolkit/support/SFunction", "apply", "(Ljava/lang/Object;)Ljava/lang/Object;", 6, "org/javaboy/mp_demo02/MpDemo02ApplicationTests", "lambda$test18$3fed5817$1", "(Lorg/javaboy/mp_demo02/model/Book;)Ljava/lang/Object;", "(Lorg/javaboy/mp_demo02/model/Book;)Ljava/lang/Object;", new Object[0]);
    }
}

首先大家注意到 apply 方法生成的就不一樣,apply 裏邊調用了 MpDemo02ApplicationTests.lambda$test18$3fed5817$1 方法,傳入了 Book 對象作爲參數。這個方法內容相當於就是 return book.getId();。然後在 writeReplace 方法中,返回 SerializedLambda 對象的時候,implMethodName 的值就是 lambda$test18$3fed5817$1 了。回到本文一開始的源碼分析中,你會發現這樣的方法名就無法提取出來我們想要的屬性名。所以這種寫法也不對。

從這裏大家也可以看到,類似於 b -> b.getId() 這樣的 Lambda,和方法引用 Book::getId 在底層是不同的。

再給小夥伴們舉個例子,比如下面一段代碼:

public class Demo01 {
    public static void main(String[] args) {
        Consumer<String> out1 = System.out::println;
        out1.accept("javaboy");
        Consumer<String> out2 = s -> System.out.println(s);
        out2.accept("江南一點雨");
    }
}

這裏有兩個輸出,第一個是一個方法引用,第二個則是一個常規的 Lambda 表達式。這兩個執行起來效果是一致的,但是底層原理不同。

先來看第一個底層生成的 Lambda 字節碼:

final class Demo01$$Lambda$14 implements Consumer {
    private final PrintStream arg$1;

    private Demo01$$Lambda$14(PrintStream var1) {
        this.arg$1 = var1;
    }

    public void accept(Object var1) {
        this.arg$1.println((String)var1);
    }
}

可以看到,這裏把 System.out 的值 PrintStream 作爲構造函數的參數傳進來賦值給 arg$1 變量,當調用 accept 方法的時候,再調用 arg$1.println 方法將字符串輸出。

對於第二個底層生成的 Lambda 字節碼如下:

final class Demo01$$Lambda$16 implements Consumer {
    private Demo01$$Lambda$16() {
    }

    public void accept(Object var1) {
        Demo01.lambda$main$0((String)var1);
    }
}

可以看到,這裏有一個新的 lambda$main$0 方法,這個方法的底層邏輯其實就是我們自定義 Lambda 的時候寫的 System.out.println(s)

3. 小結

好啦,一篇小文,和小夥伴們探討下 MP 中 qw.eq(Book::getId, 2); 方法的底層邏輯。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章