Java進階知識點8:高可擴展架構的利器 - 動態模塊加載核心技術(ClassLoader、反射、依賴隔離)

1、背景

  功能模塊化是實現系統能力高可擴展性的常見思路。而模塊化又可分爲靜態模塊化和動態模塊化兩類:java

  1. 靜態模塊化:指在編譯期能夠經過引入新的模塊擴展系統能力。好比:經過maven/gradle引入一個依賴(本質是一組jar文件)。sql

  2. 動態模塊化:指在JVM運行期能夠經過引入新的模塊擴展系統能力。好比:利用OSGI系統引入某個bundle(本質是一個jar文件),或者本身利用JDK提供的能力,將某個jar文件中的能力動態加載到運行時環境中。數據庫

  靜態模塊化你們使用的比較多,也比較熟悉,因此本文重點介紹動態模塊化。緩存

  固然本文的重點不在於闡述如何搭建一個OSGI系統,畢竟這些內容不是一篇文章能夠表述詳盡的,且這些技術不見得是任何規模的項目都適合使用的。當你瞭解實現動態模塊化所必備的基礎技術知識後,相信你也能夠很快用本身的方式在本身的特定項目中實現適當程度的動態模塊化功能,這是本文但願達到的效果。架構

2、設計本身的插件包

2.1 設計插件抽象接口

  所謂插件機制,即容許系統中的某種抽象能力能夠有不一樣的實現,且容許將這些實現存放於系統外部的插件包中,而不是固化在系統內部。app

  這個系統中的抽象能力,即插件所需的抽象接口。抽象接口能夠是Java中的interface或abstract class,甚至是可被重寫函數的普通class。抽象接口做爲業務系統與插件之間的橋樑,一般存放於一個獨立的common工程中,系統工程和插件工程都會依賴這個common工程。以下圖所示:maven

                           

    業務系統使用common工程中的抽象接口,調用接口函數,完成抽象能力的執行。插件工程負責實現common工程中的抽象接口,完成抽象能力的具體實現。將抽象接口獨立存放在common工程中,而不是直接存在於系統工程中,是爲了不插件工程依賴整個系統工程,這樣會致使插件與系統的緊耦合風險。畢竟做爲一款插件的實現方而言,不須要知道系統工程中的內容,這樣也不會存在因系統工程的改動而破壞插件工程邏輯的可能性。模塊化

2.2 建立插件工程

  建立插件工程,讓此插件工程依賴common工程。函數

  同時插件工程中還能夠按需添加實現本工程所需的其餘第三方依賴。工具

2.3 實現插件功能

  在插件工程中新建插件實現類,該類負責實現插件抽象接口。

  記住實現類的徹底限定名(例如:com.a.b.c.ConcretePlugin),後續實例化插件包中的類對象時,須要此徹底限定名。

2.4 構建插件工程,輸出插件包

  使用構建工具Gradle/Maven等構建插件工程,構建成功後獲得的jar輸出目錄(好比Gradle默認的build/libs目錄)中的全部內容(即全部jar包,包括第三方依賴的jar包),即爲插件包所需內容。

  爲插件包創建一個單獨的文件夾,將插件工程輸出的全部jar文件拷貝至此文件夾下,該文件夾即爲插件包文件夾。  插件包文件夾中的jar文件能夠所有存在文件夾頂層目錄下,也能夠在插件包文件夾下新建子文件夾分門別類存放(好比第三方依賴的jar文件單獨存在於lib子目錄下),均不影響後續對插件內容的加載。

  爲了傳輸和儲存方便,插件包文件夾能夠壓縮打包成一個獨立的文件,使用時再解壓便可。

3、實例化插件包中的類對象

3.1 建立並緩存ClassLoader

  當系統工程中須要動態引用某個插件的能力時,需首先爲每一個插件建立獨立的ClassLoader(緣由參見下節內容《隔離不一樣插件包中的依賴衝突》)。

  ClassLoader可使用URLClassLoader,代碼以下:

public void exmaple(String[] args) {
    // 獲取插件包文件夾下的全部jar文件的URL,後續建立的ClassLoader將在這些URL中去尋找所需類
    URL[] urls = getUrls(new File("/plugins/pluginA/"));
    // 將加載當前類的ClassLoader做爲新建立ClassLoader的父ClassLoader
    ClassLoader classLoader = new URLClassLoader(urls, this.getClass().getClassLoader());
}

private URL[] getUrls(File dir) {
    List<URL> results = new ArrayList<>();
    try {
        // 遍歷插件文件頂層目錄下的全部jar文件
        Files.newDirectoryStream(dir.toPath(), "*.jar")
                .forEach(path -> results.add(getUrl(path)));
        // 遍歷插件文件夾/lib子目錄下的全部jar文件。若是還有其餘子目錄,同理一塊兒遍歷
        Files.newDirectoryStream(Paths.get(dir.getAbsolutePath(), "lib"), "*.jar")
                .forEach(path -> results.add(getUrl(path)));
    } catch (IOException e) {
        throw new RuntimeException(e.getMessage(), e);
    }
    return results.toArray(new URL[0]);
}

private URL getUrl(Path path) {
    try {
        return path.toUri().toURL();
    } catch (MalformedURLException e) {
        throw new RuntimeException(e.getMessage(), e);
    }
}

  上述代碼中將加載當前類的ClassLoader做爲插件ClassLoader的父ClassLoader,是爲了保證系統工程和插件工程中同時使用到的抽象接口的構造函數的參數被同一個ClassLoader加載,不然後續反射獲取插件構造函數時,可能會提示找不到指定的構造函數。具體緣由下面3.3節中會詳細說明。

  因爲建立ClassLoader有必定開銷,爲了提高性能,能夠將建立好的的ClassLoader緩存起來,下次相同插件須要時,直接從緩存中取與之對應的ClassLoader對象便可。

3.2 加載Class

  使用插件實現類的徹底限定名加載插件實現類的Class對象,代碼以下:  

Class pluginClass = classLoader.loadClass("com.a.b.c.ConcretePlugin");

3.3 反射得到構造函數,並調用構造函數

  使用反射機制,獲取插件實現類的構造函數,並經過調用此構造函數,實例化插件實現類對象,代碼以下:

// 無參數的構造函數
Object pluginA = pluginClass.getConstructor().newInstance();

// 有參數的構造函數
Object pluginB = pluginClass.getConstructor(ParamA.class, ParamB.class).newInstance(new ParamA(), new ParamB());

  從上述代碼能夠看出,若是構造函數帶參數,那麼插件工程中需先準備好所需參數,包括參數類型的class對象和參數對象自己,這些都會致使插件構造參數此時首先被系統工程中的加載插件代碼類的類加載器(假設爲classLoaderA)加載了。若是classLoaderA又不是插件類加載器(假設爲pluginClassLoader)的父加載器,意味着pluginClassLoader加載獲得的pluginClass中,插件構造參數極可能就不是classLoaderA加載的了。JVM中,只有被同一個類加載器加載的相同徹底限定名的類,纔會真正被認爲是相同的類,因此此時極可能出現JVM認爲插件類中的構造參數與上述代碼中反射所查找的構造參數不一致,從而拋出沒法找到構造參數的異常。因此咱們在3.1中才會爲URLClassLoader顯示設置父類加載器。

  上述說明涉及類加載器的雙親委派機制,網上優秀的介紹文章不少,本文再也不贅述。

3.4 將構造獲得的Object轉換爲插件抽象接口類型

  上一個獲得的插件類實例類型爲Object,還不能正常使用,需將其轉換爲插件抽象接口類型,這樣系統工程中就能夠經過調用抽象接口中的方法,引用插件實現的具體能力了。代碼以下:

ConcretePlugin plugin = (ConcretePlugin) pluginObject;

4、隔離不一樣插件包中的依賴衝突

4.1 「Jar Hell」問題對插件架構的影響

  Jar Hell問題引發的緣由是當某個ClassLoader的Jar搜索路徑中的兩個Jar包裏存在相同徹底限定名的類時,ClassLoader只會從其中一個Jar包中加載該類。而不一樣人編寫的Jar,類的徹底限定名是可能重複的,即使是同一我的編寫的Jar,其不一樣版本的實現也使用的是相同的徹底限定名。當這些徹底限定名相同,但實現不一樣的Class所在的Jar包被做爲第三方依賴同時引入到某個類加載器的Jar搜索路徑下時(好比AppClassLoader的搜索路徑爲ClassPath),依賴衝突就產生了,並且難以解決。

  在插件架構中,Jar Hell問題出現的機率可能更高,緣由有以下幾點:

  1. 由於基於相同插件抽象接口實現的不一樣插件類,其業務功能原本就有必定的類似性,不一樣人爲各自插件類取的類名衝突的可能性較大。

  2. 由於不一樣插件功能的類似性,他們可能存在相同依賴的可能性更大,而不一樣插件的開發人員可能選用的依賴版本並不相同,不一樣版本的依賴實現徹底不一樣甚至互相不兼容。好比不一樣插件負責從不一樣數據庫中讀取數據,而HBase0.9.4.x與HBase1.1.x的驅動實現不一樣,但驅動類的徹底限定名相同,因此HBase0.9.4.x的讀取插件和HBase1.1.x的讀取插件所依賴的Jar包存在依賴衝突。

4.2 「Jar Hell」問題的解決思路

  解決Jar Hell問題的核心思想就是,爲不一樣的插件建立獨立的ClassLoader,從根本上杜絕各插件引入的可能衝突的Jar包在同一個ClassLoader的Jar搜索路徑下。

  這樣作後,彷佛每一個插件下的全部類都會被其獨享的ClassLoader加載,可是這裏存在兩個意外,一個來自於雙親委派機制,一個來自於線程上下文類加載器。

  因爲Java的ClassLoader默認採用雙親委派機制,即本身加載某個Class時,優先讓本身的父加載器去加載,若是父加載器沒法加載,再嘗試本身加載。因此,雖然每一個插件都有本身的ClassLoader,可是它們存在相同的父ClassLoader(即3.1中設置的父ClassLoader),而這個父ClassLoader將負責搜索並加載系統工程引入的依賴Jar,也就是說系統工程所引入的Jar包,可能與插件包引入的Jar包存在衝突的可能。

  對於第一個意外,本文采用了一種很簡單的解決思路,即規範插件包的實現,插件包中儘可能不要引入可能與系統工程依賴的Jar包存在版本衝突的Jar包,畢竟全部插件的實現方只需與一個系統工程兼容便可,插件與插件之間不用去關注其餘插件引入了哪些Jar包,後者纔是最麻煩的事情。

  通常狀況下,一個類所引用的其餘類將默認用本類的類加載器加載,因此一般狀況下,當咱們用pluginClassLoader去loadClass後,隨着抽象接口的實例化和方法調用,實現此抽象接口的全部其餘輔助類或第三方依賴類都會用依次按需被pluginClassLoader加載。可是,在某些特定狀況下,Java會使用線程上下文類加載器去加載所需的Class,而此時線程上下文類加載器並非pluginClassLoader。(關於使用線程上下文加載器的一個典型例子:java.sql包下的JDBC相關代碼,會使用線程上下文類加載器去加載實際的JDBC驅動中的代碼,由於java.sql屬於Java核心庫內容,裏面的類被引導類加載器加載,可是引導類加載器的Jar搜索路徑也僅限於Java核心庫,因此引導類加載器是沒法加載存放在ClassPath下的各個廠商實現的JDBC驅動的。)

  對於第二個意外,須要咱們加載插件前,手動去替換線程上下文類加載器,同時當本線程執行完插件行爲(即調用完插件抽象接口中定義的方法)後,還原上下文類加載器,以便本線程後續調用的與插件無關的代碼不會受到影響。

  能夠單獨實現一個線程上下文類加載器替換器完成線程上下文類加載器的替換和還原,代碼以下:

public class ThreadContextClassLoaderSwapper {
    private static final ThreadLocal<ClassLoader> classLoader = new ThreadLocal<>();

    // 替換線程上下文類加載器會指定的類加載器,並備份當前的線程上下文類加載器
    public static void replace(ClassLoader newClassLoader) {
        classLoader.set(Thread.currentThread().getContextClassLoader());
        Thread.currentThread().setContextClassLoader(newClassLoader);
    }

    // 還原線程上下文類加載器
    public static void restore() {
        if (classLoader.get() == null) {
            return;
        }
        Thread.currentThread().setContextClassLoader(classLoader.get());
        classLoader.set(null);
    }
}

5、總結

本文介紹了一種簡單易行的動態模塊化實現方案:

1. 設計插件抽象接口,做爲系統工程和插件工程的橋樑。

2. 使用URLClassLoader動態從外部Jar文件中查找插件實現類。

3. 使用反射機制從Class文件中查找構造函數並實例化插件實現類,將實例類型轉換後,即可以直接調用插件實現類實現的抽象接口函數。

4. 爲了儘量緩解Jar Hell問題對插件架構的影響,爲每一個插件分配獨立的類加載器pluginClassLoaderA,且在使用插件期間,保證當前線程上線文類加載器也是pluginClassLoaderA。

相關文章
相關標籤/搜索