1. 程式人生 > >Java 自定義 ClassLoader 實現隔離執行不同版本jar包的方式

Java 自定義 ClassLoader 實現隔離執行不同版本jar包的方式

1. 應用場景

有時候我們需要在一個 Project 中執行多個不同版本的 jar 包,以應對不同叢集的版本或其它的問題。如果這個時候選擇在同一個專案中實現這樣的功能,那麼通常只能選擇更低版本的 jar 包,因為它們通常是向下相容的,但是這樣也往往會失去新版本的一些特性或功能,所以我們需要以擴充套件的方式引入這些 jar 包,並通過隔離執行,來實現版本的強制對應。

2. 實現

在 Java 中,所有的類預設通過 ClassLoader 載入,而 Java 預設提供了三層的 ClassLoader,並通過雙親委託模型的原則進行載入,其基本模型與載入位置如下(更多ClassLoader相關原理請自行搜尋):

ClassLoader

Java 中預設的 ClassLoader 都規定了其指定的載入目錄,一般也不會通過 JVM 引數來使其載入自定義的目錄,所以我們需要自定義一個 ClassLoader 來載入裝有不同版本的 jar 包的擴充套件目錄,同時為了使執行擴充套件的 jar 包時,與啟動專案實現絕對的隔離,我們需要保證他們所載入的類不會有相同的 ClassLoader,根據雙親委託模型的原理可知,我們必須使自定義的 ClassLoader 的 parent 為 null,這樣不管是 JRE 自帶的 jar 包或一些基礎的 Class 都不會委託給 App ClassLoader(當然僅僅是將 Parent 設定為 null 是不夠的,後面會說明)。與此同時這些實現了不同版本的 jar 包,是經過二次開發後的可以獨立執行的專案。

2.1 例項

現在假定有這樣一個需求,實現針對叢集(比如 Hadoop 叢集)版本為 V1 與 V2 的對應的執行程式,那麼假定有如下專案:

Executor-Parent: 提供基礎的 Maven 引用,可利用 Maven 一鍵打包所有的子模組/專案
Executor-Common: 提供基礎的介面,已經有公有的實現等
Executor-Proxy: 執行不同版本程式的代理程式
Executor-V1: 版本為V1的執行程式
Executor-V2: 版本為V2的執行程式

這裡為了更凸顯 ClassLoader 的實現,不做 Executor-Parent 的實現,同時為了簡便,也沒有設定包名。

1) Executor-Common

Executor-Common 中提供一個介面,宣告執行的具體方法:

public interface Executor {
    void execute(final String name);
}

這裡的方法使用了基礎型別 String,實際中可能會使用自定義的型別,那麼在 Porxy 的實現中則需要使用自定義的 ClassLoader 來載入引數,並使用反射來獲取方法(後面會有一個簡單的示例)。回到之前的示例,這裡同時提供一個抽象的實現類:

public class AbstractExecutor implements Executor {

    @Override
    public void execute(final String name) {
        this.handle(new Handler() {
            @Override
            public void handle() {
                System.out.println("V:" + name);
            }
        });
    }

    protected void handle(Handler handler) {
        handler.call();
    }

    protected abstract class Handler {
        public void call() {
            ClassLoader oldClassLoader = Thread.currentThread().getContextClassLoader();
            // 臨時更改 ClassLoader
            Thread.currentThread().setContextClassLoader(AbstractExecutor.class.getClassLoader());

            handle();

            // 還原為之前的 ClassLoader
            Thread.currentThread().setContextClassLoader(oldClassLoader);
        }

        public abstract void handle();
    }
}

這裡需要臨時更改當前執行緒的 ContextClassLoader, 以應對擴充套件程式中可能出現的如下程式碼:

ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

classLoader.loadClass(...);

因為它們會獲取當前執行緒的 ClassLoader 來載入 class,而當前執行緒的ClassLoader極可能是App ClassLoader而非自定義的ClassLoader, 也許是為了安全起見,但是這會導致它可能載入到啟動專案中的class(如果有),或者發生其它的異常,所以我們在執行時需要臨時的將當前執行緒的ClassLoader設定為自定義的ClassLoader,以實現絕對的隔離執行。

2) Executor-V1 & Executor-V2

Executor-V1Executor-V2 依賴了 Executor-Common.jar,並實現了 Executor 介面的方法:

public class ExecutorV1 extends AbstractExecutor {

    @Override
    public void execute(final String name) {
        this.handle(new Handler() {
            @Override
            public void handle() {
                System.out.println("V1:" + name);
            }
        });
    }

}
public class ExecutorV2 extends AbstractExecutor {

    @Override
    public void execute(final String name) {
        this.handle(new Handler() {
            @Override
            public void handle() {
                System.out.println("V2:" + name);
            }
        });
    }

}

這裡僅僅是列印了它們的版本資訊,實際中,它們可能需要引入不同的版本的 Jar 包,然後根據這些 Jar 包完成相應的操作。

3) Executor-Proxy

Executor-Proxy 利用自定義的 ClassLoader 和反射來實現載入與執行 ExecutorV1ExecutorV2Executor 介面的實現,而 ExecutorV1ExecutorV2 將以 jar 包的形式被分別放置在 ${Executor-Proxy_HOME}\ext\v1${Executor-Proxy_HOME}\ext\v2 目錄下,其中自定義的 ClassLoader 實現如下:

public class StandardExecutorClassLoader extends URLClassLoader {
    private final static String baseDir = System.getProperty("user.dir") + File.separator + "ext" + File.separator;

    public StandardExecutorClassLoader(String version) {
        super(new URL[] {}, null); // 將 Parent 設定為 null

        loadResource(version);
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 測試時可列印看一下
        System.out.println("Class loader: " + name);

        return super.loadClass(name);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            return super.findClass(name);
        } catch(ClassNotFoundException e) {
            return StandardExecutorClassLoader.class.getClassLoader().loadClass(name);
        }
    }

    private void loadResource(String version) {
        String jarPath = baseDir + version;

        // 載入對應版本目錄下的 Jar 包
        tryLoadJarInDir(jarPath);
        // 載入對應版本目錄下的 lib 目錄下的 Jar 包
        tryLoadJarInDir(jarPath + File.separator + "lib");
    }

    private void tryLoadJarInDir(String dirPath) {
        File dir = new File(dirPath);
        // 自動載入目錄下的jar包
        if (dir.exists() && dir.isDirectory()) {
            for (File file : dir.listFiles()) {
                if (file.isFile() && file.getName().endsWith(".jar")) {
                    this.addURL(file);
                    continue;
                }
            }
        }
    }

    private void addURL(File file) {
        try {
            super.addURL(new URL("file", null, file.getCanonicalPath()));
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

StandardExecutorClassLoader 在例項化時,會自動載入擴充套件目錄下與其lib目錄下的 jar 包,這裡之所以要載入 lib 目錄下的 jar,是為了載入擴充套件的依賴包。

有了StandardExecutorClassLoader,我們還需要一個呼叫各版本程式的代理類ExecutorPorxy,其實現如下:

import java.lang.reflect.Method;

public class ExecutorProxy implements Executor {
    private String version;
    private StandardExecutorClassLoader classLoader;

    public ExecutorProxy(String version) {
        this.version = version;
        classLoader = new StandardExecutorClassLoader(version);
    }

    @Override
    public void execute(String name) {
        try {
            // Load ExecutorProxy class
            Class<?> executorClazz = classLoader.loadClass("Executor" + version.toUpperCase());

            Object executorInstance = executorClazz.newInstance();
            Method method = executorClazz.getMethod("execute", String.class);

            method.invoke(executorInstance, name);
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}

這裡是一個比較簡單的實現,因為通過反射呼叫的方法的引數是基本型別,在實際中,更多的可能是自定義的引數,那麼這時候則需要先通過自定義的 ClassLoader 載入其 Class,然後才能去獲取對應的方法,下面是一個省去上下文的一個例子(不能直接執行):

public void call() throws IOException {
    try {
        // Load HBaseApi class
        Class<?> hbaseApiClazz = loadHBaseApiClass();
        Object hbaseApiInstance = hbaseApiClazz.newInstance();

        // Load parameter class
        Class<?> paramClazz = classLoader.loadClass(VO_PACKAGE_PATH + "." + sourceParame.getClass().getSimpleName());

        // Transition parameter to targeParameter from sourceParameter 
        Object targetParam = BeanUtils.transfrom(paramClazz, sourceParame);

        // Get function
        Method method = hbaseApiClazz.getMethod(methodName, paramClazz);
        // Invoke function by targetParam
        method.invoke(hbaseApiInstance, targetParam);

    } catch(ClassNotFoundException | NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException e) {
        e.printStackTrace();
    } catch (IllegalArgumentException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    } catch (InvocationTargetException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    }
}

3. 執行

ExecutorV1ExecutorV2分別打包,並將其打包後的 jar包與其依賴(lib目錄下)放入 Executor-Proxy 專案的 ext\v1ext\v2 目錄下,在 Executor-Proxy 專案中則可以使用 Junit 進行測試:

public class ExecutorTest {

    @Test
    public void testExecuteV1() {

        Executor executor = new ExecutorProxy("v1");

        executor.execute("TOM");
    }

    @Test
    public void testExecuteV2() {

        Executor executor = new ExecutorProxy("v2");

        executor.execute("TOM");
    }

}

列印結果最終分別如下:

execute testExecuteV1():

V1:TOM
execute testExecuteV2():

V2:TOM

4. 總結

總的來說,實現隔離允許指定 jar 包,主要需要做到以下幾點:

  • 自定義 ClassLoader,使其 Parent = null,避免其使用系統自帶的 ClassLoader 載入 Class。
  • 在呼叫相應版本的方法前,更改當前執行緒的 ContextClassLoader,避免擴充套件包的依賴包通過Thread.currentThread().getContextClassLoader()獲取到非自定義的 ClassLoader 進行類載入
  • 通過反射獲取 Method 時,如果引數為自定義的型別,一定要使用自定義的 ClassLoader 載入引數獲取 Class,然後在獲取 Method,同時引數也必須轉化為使用自定義的 ClassLoade 載入的型別(不同 ClassLoader 載入的同一個類不相等)

實際運用中,往往容易做到第一點或第三點,而忽略第二點,比如使用 HBase 相關包時。

當然,這只是一種解決的方式,我們仍然可以使用微服務來達到同樣甚至更棒的效果,

以上。