1

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服务。类加载器动态加载脚本技术,在业务监控平台运用再适合不过了。当然一些业务场景也是可以参考此项技术,来解决一些技术问题。

*文/史一鑫 
@得物技术公众号


得物技术
759 声望1.5k 粉丝