基於SPI的增強式外掛框架設計

語言: CN / TW / HK

很久之前,為了診斷線上的問題,就想要是能有工具可以在線上出問題的時候,放個診斷包進去馬上生效,就能看到線上問題的所在,那該是多麼舒服的事情。後來慢慢的切換到java領域後,這種理想也變成了現實,小如IDEA中更改頁面就能馬上生效,大如利用Althas工具進行線上資料診斷,可謂是信手拈來,極大的方便了開發和診斷。後來深入研究之後,就慢慢的不滿足框架本身帶來的便利了,造輪子的想法慢慢在腦中揮之不去,這也是本文產生的原因了。接下來,你無需準備任何前置知識,因為我已經為你準備好了ClassLoader甜點,Javassist配菜,JavaAgent高湯,手寫外掛載入器框架主食,外加SPI知識做調料,且讓我們整理餐具,開始這一道頗有點特色的吃播旅程吧。

雙親委派模型

開始前,讓我們先聊聊雙親委派這個話題,因為無論是做熱部署,還是做位元組碼增強,甚至於日常的編碼,這都是繞不開的一個話題。先看如下圖示:

從如上圖示,我們可以看到雙親委派模型整體的工作方式,整體講解如下:

1. 類載入器的findClass(loadClass)被呼叫

2. 進入App ClassLoader中,先檢查快取中是否存在,如果存在,則直接返回

3. 步驟2中的快取中不存在,則被代理到父載入器,即Extension ClassLoader

4. 檢查Extension ClassLoader快取中是否存在

5. 步驟4中的快取中不存在,則被代理到父載入器,即Bootstrap ClassLoader

6. 檢查Bootstrap ClassLoader快取中是否存在

7. 步驟6中的快取中不存在,則從Bootstrap ClassLoader的類搜尋路徑下的檔案中尋找,一般為rt.jar等,如果找不到,則丟擲ClassNotFound Exception

8. Extension ClassLoader會捕捉ClassNotFound錯誤,然後從Extension ClassLoader的類搜尋路徑下的檔案中尋找,一般為環境變數$JRE_HOME/lib/ext路徑下,如果也找不到,則丟擲ClassNotFound Exception

9. App ClassLoader會捕捉ClassNotFound錯誤,然後從App ClassLoader的類搜尋路徑下的檔案中尋找,一般為環境變數$CLASSPATH路徑下,如果找到,則將其讀入位元組陣列,如果也找不到,則丟擲ClassNotFound Exception。如果找到,則App ClassLoader呼叫defineClass()方法。

通過上面的整體流程描述,是不是感覺雙親委派機制也不是那麼難理解。本質就是先查快取,快取中沒有就委託給父載入器查詢快取,直至查到Bootstrap載入器,如果Bootstrap載入器在快取中也找不到,就拋錯,然後這個錯誤再被一層層的捕捉,捕捉到錯誤後就查自己的類搜尋路徑,然後層層處理。

自定義ClassLoader

瞭解了雙親委派機制後,那麼如果要實現類的熱更換或者是jar的熱部署,就不得不涉及到自定義ClassLoader了,實際上其本質依舊是利用ClassLoader的這種雙親委派機制來進行操作的。遵循上面的流程,我們很容易的來實現利用自定義的ClassLoader來實現類的熱交換功能:

public class CustomClassLoader extends ClassLoader {




//需要該類載入器直接載入的類檔案的基目錄
private String baseDir;

public CustomClassLoader(String baseDir, String[] classes) throws IOException {
super();
this.baseDir = baseDir;
loadClassByMe(classes);
}

private void loadClassByMe(String[] classes) throws IOException {
for (int i = 0; i < classes.length; i++) {
findClass(classes[i]);
}
}

/**
* 重寫findclass方法
*
* 在ClassLoader中,loadClass方法先從快取中找,快取中沒有,會代理給父類查詢,如果父類中也找不到,就會呼叫此使用者實現的findClass方法
*
* @param name
* @return
*/
@Override
protected Class findClass(String name) {
Class clazz = null;
StringBuffer stringBuffer = new StringBuffer(baseDir);
String className = name.replace('.', File.separatorChar) + ".class";
stringBuffer.append(File.separator + className);
File classF = new File(stringBuffer.toString());
try {
clazz = instantiateClass(name, new FileInputStream(classF), classF.length());
} catch (IOException e) {
e.printStackTrace();
}
return clazz;
}

private Class instantiateClass(String name, InputStream fin, long len) throws IOException {
byte[] raw = new byte[(int) len];
fin.read(raw);
fin.close();
return defineClass(name, raw, 0, raw.length);
}
}

這裡需要注意的是,在自定義的類載入器中,我們可以覆寫findClass,然後利用defineClass載入類並返回。

上面這段程式碼,我們就實現了一個最簡單的自定義類載入器,但是能映射出雙親委派模型呢?

首先點開ClassLoader類,在裡面翻到這個方法:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

如果對比著雙親委派模型來看,則loadClass方法對應之前提到的步驟1-8,點進去findLoadedClass方法,可以看到底層實現是native的native final Class<?> findLoadedClass0 方法,這個方法會從JVM快取中進行資料查詢。後面的分析方法類似。

而自定義類載入器中的findClass方法,則對應步驟9:

clazz = instantiateClass(name, new FileInputStream(classF), classF.length());
//省略部分邏輯
return defineClass(name, raw, 0, raw.length);

看看,整體是不是很清晰?

自定義類載入器實現類的熱交換

寫完自定義類載入器,來看看具體的用法吧,我們建立一個類,擁有如下內容:

package com.tw.client;
public class Foo {
public Foo() {
}
public void sayHello() {
System.out.println("hello world22222! (version 11)");
}
}

顧名思義,此類只要呼叫sayHello方法,便會打印出hello world22222! (version 11)出來。

熱交換處理過程如下:

public static void main(String[] args) throws Exception {
while (true) {
run();
Thread.sleep(1000);
}
}
/**
* ClassLoader用來載入class類檔案的,實現類的熱替換
* 注意,需要在swap目錄下,一層層建立目錄com/tw/client/,然後將Foo.class放進去
* @throws Exception
*/
public static void run() throws Exception {
CustomClassLoader customClassLoader = new CustomClassLoader("swap", new String[]{"com.tw.client.Foo"});
Class clazz = customClassLoader.loadClass("com.tw.client.Foo");
Object foo = clazz.newInstance();
Method method = foo.getClass().getMethod("sayHello", new Class[]{});
method.invoke(foo, new Object[]{});
}

當我們執行起來後,我們會將提前準備好的另一個Foo.class來替換當前這個,來看看結果吧(直接將新的Foo.class類拷貝過去覆蓋即可):

hello world22222! (version 11)
hello world22222! (version 11)
hello world22222! (version 11)
hello world22222! (version 11)
hello world22222! (version 11)
hello world2222! (version 2)
hello world2222! (version 2)
hello world2222! (version 2)
hello world2222! (version 2)

可以看到,當我們替換掉原來執行的類的時候,輸出也就變了,變成了新類的輸出結果。整體類的熱交換成功。

不知道我們注意到一個細節沒有,在上述程式碼中,我們先創建出Object的類物件,然後利用Method.invoke方法來呼叫類:

 Object foo = clazz.newInstance();
Method method = foo.getClass().getMethod("sayHello", new Class[]{});
method.invoke(foo, new Object[]{});

有人在這裡會疑惑,為啥不直接轉換為Foo類,然後呼叫類的Foo.sayHello方法呢?像下面這種方式:

Foo foo2 = (Foo) clazz.newInstance();
foo2.sayHello();

這種方式是不行的,但是大家知道為啥不行嗎?

我們知道,我們寫的類,一般都是被AppClassloader載入的,也就是說,你寫在main啟動類中的所有類,只要你寫出來,那麼就會被AppClassloader載入,所以,如果這裡我們強轉為Foo型別,那鐵定是會被AppClassloader載入的,但是由於我們的clazz物件是由CustomerClassloader載入的,所以這裡就會出現這樣的錯誤:

java.lang.ClassCastException: com.tw.client.Foo cannot be cast to com.tw.client.Foo

那有什麼方法可以解決這個問題嗎?其實是有的,就是對Foo物件抽象出一個Interface,比如說IFoo,然後轉換的時候,轉換成介面,就不會有這種問題了:

IFoo foo2 = (IFoo) clazz.newInstance();
foo2.sayHello();

通過介面這種方式,我們就很容易對執行中的元件進行類的熱交換了,屬實方便。

需要注意的是,主執行緒的類載入器,一般都是AppClassLoader,但是當我們創建出子執行緒後,其類載入器都會繼承自其建立者的類載入器,但是在某些業務中,我想在子執行緒中使用自己的類載入器,有什麼辦法嗎?其實這裡也就是打斷雙親委派機制。

由於Thread物件中已經附帶了ContextClassLoader屬性,所以這裡我們可以很方便的進行設定和獲取:

//設定操作
Thread t = Thread.currentThread();
t.setContextClassLoader(loader);
//獲取操作
Thread t = Thread.currentThread();
ClassLoader loader = t.getContextClassLoader();
Class<?> cl = loader.loadClass(className);

SPI實現類的熱交換

說完基於自定義ClassLoader來進行類的熱交換後,我們再來說說Java中的SPI。說到SPI相信大家都聽過,因為在java中天生整合,其內部機制也是利用了自定義的類載入器,然後進行了良好的封裝暴露給使用者,具體的原始碼大家可以自定翻閱ServiceLoader類。

這裡我們寫個簡單的例子:

public interface HelloService {
void sayHello(String name);
}
public class HelloServiceProvider implements HelloService {
@Override
public void sayHello(String name) {
System.out.println("Hello " + name);
}
}
public class NameServiceProvider implements HelloService{
@Override
public void sayHello(String name) {
System.out.println("Hi, your name is " + name);
}
}

然後我們基於介面的包名+類名作為路徑,創建出com.tinywhale.deploy.spi.HelloService檔案到resources中的META-INF.services資料夾,裡面放入如下內容:

com.tinywhale.deploy.spi.HelloServiceProvider
com.tinywhale.deploy.spi.NameServiceProvider

然後在啟動類中執行:

public static void main(String...args) throws Exception {
while(true) {
run();
Thread.sleep(1000);
}
}
private static void run(){
ServiceLoader<HelloService> serviceLoader = ServiceLoader.load(HelloService.class);
for (HelloService helloWorldService : serviceLoader) {
helloWorldService.sayHello("myname");
}
}

可以看到,在啟動類中,我們利用ServiceLoader類來遍歷META-INF.services資料夾下面的provider,然後執行,則輸出結果為兩個類的輸出結果。之後在執行過程中,我們去target資料夾中,將com.tinywhale.deploy.spi.HelloService檔案中的NameServiceProvider註釋掉,然後儲存,就可以看到只有一個類的輸出結果了。

Hello myname
Hi, your name is myname
Hello myname
Hi, your name is myname
Hello myname
Hi, your name is myname
Hello myname
Hello myname
Hello myname
Hello myname

這種基於SPI類的熱交換,比自己自定義載入器更加簡便,推薦使用。

自定義類載入器實現Jar熱部署

上面講解的內容,一般是類的熱交換,但是如果我們需要對整個jar包進行熱部署,該怎麼做呢?雖然現在有很成熟的技術,比如OSGI等,但是這裡我將從原理層面來講解如何對Jar包進行熱部署操作。

由於內建的URLClassLoader本身可以對jar進行操作,所以我們只需要自定義一個基於URLClassLoader的類載入器即可:

public class BizClassLoader extends URLClassLoader {
public BizClassLoader(URL[] urls) {
super(urls);
}
}

注意,我們打的jar包,最好打成fat jar,這樣處理起來方便,不至於少打東西:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.4.3</version>
<configuration>
<!-- 自動將所有不使用的類排除-->
<minimizeJar>true</minimizeJar>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<shadedArtifactAttached>true</shadedArtifactAttached>
<shadedClassifierName>biz</shadedClassifierName>
</configuration>
</execution>
</executions>
</plugin>

之後,我們就可以使用了:

public static void main(String... args) throws Exception {
while (true) {
loadJarFile();
Thread.sleep(1000);
}
}
/**
* URLClassLoader 用來載入Jar檔案, 直接放在swap目錄下即可
*
* 動態改變jar中類,可以實現熱載入
*
* @throws Exception
*/
public static void loadJarFile() throws Exception {
File moduleFile = new File("swap\\tinywhale-client-0.0.1-SNAPSHOT-biz.jar");
URL moduleURL = moduleFile.toURI().toURL();
URL[] urls = new URL[] { moduleURL };
BizClassLoader bizClassLoader = new BizClassLoader(urls);
Class clazz = bizClassLoader.loadClass("com.tw.client.Bar");
Object foo = clazz.newInstance();
Method method = foo.getClass().getMethod("sayBar", new Class[]{});
method.invoke(foo, new Object[]{});
bizClassLoader.close();
}

啟動起來,看下輸出,之後用一個新的jar覆蓋掉,來看看結果吧:

I am bar, Foo's sister, can you catch me ?????????????
I am bar, Foo's sister, can you catch me ?????????????
I am bar, Foo's sister, can you catch me !!!!
I am bar, Foo's sister, can you catch me !!!!
I am bar, Foo's sister, can you catch me !!!!
I am bar, Foo's sister, can you catch me !!!!

可以看到,jar包被自動替換了。當然,如果想解除安裝此包,我們可以呼叫如下語句進行解除安裝:

bizClassLoader.close();

需要注意的是,jar包中不應有長時間執行的任務或者子執行緒等,因為呼叫類載入器的close方法後,會釋放一些資源,但是長時間執行的任務並不會終止。 所以這種情況下,如果你解除安裝了舊包,然後馬上載入新包,且包中有長時間的任務,請確認做好業務防重,否則會引發不可知的業務問題。

由於Spring中已經有對jar包進行操作的類,我們可以配合上自己的annotation實現特定的功能,比如擴充套件點實現,外掛實現,服務檢測等等等等,用途非常廣泛,大家可以自行發掘。

上面講解的基本是原理部分,由於目前市面上有很多成熟的元件,比如OSGI等,已經實現了熱部署熱交換等的功能,所以很推薦大家去用一用。

說到這裡,相信大家對類的熱交換,jar的熱部署應該有初步的概念了,但是這僅僅算是開胃小菜。由於熱部署一般都是和位元組碼增強結合著來用的,所以這裡我們先來大致熟悉一下Java Agent技術。

程式碼增強  技術拾憶

話說在JDK中,一直有一個比較重要的jar包,名稱為rt.jar,他是java執行時環境中,最核心和最底層的類庫的來源。比如java.lang.String, java.lang.Thread, java.util.ArrayList等均來源於這個類庫。今天我們所要講解的角色是rt.jar中的java.lang.instrument包,此包提供的功能,可以讓我們在執行時環境中動態的修改系統中的類,而Java Agent作為其中一個重要的元件,極具特色。

現在我們有個場景,比如說,每次請求過來,我都想把jvm資料資訊或者呼叫量上報上來,由於應用已經上線,無法更改程式碼了,那麼有什麼辦法來實現嗎?當然有,這也是Java Agent最擅長的場合,當然也不僅僅只有這種場合,諸如大名鼎鼎的熱部署JRebel,阿里的arthas,線上診斷工具btrace,UT覆蓋工具JaCoCo等,不一而足。

在使用Java Agent前,我們需要了解其兩個重要的方法:

/**
* main方法執行之前執行,manifest需要配置屬性Premain-Class,引數配置方式載入
*/
public static void premain(String agentArgs, Instrumentation inst);
/**
* 程式啟動後執行,manifest需要配置屬性Agent-Class,Attach附加方式載入
*/
public static void agentmain(String agentArgs, Instrumentation inst);

還有個必不可少的東西是MANIFEST.MF檔案,此檔案需要放置到resources/META-INF資料夾下,此檔案一般包含如下內容:

Premain-class                : main方法執行前執行的agent類.
Agent-class : 程式啟動後執行的agent類.
Can-Redefine-Classes : agent是否具有redifine類能力的開關,true表示可以,false表示不可以.
Can-Retransform-Classes : agent是否具有retransform類能力的開關,true表示可以,false表示不可以.
Can-Set-Native-Method-Prefix : agent是否具有生成本地方法字首能力的開關,trie表示可以,false表示不可以.
Boot-Class-Path : 此路徑會被加入到BootstrapClassLoader的搜尋路徑.

在對jar進行打包的時候,最好打成fat jar,可以減少很多不必要的麻煩,maven加入如下打包內容:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>

而MF配置檔案,可以利用如下的maven內容進行自動生成:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>

工欲善其事必先利其器,準備好了之後,先來手寫個Java Agent嚐鮮吧,模擬premain呼叫,main呼叫和agentmain呼叫。

首先是premain呼叫類 ,agentmain呼叫類,main呼叫類:

//main執行前呼叫
public class AgentPre {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("execute premain method");
}
}
//main主方法入口
public class App {
public static void main(String... args) throws Exception {
System.out.println("execute main method ");
}
}
//main執行後呼叫
public class AgentMain {
public static void agentmain(String agentArgs, Instrumentation inst) {
System.out.println("execute agentmain method");
}
}

可以看到,邏輯很簡單,輸出了方法執行體中列印的內容。之後編譯jar包,則會生成fat jar。需要注意的是,MANIFEST.MF檔案需要手動建立下,裡面加入如下內容:

Manifest-Version: 1.0
Premain-Class: com.tinywhale.deploy.javaAgent.AgentPre
Agent-Class: com.tinywhale.deploy.javaAgent.AgentMain

由於程式碼是在IDEA中啟動,所以想要執行premain,需要在App4a啟動類上右擊:Run App.main(),之後IDEA頂部會出現App的執行配置,我們需要點選Edit Configurations選項,然後在VM options中填入如下命令:

-javaagent:D:\app\tinywhale\tinywhale-deploy\target\tinywhale-deploy-1.0-SNAPSHOT-biz.jar

之後啟動App,就可以看到輸出結果了。注意這裡最好用fat jar,減少出錯的機率。

execute premain method
execute main method

但是這裡的話,我們看不到agentmain輸出,是因為agentmain的執行,是需要進行attach的,這裡我們對agentmain進行attach:

public class App {
public static void main(String... args) throws Exception {
System.out.println("execute main method ");
attach();
}
private static void attach() {
File agentFile = Paths.get("D:\\app\\tinywhale\\tinywhale-deploy\\target\\tinywhale-deploy-1.0-SNAPSHOT.jar").toFile();
try {
String name = ManagementFactory.getRuntimeMXBean().getName();
String pid = name.split("@")[0];
VirtualMachine jvm = VirtualMachine.attach(pid);
jvm.loadAgent(agentFile.getAbsolutePath());
} catch (Exception e) {
System.out.println(e);
}
}
}

啟動app後,得到的結果為:

execute premain method
execute main method
execute agentmain method

可以看到,整個執行都被串起來了。

講到這裡,相信大家基本上理解java agent的執行順序和配置了吧, premain執行需要配置-javaagent啟動引數,而agentmain執行需要attach vm pid。

看到這裡,相信對java agent已經有個初步的認識了吧。接下來,我們就基於Java SPI + Java Agent + Javassist來實現一個外掛系統,這個外掛系統比較特殊的地方,就是可以增強spring框架,使其路徑自動註冊到component-scan路徑中,頗有點霸道(雞賊)的意思。Javassist框架的使用方式,我這裡不細細的展開,感興趣的可以看我翻譯的中文版:javassist中文技術文件

(https://www.cnblogs.com/scy251147/p/11100961.html)

外掛框架  玉汝於成

首先來說下這個框架的主體思路,使用Java SPI來做外掛系統;使用Java Agent來使得外掛可以在main主入口方法前或者是方法後執行;使用Javassist框架來進行位元組碼增強,即實現對spring框架的增強。

針對外掛部分,我們可以定義公共的介面契約:

public interface IPluginExecuteStrategy {
/**
* 執行方法
* @param agentArgs
* @param inst
*/
void execute(String agentArgs, Instrumentation inst);
}

然後針對premain和agentmain,利用策略模式進行組裝如下:

premain處理策略類

public class PluginPreMainExecutor implements IPluginExecuteStrategy{




/**
* 掃描載入的plugin,識別出@PreMainCondition並載入執行
*/
@Override
public void execute(String agentArgs, Instrumentation inst) {
//獲取前置執行集合
List<String> pluginNames = AgentPluginAnnotationHelper.annoProcess(PreMainCondition.class);
ServiceLoader<IPluginService> pluginServiceLoader = ServiceLoader.load(IPluginService.class);
//只執行帶有PreMainCondition的外掛
for (IPluginService pluginService : pluginServiceLoader) {
if (pluginNames.contains(pluginService.getPluginName())) {
pluginService.pluginLoad(agentArgs, inst);
}
}
}
}

agentmain處理策略類

public class PluginAgentMainExecutor implements IPluginExecuteStrategy {




/**
* 掃描載入的plugin,識別出@AgentMainCondition並載入執行
*/
@Override
public void execute(String agentArgs, Instrumentation inst) {
//獲取後置執行集合
List<String> pluginNames = AgentPluginAnnotationHelper.annoProcess(AgentMainCondition.class);
ServiceLoader<IPluginService> pluginServiceLoader = ServiceLoader.load(IPluginService.class);
for (IPluginService pluginService : pluginServiceLoader) {
//只執行帶有AgentMainCondition的外掛
if (pluginNames.contains(pluginService.getPluginName())) {
pluginService.pluginLoad(agentArgs, inst);
}
}
}
}

針對premain和agentmain,執行器工廠如下:

public class AgentPluginContextFactory {
/**
* 建立agent pre執行上下文
* @return
*/
public static PluginExecutorContext makeAgentPreExecuteContext() {
IPluginExecuteStrategy strategy = new PluginPreMainExecutor();
PluginExecutorContext context = new PluginExecutorContext(strategy);
return context;
}




/**
* 建立agent main執行上下文
* @return
*/
public static PluginExecutorContext makeAgentMainExecuteContext() {
IPluginExecuteStrategy strategy = new PluginAgentMainExecutor();
PluginExecutorContext context = new PluginExecutorContext(strategy);
return context;
}


}

編寫Premain-Class和Agent-Class指定的類:

public class AgentPluginPreWrapper {
public static void premain(String agentArgs, Instrumentation inst) {
AgentPluginContextFactory.makeAgentPreExecuteContext().execute(agentArgs, inst);
}


}




public class AgentPluginMainWrapper {
public static void agentmain(String agentArgs, Instrumentation inst) {
AgentPluginContextFactory.makeAgentMainExecuteContext().execute(agentArgs, inst);
}
}

配置檔案中指定相應的類:

Manifest-Version: 1.0
Premain-Class: org.tiny.upgrade.core.AgentPluginPreWrapper
Agent-Class: org.tiny.upgrade.core.AgentPluginMainWrapper
Permissions: all-permissions
Can-Retransform-Classes: true
Can-Redefine-Classes: true

框架搭好後,來編寫外掛部分,外掛的話,需要繼承自org.tiny.upgrade.sdk.IPluginService並實現:

@AgentMainCondition
@Slf4j
public class CodePadPluginServiceProvider implements IPluginService {


@Override
public String getPluginName() {
return "增強外掛";
}


@Override
public void pluginLoad(String agentArgs, Instrumentation inst) {
//獲取已載入的所有類
Class<?>[] classes = inst.getAllLoadedClasses();
if (classes == null || classes.length == 0) {
return;
}
//需要將業務類進行retransform一下,這樣可以避免在transform執行的時候,找不到此類的情況
for (Class<?> clazz : classes) {
if (clazz.getName().contains(entity.getClassName())) {
try {
inst.retransformClasses(clazz);
} catch (UnmodifiableClassException e) {
log.error("retransform class fail:" + clazz.getName(), e);
}
}
}
//進行增強操作
inst.addTransformer(new ByteCodeBizInvoker(), true);
}


@Override
public void pluginUnload() {


}
}

這裡需要注意的是,在外掛load的時候,我們做了class retransform操作,這樣操作的原因是因為,在程式啟動的時候,有時候比如一些類,會在JavaAgent之前啟動,這樣會造成有些類在進行增強的時候,無法處理,所以這裡需要遍歷並操作下,避免意外情況。

下面是具體的增強操作:

@Slf4j
public class ByteCodeBizInvoker implements ClassFileTransformer {
/**
* 在此處載入tprd-ut並利用類載入器載入
*
* @param loader
* @param className
* @param classBeingRedefined
* @param protectionDomain
* @param classfileBuffer
* @return
* @throws IllegalClassFormatException
*/
@Override
public byte[] transform(ClassLoader loader
, String className
, Class<?> classBeingRedefined
, ProtectionDomain protectionDomain
, byte[] classfileBuffer) throws IllegalClassFormatException {
//java自帶的方法不進行處理
if (loader == null) {
return null;
}
//增強spring5的componetscan,將org.tiny路徑塞入
if (className.contains("ComponentScanBeanDefinitionParser")) {
try {
System.out.println("增強spring");
ClassPool classPool = new ClassPool(true);
classPool.appendClassPath(ByteCodeBizInvoker.class.getName());


CtClass ctClass = classPool.get(className.replace("/", "."));
ClassFile classFile = ctClass.getClassFile();
MethodInfo methodInfo = classFile.getMethod("parse");
CtMethod ctMethod = ctClass.getDeclaredMethod("parse");
addComponentScanPackage(methodInfo, ctMethod);
return ctClass.toBytecode();
} catch (Exception e) {
log.error("handle spring 5 ComponentScanBeanDefinitionParser error", e);
}
}
}
/**
* 遍歷method,直至找到ReportTracer標記類
*
* @param ctMethod
*/
private void addComponentScanPackage(MethodInfo methodInfo, CtMethod ctMethod) throws CannotCompileException {
final boolean[] success = {false};
CodeAttribute ca = methodInfo.getCodeAttribute();
CodeIterator codeIterator = ca.iterator();
//行遍歷方法體
while (codeIterator.hasNext()) {
ExprEditor exprEditor = new ExprEditor() {
public void edit(MethodCall m) throws CannotCompileException {
String methodCallName = m.getMethodName();
if (methodCallName.equals("getAttribute")) {
//將org.tiny追加進去
m.replace("{ $_ = $proceed($$); $_ = $_ + \",org.tiny.upgrade\"; }");
success[0] = true;
}
}
};
ctMethod.instrument(exprEditor);
if (success[0]) {
break;
}
}
}
}

從上面可以看出,我們是修改了spring中的ComponentScanBeanDefinitionParser類,並將裡面的parser方法中將org.tiny.upgrade包掃描路徑自動註冊進去,這樣當別人整合我們的框架的時候,就無須掃描到框架也能執行了。

寫到這裡,相信大家對整體框架有個大概的認識了。但是這個框架有個缺陷,就是我的外掛jar寫完後,一定要放到專案的maven dependency中,然後打包部署才行。實際上有時候,我專案上線後,根本就沒有機會重新打包部署,那麼接下來,我們就通過自定義Classloader來讓我們的外掛不僅僅可以本地整合,而且可以從網路中整合。

首先,我們需要定義自定義類載入器:

public class TinyPluginClassLoader extends URLClassLoader {
/**
* 帶參構造
* @param urls
*/
public TinyPluginClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
}
/**
* 新增URL路徑
* @param url
*/
public void addURL(URL url) {
super.addURL(url);
}
}

這個類載入器,是不是很眼熟,和前面講的類似,但是帶了個parent classloader的標記,這是為什麼呢?這個標記的意思是,當前自定義的TinyPluginClassLoader的父classloader是誰,這樣的話,這個自定義類載入器就可以繼承父類載入器中的資訊了,避免出現問題,這個細節大家注意。

這裡需要說明的是,從本地jar檔案載入還是從網路jar檔案載入,本質上是一樣的,因為TinyPluginClassLoader是按照URL來的。

針對於本地jar檔案,我們構造如下URL即可:

URL url = new URL("jar:file:/D:/project/tiny-plugin-hello/target/tiny-plugin-hello-1.0-SNAPSHOT.jar!/")

針對於網路jar檔案,我們構造如下URL即可:

URL url = new URL("jar:http://111.111.111.111/tiny-plugin-hello-1.0-SNAPSHOT.jar!/")

這樣,我們只需要定義好自定義類載入器載入邏輯即可:

  /**
* 從jar檔案中提取出對應的外掛類
*
* @param pluginClass
* @param jarFile
* @return
*/
public static Set<Class> loadPluginFromJarFile(Class pluginClass, JarFile jarFile, TinyPluginClassLoader tinyPluginClassLoader) {
Set<Class> pluginClasses = new HashSet<Class>();
Enumeration<JarEntry> jars = jarFile.entries();
while (jars.hasMoreElements()) {
JarEntry jarEntry = jars.nextElement();
String jarEntryName = jarEntry.getName();
if (jarEntryName.charAt(0) == '/') {
jarEntryName = jarEntryName.substring(1);
}
if (jarEntry.isDirectory() || !jarEntryName.endsWith(".class")) {
continue;
}
String className = jarEntryName.substring(0, jarEntryName.length() - 6);
try {
Class clazz = tinyPluginClassLoader.loadClass(className.replace("/", "."));
if (clazz != null && !clazz.isInterface() && pluginClass.isAssignableFrom(clazz)) {
pluginClasses.add(clazz);
}
} catch (ClassNotFoundException e) {
log.error("PluginUtil.loadPluginFromJarFile fail",e);
}
}
return pluginClasses;
}

之後,我們就可以用如下程式碼對一個具體的jar路徑進行載入就行了:

 /**
* 載入外掛
*
* @return
*/
@Override
public Set<Class> loadPlugins(URL jarURL) {
try {
JarFile jarFile = ((JarURLConnection) jarURL.openConnection()).getJarFile();
getTinyPluginClassLoader().addURL(jarURL);
return PluginUtil.loadPluginFromJarFile(IPluginService.class, jarFile, getTinyPluginClassLoader());
} catch (IOException e) {
log.error("LoadPluginViaJarStrategy.loadPlugins fail", e);
return null;
}
}

最終,我們只需要利用SPI進行動態載入:

  /**
* 執行外掛
*/
public void processPlugins(URL... urls) {
if (urls == null || urls.length == 0) {
log.error("jar url path empty");
return;
}
for (URL url : urls) {
pluginLoadFactory.loadJarPlugins(url);
}
ServiceLoader<IPluginService> serviceLoader = ServiceLoader.load(IPluginService.class, pluginLoadFactory.getPluginLoader());
for (IPluginService pluginService : serviceLoader) {
pluginService.Process();
}
}

這樣,我們不僅實現了外掛化,而且我們的外掛還支援從本地jar檔案或者網路jar檔案載入。由於我們利用了agentmain對程式碼進行增強,所以當系統檢測到我這個jar的時候,下一次執行會重新對程式碼進行增強並生效。

總結

到這裡,我們的用餐進入到尾聲了。也不知道這餐,您享用的是否高興?

其實本文的技術,從雙親委派模型到自定義類載入器,再到基於自定義類載入器實現的類交換,基於Java SPI實現的類交換,最後到基於Java SPI+ Java Agent + Javassist實現的外掛框架及框架支援遠端外掛化,來一步一步的向讀者展示所涉及的知識點。當然,由於筆者知識有限,疏漏之處,還望海涵,真誠期待我的拋磚,能夠引出您的玉石之言。