基於 SPI 的增強式插件框架設計

導讀

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

01 雙親委派模型

在今年的敏捷團隊建設中,我通過 Suite 執行器實現了一鍵自動化單元測試。Juint 除了 Suite 執行器還有哪些執行器呢?由此我的 Runner 探索之旅開始了!

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

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

  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 加載器在緩存中也找不到,就拋錯,然後這個錯誤再被一層層的捕捉,捕捉到錯誤後就查自己的類搜索路徑,然後層層處理。

02 自定義 ClassLoader

理解,首先 MCube 會依據模板緩存狀態判斷是否需要網絡獲取最新模板,當獲取到模板後進行模板加載,加載階段會將產物轉換爲視圖樹的結構,轉換完成後將通過表達式引擎解析表達式並取得正確的值,通過事件解析引擎解析用戶自定義事件並完成事件的綁定,完成解析賦值以及事件綁定後進行視圖的渲染,最終將目標頁面展示到屏幕

瞭解了雙親委派機制後,那麼如果要實現類的熱更換或者是 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);

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

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

理解,首先 MCube 會依據模板緩存狀態判斷是否需要網絡獲取最新模板,當獲取到模板後進行模板加載,加載階段會將產物轉換爲視圖樹的結構,轉換完成後將通過表達式引擎解析表達式並取得正確的值,通過事件解析引擎解析用戶自定義事件並完成事件的綁定,完成解析賦值以及事件綁定後進行視圖的渲染,最終將目標頁面展示到屏幕。

寫完自定義類加載器,來看看具體的用法吧,先創建一個類,擁有如下內容:

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);

04 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 類的熱交換,比自己自定義加載器更加簡便,推薦使用。

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

理解,首先 MCube 會依據模板緩存狀態判斷是否需要網絡獲取最新模板,當獲取到模板後進行模板加載,加載階段會將產物轉換爲視圖樹的結構,轉換完成後將通過表達式引擎解析表達式並取得正確的值,通過事件解析引擎解析用戶自定義事件並完成事件的綁定,完成解析賦值以及事件綁定後進行視圖的渲染,最終將目標頁面展示到屏幕。

上面講解的內容,一般是類的熱交換,但是如果需要對整個 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 技術。

06 代碼增強 技術拾憶

理解,首先 MCube 會依據模板緩存狀態判斷是否需要網絡獲取最新模板,當獲取到模板後進行模板加載,加載階段會將產物轉換爲視圖樹的結構,轉換完成後將通過表達式引擎解析表達式並取得正確的值,通過事件解析引擎解析用戶自定義事件並完成事件的綁定,完成解析賦值以及事件綁定後進行視圖的渲染,最終將目標頁面展示到屏幕。

話說在 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)

07 插件框架 玉汝於成

理解,首先 MCube 會依據模板緩存狀態判斷是否需要網絡獲取最新模板,當獲取到模板後進行模板加載,加載階段會將產物轉換爲視圖樹的結構,轉換完成後將通過表達式引擎解析表達式並取得正確的值,通過事件解析引擎解析用戶自定義事件並完成事件的綁定,完成解析賦值以及事件綁定後進行視圖的渲染,最終將目標頁面展示到屏幕。

首先來說下這個框架的主體思路,使用 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 的時候,下一次執行會重新對代碼進行增強並生效。

08 總結

理解,首先 MCube 會依據模板緩存狀態判斷是否需要網絡獲取最新模板,當獲取到模板後進行模板加載,加載階段會將產物轉換爲視圖樹的結構,轉換完成後將通過表達式引擎解析表達式並取得正確的值,通過事件解析引擎解析用戶自定義事件並完成事件的綁定,完成解析賦值以及事件綁定後進行視圖的渲染,最終將目標頁面展示到屏幕。

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

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

京東技術 京東官方技術公衆號,你想知道的京東技術前沿黑科技,全在這裏。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/heEeGMfCOnaNYv_xFrg-1g