品Spring:實現bean定義時採用的「先進生產力」

  • 2019 年 10 月 3 日
  • 筆記


前景回顧



當我們把寫好的業務程式碼交給Spring之後,Spring都會做些什麼呢?

仔細想像一下,再稍微抽象一下,Spring所做的幾乎全部都是:

“bean的實例化,bean的依賴裝配,bean的初始化,bean的方法調用,bean的銷毀回收”。

那問題來了,Spring為什麼能夠準確無誤的完成這波對bean的操作呢?答案很簡單,就是:

“Spring掌握了有關bean的足夠多的資訊”。

這就是本系列文章第一篇“帝國的基石”的核心思想。Spring通過bean定義的概念收集到了bean的全部資訊。

這件事也說明,當我們擁有了一個事物的大量有效資訊之後,就可以做出一些非常有價值的操作。如大數據分析,用戶畫像等。

緊接著就是第二個問題,Spring應該採用什麼樣的方式來收集bean的資訊呢?

這就是本系列文章第二篇“bean定義上梁山”主要講的內容。

首先是統一了編程模型,只要是圍繞Spring的開發,包括框架自身的開發,最後大都轉化為bean定義的註冊。

為了滿足不同的場景,Spring提供了兩大類的bean定義註冊方式:

實現指定介面,採用寫程式碼的方式來註冊,這是非常靈活的動態註冊,根據不同的條件註冊不同的bean,主要用於第三方組件和Spring的整合。

標上指定註解,採用註解掃描的方式來註冊,這相當於一種靜態的註冊,非常不靈活,但特別簡單易用,主要用於普通業務程式碼的開發。

Spring設計的這一切,看起來確實完美,用起來也確實很爽,但實現起來呢,也確實的非常麻煩。

尤其是在全部採用註解和Java配置的時候,那才叫一個繁瑣,看看源碼便知一二。

所以本篇及接下來的幾篇都會寫一些和實現細節相關的內容,俗稱“乾貨”,哈哈。


最容易想到的實現方案


一個bean其實就是一個類,所以bean的資訊就是類的資訊。

那一個類都有哪些資訊呢,閉著眼睛都能說出來,共四大類資訊:

類型資訊,類名,父類,實現的介面,訪問控制/修飾符

欄位資訊,欄位名,欄位類型,訪問控制/修飾符

方法資訊,方法名,返回類型,參數類型,訪問控制/修飾符

註解資訊,類上的註解,欄位上的註解,方法上的註解/方法參數上的註解

註:還有內部類/外部類這些資訊,也是非常重要的。

看到這裡腦海中應該立馬蹦出兩個字,沒錯,就是反射

但是,Spring並沒有採用反射來獲取這些資訊,個人認為可能有以下兩個大的原因:

性能損耗問題

要想使用反射,JVM必須先載入類,然後生成對應的Class<?>對象,最後快取起來。

實際的工程可能會註冊較多的bean,但是真正運行時不一定都會用得到。

所以JVM載入過多的類,不僅會耗費較多的時間,還會佔用較多的記憶體,而且載入的類很多可能都不用。

資訊完整度問題

JDK在1.8版本中新增加了一些和反射相關的API,比如和方法參數名稱相關的。此時才能使用反射獲取相對完善的資訊。

但Spring很早就提供了對註解的支援,所以當時的反射並不完善,也可能是通過反射獲取到的資訊並不能完全符合要求。

總之,Spring沒有選擇反射。

那如何獲取類的這些資訊呢?答案應該只剩一種,就是直接從位元組碼文件中獲取。


採用先進的生產力


源碼經過編譯變成位元組碼,所以源碼中有的資訊,在位元組碼中肯定都有。只不過換了一種存在的形式。

Java源碼遵循Java語法規範,生成的位元組碼遵循JVM中的位元組碼規範。

位元組碼文件的結構確實有些複雜,應用程式想要直接從位元組碼中讀出需要的資訊也確實有些困難。

小平同志曾說過,“科學技術是第一生產力”。所以要解決複雜的問題,必須要有比較可靠的技術才行。

對於複雜的位元組碼來說,先進的生產力就是ASM了。ASM是一個小巧快速的Java位元組碼操作框架。

它既可以讀位元組碼文件,也可以寫位元組碼文件。Spring框架主要用它來讀取位元組碼。

ASM框架是採用訪問者模式設計出來的,如果不熟悉這個設計模式的可以閱讀本公眾號上一篇文章“趣說訪問者模式”。

該模式的核心思想就是,訪問者按照一定的規則順序進行訪問,期間會自動獲取到相關資訊,把有用的資訊保存下來即可。

下面介紹一下ASM的具體使用方式,可以看看作為了解,說不定以後會用到。哈哈。

ASM定義了ClassVisitor來獲取類型資訊,AnnotationVisitor來獲取註解資訊,FieldVisitor來獲取欄位資訊,MethodVisitor來獲取方法資訊。

先準備好產生位元組碼的素材,其實就是一個類啦,這個類僅作測試使用,不用考慮是否合理,如下:

@Configuration("ddd")
@ComponentScan(basePackages = {"a.b.c""x.y.z"},
scopedProxy = ScopedProxyMode.DEFAULT,
includeFilters = {@Filter(classes = Integer.class)})
@Ann0(ann1 = @Ann1(name = "ann1Name"))
public class D<@Null T extends Numberextends C<@Valid Long, @NotNull Dateimplements AB {

    protected Long lon = Long.MAX_VALUE;

    private String str;

    @Autowired(required = false)
    private Date date;

    @Resource(name = "aaa", lookup = "bbb")
    private Map<@NotNull String, @Null Object> map;

    @Bean(name = {"cc""dd"}, initMethod = "init")
    public String getStr(@NotNull String sssss, @Null int iiiii, double dddd, @Valid long llll) throws Exception {
        return sssss;
    }

    @Override
    public double getDouble(double d) {
        return d;
    }
}


這個類裡面包含了較為全面的資訊,泛型、父類、實現的介面、欄位、方法、註解等。

按照ASM規定的訪問順序,首先訪問類型資訊,使用ClassVisitor的visit方法,如下:

@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
    log("---ClassVisitor-visit---");
    log("version", version);
    log("access", access);
    log("name", name);
    log("signature", signature);
    log("superName", superName);
    log("interfaces", Arrays.toString(interfaces));
}


這個方法會由ASM框架調用,方法參數的值是框架傳進來的,我們要做的只是在方法內部把這些參數值保存下來就行了。

然後可以按照自己的需求去解析和使用,我這裡只是簡單輸出一下。如下:

//版本資訊,52表示的是JDK1.8
version = 52
//訪問控制資訊,表示的是public class
access = 33
//類型的名稱
name = org/cnt/ts/asm/D
//類型的簽名,依次為,本類的泛型、父類、父類的泛型、實現的介面
signature = <T:Ljava/lang/Number;>Lorg/cnt/ts/asm/C<Ljava/lang/Long;Ljava/util/Date;>;Lorg/cnt/ts/asm/A;Lorg/cnt/ts/asm/B;
//父類型的名稱
superName = org/cnt/ts/asm/C
//實現的介面
interfaces = [org/cnt/ts/asm/A, org/cnt/ts/asm/B]


現在我們已經獲取到了這些資訊,雖然我們並不知道它是如何在位元組碼中存著的,這就是訪問者模式的好處。

類型名稱都是以斜線“/”分割,是因為斜線是路徑分隔符,可以非常方便的拼出完整路徑,從磁碟上讀取.class文件的內容。

還有以大寫“L”開頭後跟一個類型名稱的,這個大寫L表示的是“對象”的意思,後跟的就是對象的類型名稱,說白了就是類、介面、枚舉、註解等這些。

接著訪問的是類型上標的註解,使用ClassVisitor的visitAnnotation方法,如下:

@Override
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) 
{
    log("---ClassVisitor-visitAnnotation---");
    log("descriptor", descriptor);
    log("visible", visible);
    return new _AnnotationVisitor();
}


需要說明的是,這個方法只能訪問到註解的類型資訊,註解的屬性資訊需要使用AnnotationVisitor去訪問,也就是這個方法的返回類型。

類上標有@Configuration(“ddd”),所以輸出結果如下:

//類型描述/名稱
descriptor = Lorg/springframework/context/annotation/Configuration;
//這個是可見性,表明在運行時可以獲取到註解的資訊
visible = true


然後使用AnnotationVisitor去訪問顯式設置過的註解屬性資訊,使用visit方法訪問基本的資訊,如下:

@Override
public void visit(String name, Object value) 
{
    log("---AnnotationVisitor-visit---");
    log("name", name);
    log("value", value);
}


實際上我們是把ddd設置給了註解的value屬性,所以結果如下:

//屬性名稱,是value
name = value
//屬性值,是ddd
value = ddd


至此,@Configuration註解已經訪問完畢。

然後再訪問@ComponentScan註解,同樣使用ClassVisitor的visitAnnotation方法,和上面的那個一樣。

得到的結果如下:

descriptor = Lorg/springframework/context/annotation/ComponentScan;
visible = true


然後使用AnnotationVisitor去訪問設置過的註解屬性資訊,使用visitArray方法訪問數組類型的資訊,如下:

@Override
public AnnotationVisitor visitArray(String name) 
{
    log("---AnnotationVisitor-visitArray---");
    log("name", name);
    return new _AnnotationVisitor();
}


這個方法只能訪問到數組類型屬性的名稱,結果如下:

name = basePackages


屬性的值還是使用基本的visit方法去訪問,因為數組的值是多個,所以visit方法會多次調用,按順序依次獲取數組的每個元素值。

因數組有兩個值,所以方法調用兩次,結果如下:

name = null
value = a.b.c

name = null
value = x.y.z


因為數組的值沒有名稱,所以name總是null。value的值就是數組的元素值,按先後順序保存在一起即可。

然後由於註解的下一個屬性是枚舉類型的,所以使用visitEnum方法來訪問,如下:

@Override
public void visitEnum(String name, String descriptor, String value) {
    log("---AnnotationVisitor-visitEnum---");
    log("name", name);
    log("descriptor", descriptor);
    log("value", value);
}


結果如下:

//註解的屬性名稱,是scopedProxy
name = scopedProxy
//枚舉類型,是ScopedProxyMode
descriptor = Lorg/springframework/context/annotation/ScopedProxyMode;
//屬性的值,是我們設置的DEFAULT
value = DEFAULT


然後繼續訪問數組類型的屬性,使用visitArray方法訪問。

得到的結果如下:

name = includeFilters


接下來該獲取數組的元素了,由於這個數組元素的類型也是一個註解,所有使用visitAnnotation方法訪問,如下:

@Override
public AnnotationVisitor visitAnnotation(String name, String descriptor) 
{
    log("---AnnotationVisitor-visitAnnotation---");
    log("name", name);
    log("descriptor", descriptor);
    return new _AnnotationVisitor();
}


得到的結果如下:

name = null
//註解類型名稱
descriptor = Lorg/springframework/context/annotation/ComponentScan$Filter;


可以看到這個註解是@ComponentScan內部的@Filter註解。這個註解本身是作為數組元素的值,所以name是null,因為數組元素是沒有名稱的。

然後再訪問@Filter這個註解的屬性,得到屬性名稱如下:

name = classes


屬性值是一個數組,它只有一個元素,如下:

name = null
value = Ljava/lang/Integer;


,程式碼較多,不再貼了,只給出結果的解析。

下面是map類型的那個欄位的結果,如下:

//訪問控制,private
access = 2
//欄位名稱
name = map
//欄位類型
descriptor = Ljava/util/Map;
//欄位類型簽名,包括泛型資訊
signature = Ljava/util/Map<Ljava/lang/String;Ljava/lang/Object;>;
value = null


該欄位上標了註解,結果如下:

descriptor = Ljavax/annotation/Resource;
visible = true


並且設置了註解的兩個屬性,結果如下:

name = name
value = aaa

name = lookup
value = bbb


由於編譯器會生成默認的無參構造函數,所以會有如下:

//訪問控制,public
access = 1
//對應於構造函數名稱
name = <init>
//方法沒有參數,返回類型是void
descriptor = ()V
signature = null
exceptions = null


這有一個定義的方法結果,如下:

//public
access = 1
//方法名稱
name = getStr
//方法參數四個,分別是,String、int、double、long,返回類型是String
descriptor = (Ljava/lang/String;IDJ)Ljava/lang/String;
signature = null
//拋出Exception異常
exceptions = [java/lang/Exception]


參數裡面的大寫字母I表示int,D表示double,J表示long,都是基本數據類,要記住不是包裝類型。

方法的四個參數名稱,依次分別是:

//參數名稱
name = sssss
//參數訪問修飾,0表示沒有修飾
access = 0

name = iiiii
access = 0

name = dddd
access = 0

name = llll
access = 0


由於方法上標有註解,結果如下:

descriptor = Lorg/springframework/context/annotation/Bean;
visible = true


數組類型的屬性名稱,如下:

name = name


屬性值有兩個,如下:

name = null
value = cc

name = null
value = dd


簡單類型的屬性值,如下:

name = initMethod
value = init


由於方法的其中三個參數上也標了註解,結果如下:

//參數位置,第0個參數
parameter = 0
//註解類型名稱,@NotNull
descriptor = Ljavax/validation/constraints/NotNull;
//可見性,運行時可見
visible = true

parameter = 1
descriptor = Ljavax/validation/constraints/Null;
visible = true

parameter = 3
descriptor = Ljavax/validation/Valid;
visible = true


以上這些只是部分的輸出結果。完整示例程式碼參見文章末尾,可以自己運行一下仔細研究研究。


結尾總結


在業務開發中直接使用ASM的情況肯定較少,一般在框架開發或組件開發時可能會用到。

ASM的使用並不是特別難,多做測試即可發現規律。

我在測試時發現兩個值得注意的事情:

只能訪問到顯式設置註解屬性的那些值,對於註解的默認屬性值是訪問不到的。

要想獲取到註解的默認值,需要去訪問註解自己的位元組碼文件,而不是使用註解的類的位元組碼文件。

只能訪問到類型自己定義的資訊,從父類型繼承的資訊也是訪問不到的。

也就是說,位元組碼中只包括在源碼文件中出現的資訊,位元組碼本身不處理繼承問題。

因此,JVM在載入一個類型時,要載入它的父類型,並處理繼承問題。


完整示例程式碼
https://github.com/coding-new-talking/taste-spring.git

(END)


作者是工作超過10年的碼農,現在任架構師。喜歡研究技術,崇尚簡單快樂。追求以通俗易懂的語言解說技術,希望所有的讀者都能看懂並記住。下面是公眾號和知識星球的二維碼,歡迎關注!