Classloader隔離技術在業務監控中的應用詳解

1. 背景&簡介

業務監控平臺是得物自研的一款用於數據和狀態驗證的平臺。能快速便捷發現線上業務臟數據和錯誤邏輯,有效防止資產損失和保證系統穩定性。

數據流向:

上圖的過濾和校驗步驟的實際工作就是執行一個用戶自定義的Groovy核對腳本。業務監控內部通過一個執行腳本的模塊來實現。

本篇以腳本執行模塊的一個技術問題為切入點,給大傢分享利用ClassLoader隔離技術實現腳本執行隔離的經驗。

2. 業務監控平臺腳本調試流程

業務監控核心執行邏輯是數據校驗核對。不同域會有不同的數據校驗核對規則。最初版本用戶編寫一個腳本進行調試的步驟如下:

1.編寫數據校驗腳本(在業務監控平臺規則下),腳本demo:

@Service
public class DubboDemoScript implements DemoScript {
    @Resource
    private DemoService demoService;
    @Override
    public boolean filter(JSONObject jsonObject) {
        // 這裡省略數據過濾邏輯 由業務使用方實現
        return true;
    }
    @Override
    public String check(JSONObject jsonObject) {
        Long id = jsonObject.getLong("id");
        // 數據校驗,由業務使用方實現
        Response responseResult = demoService.queryById(id);
        log.info("[DubboClassloaderTestDemo]返回結果={}", JsonUtils.serialize(responseResult));
        return JsonUtils.serialize(responseResult);
    }
}

其中DemoScript是業務監控平臺定義的一個模板interface,  不同腳本實現此接口並重寫 filter和check兩個方法。filter方法是用來進行數據過濾的,check方法是進行數據核對校驗的-用戶主要編寫這兩個方法中的邏輯。

2.在業務監控平臺腳本調試頁面進行調試腳本,當腳本中有第三方團隊Maven依賴時候,業務監控平臺需要在pom.xml中添加Maven依賴並進行發佈,之後通知用戶再此進行調試。

3.點擊腳本調試,查看腳本調試結果。

4.保存並上線腳本。

2.1 業務監控的腳本開發調試流程圖

用戶想要調試一個腳本需要告知平臺開發,平臺開發手動將Maven依賴添加到project中並去發佈平臺進行發佈。中間不僅特別耗時,效率低,而且還要頻繁發佈,嚴重影響瞭業務監控平臺的用戶使用體驗且增加平臺開發的維護成本。

為此,業務監控平臺在新版本中使用瞭Classloader隔離技術來動態加載腳本中依賴的業務方服務。業務監控不需要再進行特殊處理(添加Maven依賴再進行發佈),用戶在管控後臺直接上傳腳本以來的JAR文件就可以完成調試,大大降低瞭使用和維護成本,提高用戶體驗。

3. 自定義Classloder | 打破雙親委派

3.1 什麼是Classloader

ClassLoader是一個抽象類,我們用它的實例對象來裝載類 ,它負責將Java字節碼裝載到JVM中 , 並使其成為JVM一部分。JVM的類動態加載技術能夠在運行時刻動態地加載或者替換系統的某些功能模塊,而不影響系統其他功能模塊的正常運行。一般是通過類名讀入一個class文件來裝載這個類。

類裝載就是尋找一個類或是一個接口的字節碼文件並通過解析該字節碼來構造代表這個類或是這個接口的class對象的過程 。在Java中,類裝載器把一個類裝入Java虛擬機中,要經過三個步驟來完成:裝載、鏈接和初始化。

3.2 Classloader動態加載依賴文件

利用Classloader實現類URLClassloader來實現依賴文件的動態加載。示例代碼:

public class CustomClassLoader extends URLClassLoader {
/**
 * @param jarPath jar文件目錄地址
 * @return
 */
private CustomClassLoader createCustomClassloader(String jarPath) throws MalformedURLException {
    File file = new File(jarPath);
    URL url = file.toURI().toURL();
    List<URL> urlList = Lists.newArrayList(url);
    URL[] urls = new URL[urlList.size()];
    urls = urlList.toArray(urls);
    return new CustomJarClassLoader(urls, classLoader.getParent());
}
public CustomClassLoader(URL[] urls, ClassLoader parent) {
    super(urls, parent);
}

在新增依賴文件的時候,使用Classloader的addURL方法動態添加來進行實現。

如果所有腳本使用同一個類加載器,來進行加載,就會出現問題,原因:同一個類(全限定名一樣)隻會被類加載器加載一次(雙親委派)。但是不同腳本存在兩個全限定名一樣的情況,但是方法或者屬性不相同,因此加載一次就會導致其中一個腳本核對邏輯出錯。

在理解瞭上面的情況下,我們就需要打破Java雙親委派機制,這裡要知道一個知識點:一個類的全限定名以及加載該類的加載器兩者共同形成瞭這個類在JVM中的唯一標識,因此就需要自定義類加載器,讓腳本和Classloader一一對應且各不相同。話不多說,直接上幹貨:

3.3 自定義類加載器

public class CustomClassLoader extends URLClassLoader {
    public JarFile jarFile;
    public ClassLoader parent;
    public CustomClassLoader(URL[] urls, JarFile jarFile, ClassLoader parent) {
        super(urls, parent);
        this.jarFile = jarFile;
        this.parent = parent;
    }
    public CustomClassLoader(URL[] urls, ClassLoader parent) {
        super(urls, parent);
    }
    private static String classNameToJarEntry(String name) {
        String classPath = name.replaceAll("\\.", "\\/");
        return new StringBuilder(classPath).append(".class").toString();
    }
    /**
     * 重寫loadClass方法,按照類包路徑規則拉進行加載Class到jvm
     * @param name 類全限定名
     * @return
     * @throws ClassNotFoundException
     */
    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 這裡定義類加載規則,和findClass方法一起組合打破雙親
        if (name.startsWith("com.xx") || name.startsWith("com.yyy")) {
           return this.findClass(name);
        }
        return super.loadClass(name, resolve);
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = null;
        try {
            String jarEntryName = classNameToJarEntry(name);
            if (jarFile == null) {
                return clazz;
            }
            JarEntry jarEntry = jarFile.getJarEntry(jarEntryName);
            if (jarEntry != null) {
                InputStream inputStream = jarFile.getInputStream(jarEntry);
                byte[] bytes = IOUtils.toByteArray(inputStream);
                clazz = defineClass(name, bytes, 0, bytes.length);
            }
        } catch (IOException e) {
            log.info("Custom classloader load calss {} failed", name)
        }
        return clazz;
    }
}

說明:上述自定義類加載器的loadClass和findClass方法一起達到破壞雙親委派機制的關鍵。其中super.loadClass(name, resolve)方法是不符合自定義類加載器規則的情況下,讓其父加載器(這裡的父加載器就是LanuchUrlClassloader)進行類加載,自定義類加載器隻關註自己要加載的類,並按照腳本維度進行緩存對應的Classloader。

3.4 業務監控使用CustomClassloader

腳本或者調試腳本過程中和Classloader之間的創建關系:

一個腳本對應多個依賴的JAR文件(JAR文件在腳本調試頁面上傳到HDFS),一個腳本對應一個classloader(並進行本地緩存)(完全相同的兩個類在不同的classloader中加載後兩個Class對象是不相等的)。

3.5 業務監控動態加載JAR和腳本的實現

在上述的操作中,相信大傢對JAR怎麼實現腳本加載的,和腳本中@Resource註解標記的屬性DemoService類如何創建Bean和註入到Spring容器比較關註。貼張流程圖來講解:

流程圖中生成FeignClient對象的創建源碼:

/**
 * 
 * @param serverName 服務名 (@FeignClient主鍵中的name值)
 *  eg:@FeignClient("demo-interfaces") 
 * @param beanName feign對象名稱 eg: DemoFeignClient
 * @param targetClass feign的Class對象 
 * @param <T> FeignClient主鍵標記的Object
 * @return
 */
public static <T> T build(String serverName, String beanName, Class<T> targetClass) {
    return buildClient(serverName, beanName, targetClass);
}
private static <T> T buildClient(String serverName, String beanName, Class<T> targetClass) {
    T t = (T) BEAN_CACHE.get(serverName + "-" + beanName);
    if (Objects.isNull(t)) {
        FeignClientBuilder.Builder<T> builder = new FeignClientBuilder(applicationContext).forType(targetClass, serverName);
        t = builder.build();
        BEAN_CACHE.put(serverName + "-" + beanName, t);
    }
    return t;
}

流程圖中生成註冊Dubbo consumer的源碼:

public void registerDubboBean(Class clazz, String beanName) {
        // 當前應用配置
    ApplicationConfig application = new ApplicationConfig();
    application.setName("demo-service");
    // 連接註冊中心配置
    RegistryConfig registry = new RegistryConfig();
    registry.setAddress(registryAddress);
    // ReferenceConfig為重對象,內部封裝瞭與註冊中心的連接,以及與服務提供方的連接
    ReferenceConfig reference = new ReferenceConfig&lt;&gt;(); // 此實例很重,封裝瞭與註冊中心的連接以及與提供者的連接,請自行緩存,否則可能造成內存和連接泄漏
    reference.setApplication(application);
    reference.setRegistry(registry); // 多個註冊中心可以用setRegistries()
    reference.setInterface(clazz);
    reference.setVersion("1.0");
    // 註意:此代理對象內部封裝瞭所有通訊細節,這裡用dubbo2.4版本以後提供的緩存類ReferenceConfigCache
    ReferenceConfigCache cache = ReferenceConfigCache.getCache();
    Object dubboBean = cache.get(reference);    
    dubboBeanMap.put(beanName, dubboBean);
    // 註冊bean
    SpringContextUtils.registerBean(beanName, dubboBean);
    // 註入bean
    SpringContextUtils.autowireBean(dubboBean);
}

以上就是Classloader隔離技術在業務監控平臺的實際運用,當然在開發中也遇到一些問題,下面列舉2個例子。

4. 問題&原因&方案

問題一: 多個團隊的Check腳本運行在一起,單個應用的Metaspace空間占用會不會過大?

答:隨著業務的發展,JAR文件的不斷增多,確實會出現元數據區占用過大的情況,這也是做Classloader隔離的原因。在做瞭這一步之後,為後面進行腳本拆分做瞭鋪墊,比如按照應用、團隊等維度單獨部署應用來運行其對應check腳本。這樣腳本和業務監控邏輯上也進行瞭拆分,也會降低主應用的發佈頻率帶來的噪音。

問題二:Classloader隔離實現上有沒有遇到什麼難題?

答:中間遇到瞭一些問題,就是同一個全限定名的類,出現瞭CastException異常,此類問題是最容易出現的,也最容易想到的。

原因:同一個類被2個不同的Classloader對象加載瞭2次。解決也很簡單,使用同一個類加載器。

5. 總結

該篇文章講解瞭自定義Classloader的實現和如何做到隔離,如何動態加載JAR文件,如何手動註冊入Dubbo和Feign服務。類加載器動態加載腳本技術,在業務監控平臺運用再適合不過瞭。當然一些業務場景也是可以參考此項技術,來解決一些技術問題。

以上就是Classloader隔離技術在業務監控中的應用詳解的詳細內容,更多關於Classloader業務監控隔離技術的資料請關註WalkonNet其它相關文章!

推薦閱讀: