品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 Number> extends C<@Valid Long, @NotNull Date> implements A, B {
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年的碼農,現在任架構師。喜歡研究技術,崇尚簡單快樂。追求以通俗易懂的語言解說技術,希望所有的讀者都能看懂並記住。下面是公眾號和知識星球的二維碼,歡迎關注!