一、背景
线上项目想要快速测试不同推荐算法的效果,目前的流程比较长:算法同学每次有更新就打一个 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 包
创建目录,要与接口所在的包名一致,然后添加【tatget】目录里编译好的接口【class】文件,点击 OK 结束
构建,得到接口 Jar 包
2、实现接口 Jar 包
1)引入接口 Jar 包
测试时可本地引入,规范之后可以发到版本仓库
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)效果展示
四、实践
实际应用时,没有使用 demo 中的通过接口触发,而是提供了一个配置中心(包括开关及 jar 包地址),每个 pod 监听到配置中心上开启热加载时,会主动触发热加载配置中心上配置的 jar 包地址。然后提供了一键查询接口,检查各环境热加载的版本。
1、对象存储上传
将要更新的 jar 包上传到对象存储,为了安全起见,权限设为私有读,在程序中通过对象存储的 secretId、secretKey 生成签名来访问。
2、配置中心
在七彩石配置热加载开关及上传到 cos 中的 jar 包地址(也可以是其他地址)。对应的服务监听七彩石的配置变更,若开关打开则热加载配置的地址,热加载失败或者开关关闭,则回滚项目中自带的 jar 包。
3、服务发现查询
提供了一个接口查询热加载状态,通过服务发现应用项目的所有环境的实例,然后遍历请求,可以判断加载状态。