Java 自定義 ClassLoader 實現隔離執行不同版本jar包的方式
1. 應用場景
有時候我們需要在一個 Project 中執行多個不同版本的 jar 包,以應對不同叢集的版本或其它的問題。如果這個時候選擇在同一個專案中實現這樣的功能,那麼通常只能選擇更低版本的 jar 包,因為它們通常是向下相容的,但是這樣也往往會失去新版本的一些特性或功能,所以我們需要以擴充套件的方式引入這些 jar 包,並通過隔離執行,來實現版本的強制對應。
2. 實現
在 Java 中,所有的類預設通過 ClassLoader 載入,而 Java 預設提供了三層的 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-V1
和 Executor-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
和反射來實現載入與執行 ExecutorV1
和 ExecutorV2
中 Executor
介面的實現,而 ExecutorV1
和 ExecutorV2
將以 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. 執行
將ExecutorV1
和 ExecutorV2
分別打包,並將其打包後的 jar包與其依賴(lib目錄下)放入 Executor-Proxy
專案的 ext\v1
和 ext\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 相關包時。
當然,這只是一種解決的方式,我們仍然可以使用微服務來達到同樣甚至更棒的效果,
以上。