09 热加载实践

Wu Jun 2021-07-12 22:21:47
Categories: > Tags:

一、背景

线上项目想要快速测试不同推荐算法的效果,目前的流程比较长:算法同学每次有更新就打一个 Jar 包,工程同学要在项目中替换新的 Jar 包,重新编译、发布。

想要免发布,快速替换新 Jar 包中变更的策略 Class。

二、思路

1、工程同学提供一个“策略接口”,算法同学提供实现这个“策略接口”的 Jar 包。即使不用热替换,定义好接口也比口头约定再反射来得稳健。

2、通过轮询某个目录的变更,或调用接口主动触发,让程序感知到 Jar 包有更新时,替换新 Jar 包,热加载“策略接口”的新实现类,生成“策略接口”的新实例,来替换程序中“策略接口”的老实现。

3、如果一开始工程程序中没有引用默认 Jar 包,则新 Jar 包可以用系统类加载器来加载;如果工程中已经引入了默认 Jar 包,想要替换其中已加载的类,要避免双亲委派,则需要使用自定义类加载器。

注意

在生产上直接替换已加载的类会有很多隐患,如果新加载的类引用了原来不存在的类,或者删掉了原来已有的字段,都会造成很大问题。非要这样做的话,最好是在测试环境先验证好。

三、DEMO

1、生成接口 Jar 包

1)定义接口类
public interface HelloWorldService {
    String hello();
}
2)IntelliJ 打包

打开 File -> Project Structure -> Artifacts,点击 + 号建立一个空 Jar 包

image

创建目录,要与接口所在的包名一致,然后添加【tatget】目录里编译好的接口【class】文件,点击 OK 结束

image

构建,得到接口 Jar 包

image

2、实现接口 Jar 包

1)引入接口 Jar 包

测试时可本地引入,规范之后可以发到版本仓库

image

2)实现类

分别创建了 2 个实现类,共 3 个版本,实现引入的“接口”,生成测试 Jar 包。

2.1)实现类1

第一个测试类

public class HelloWorldServiceImpl1 implements HelloWorldService {

    public String hello() {
        return "World I";
    }
}
2.2)实现类2,版本1

测试不同类的加载

public class HelloWorldServiceImpl2 implements HelloWorldService {

    public String hello() {
        return "World II";
    }
}
2.3)实现类2,版本2

测试相同类不同版本的替换

public class HelloWorldServiceImpl2 implements HelloWorldService {

    public String hello() {
        return "World II.2";
    }
}
3)IntelliJ 打包

一样打开 File -> Project Structure -> Artifacts,这次选择从 modules 创建

选择好 main 函数,点 OK

一样通过 build -> build Artifacts 构建,对上面 3 个实现类,分别构建,得到 3 个实现 Jar 包。

3、测试热加载

1)copy 实现 Jar 包

将上面生成的 3 个实现 Jar 包,重命名,copy 到测试项目,以方便测试。可以将其中一个 Jar 包引入到工程中,来测试引入与不引入时,类加载器工作的区别。或者放在对象存储上,一样的原理。

2)实现自定义类加载器

如果工程中已经引入过旧版“实现Jar包”,则需要实现自定义类加载器,否则用系统自带的类加载器就行。

这里自定义类加载器,目的是避开双亲委派,重新加载已加载过的接口实现类,所以将其范围限制在实现Jar包的路径。

/**
 * 重写 loadClass
 * 为了最小限度破坏双亲委派,其他类还是由系统默认 ClassLoader 加载
 * 只有实现类的目录(这里是 com.tencent.srmp.jarmaker )自己重新加载
 */
public class HelloWorldClassLoader extends URLClassLoader {

    public HelloWorldClassLoader(URL[] urls, ClassLoader parent) {
        super(urls, parent);
    }

    @Override
    public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 检查是否已经加载过
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            //如果是"实现Jar包"的类,则自己加载
            if (name.startsWith("com.tencent.srmp.jarmaker")) {
                c = super.findClass(name);
            } else {
                //其他的让父类加载,还没找到就抛错
                c = super.loadClass(name, false);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return this.loadClass(name, false);
    }

}

用前面 copy 的实现 Jar 包来测试

加载类 1

本地:http://localhost:8080/reload?jarPath=file:/Users/xxx/IdeaProjects/jar-loader/src/main/resources/lib/jar-maker1.jar&className=com.xxx.jarmaker.HelloWorldServiceImpl1
对象存储:http://localhost:8080/reload?jarPath=https://xxx.cos.ap-guangzhou.myqcloud.com/jar-maker1.jar&className=com.xxx.jarmaker.HelloWorldServiceImpl1

加载类 2

本地:http://localhost:8080/reload?jarPath=file:/Users/xxx/IdeaProjects/jar-loader/src/main/resources/lib/jar-maker2.jar&className=com.xxx.jarmaker.HelloWorldServiceImpl2
对象存储:http://localhost:8080/reload?jarPath=https://xxx.cos.ap-guangzhou.myqcloud.com/jar-maker2.jar&className=com.xxx.jarmaker.HelloWorldServiceImpl2

加载类 2 版本 2

本地:http://localhost:8080/reload?jarPath=file:/Users/xxx/IdeaProjects/jar-loader/src/main/resources/lib/jar-maker2.2.jar&className=com.xxx.jarmaker.HelloWorldServiceImpl2
对象存储:http://localhost:8080/reload?jarPath=https://xxx.cos.ap-guangzhou.myqcloud.com/jar-maker2.2.jar&className=com.xxx.jarmaker.HelloWorldServiceImpl2
3)测试热加载

写了一个 controller 来测试,两个接口,/hello 来请求实现类方法,/reload 来触发热加载

3.1)/reload

有两个参数,jarPath 指定新 Jar 包的地址,className 指定要热加载的类名,使用自定义的类加载器。

    @GetMapping("/reload")
    public String reload(@RequestParam("jarPath") String jarPath, @RequestParam("className") String className) {
        if (StringUtils.isBlank(jarPath)) {
            return "reload err, jarPath is Empty";
        }

        try {
            URL url = new URL(jarPath);

            HelloWorldClassLoader child = new HelloWorldClassLoader(new URL[]{url},
                    Thread.currentThread().getContextClassLoader());

            Class classToLoad = Class.forName(className, true, child);

            Object instance = classToLoad.newInstance();

            if (instance instanceof HelloWorldService) {
                helloWorldService = (HelloWorldService) instance;
            } else {
                return "reload err, not instanceof HelloWorldService";
            }
        } catch (Exception e) {
            e.printStackTrace();
            return "reload err, " + e.getMessage();
        }
        return "reload success";
    }
3.2)/hello

/reload 后测试 /hello,分别返回 World I、World II、World II.2,替换成功。

    @GetMapping("/hello")
    public String hello() {
        try {
            if (null == helloWorldService) {
                return "err, HelloWorldService not loader";
            }

            String result = helloWorldService.hello();
            System.out.println(result);
            return result;
        } catch (Exception e) {
            e.printStackTrace();
            return "hello err" + e.getMessage();
        }
    }
4)效果展示

image

四、实践

实际应用时,没有使用 demo 中的通过接口触发,而是提供了一个配置中心(包括开关及 jar 包地址),每个 pod 监听到配置中心上开启热加载时,会主动触发热加载配置中心上配置的 jar 包地址。然后提供了一键查询接口,检查各环境热加载的版本。

1、对象存储上传

将要更新的 jar 包上传到对象存储,为了安全起见,权限设为私有读,在程序中通过对象存储的 secretId、secretKey 生成签名来访问。

image

2、配置中心

在七彩石配置热加载开关及上传到 cos 中的 jar 包地址(也可以是其他地址)。对应的服务监听七彩石的配置变更,若开关打开则热加载配置的地址,热加载失败或者开关关闭,则回滚项目中自带的 jar 包。

image

3、服务发现查询

提供了一个接口查询热加载状态,通过服务发现应用项目的所有环境的实例,然后遍历请求,可以判断加载状态。

image