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<>(); // 此实例很重,封装了与注册中心的连接以及与提供者的连接,请自行缓存,否则可能造成内存和连接泄漏
    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服务。类加载器动态加载脚本技术,在业务监控平台运用再适合不过了。当然一些业务场景也是可以参考此项技术,来解决一些技术问题。

*文/史一鑫

原文地址:https://cloud.tencent.com/developer/article/2081528

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习编程?其实不难,不过在学习编程之前你得先了解你的目的是什么?这个很重要,因为目的决定你的发展方向、决定你的发展速度。
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面设计类、前端与移动、开发与测试、营销推广类、数据运营类、运营维护类、游戏相关类等,根据不同的分类下面有细分了不同的岗位。
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生学习Java开发,但要结合自身的情况,先了解自己适不适合去学习Java,不要盲目的选择不适合自己的Java培训班进行学习。只要肯下功夫钻研,多看、多想、多练
Can’t connect to local MySQL server through socket \'/var/lib/mysql/mysql.sock问题 1.进入mysql路径
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 sqlplus / as sysdba 2.普通用户登录
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服务器有时候会断掉,所以写个shell脚本每五分钟去判断是否连接,于是就有下面的shell脚本。
BETWEEN 操作符选取介于两个值之间的数据范围内的值。这些值可以是数值、文本或者日期。
假如你已经使用过苹果开发者中心上架app,你肯定知道在苹果开发者中心的web界面,无法直接提交ipa文件,而是需要使用第三方工具,将ipa文件上传到构建版本,开...
下面的 SQL 语句指定了两个别名,一个是 name 列的别名,一个是 country 列的别名。**提示:**如果列名称包含空格,要求使用双引号或方括号:
在使用H5混合开发的app打包后,需要将ipa文件上传到appstore进行发布,就需要去苹果开发者中心进行发布。​
+----+--------------+---------------------------+-------+---------+
数组的声明并不是声明一个个单独的变量,比如 number0、number1、...、number99,而是声明一个数组变量,比如 numbers,然后使用 nu...
第一步:到appuploader官网下载辅助工具和iCloud驱动,使用前面创建的AppID登录。
如需删除表中的列,请使用下面的语法(请注意,某些数据库系统不允许这种在数据库表中删除列的方式):
前不久在制作win11pe,制作了一版,1.26GB,太大了,不满意,想再裁剪下,发现这次dism mount正常,commit或discard巨慢,以前都很快...
赛门铁克各个版本概览:https://knowledge.broadcom.com/external/article?legacyId=tech163829
实测Python 3.6.6用pip 21.3.1,再高就报错了,Python 3.10.7用pip 22.3.1是可以的
Broadcom Corporation (博通公司,股票代号AVGO)是全球领先的有线和无线通信半导体公司。其产品实现向家庭、 办公室和移动环境以及在这些环境...
发现个问题,server2016上安装了c4d这些版本,低版本的正常显示窗格,但红色圈出的高版本c4d打开后不显示窗格,
TAT:https://cloud.tencent.com/document/product/1340