jdk: 17

springboot:2.6.6

一、需求

正在运行的springboot程序,上传一个jar包,完成对已有接口的实现(更新),如果不用了随时可以卸载掉。并且插件内部可以使用主程序里边已有的各类bean。

二、实现思路

  • 我们的springboot应用程序提前预留好了扩展接口,但是在应用程序内部并未做任何的实现,而是交由独立的插件来完成。
  • jar上传之后,涉及到了jar的热加载,我们可以使用 java.net.URLClassLoader 来完成。加载完成之后,将实现类注册成为bean。
  • 因为jar需要更新,所以我们每次是new URLClassLoader,并把之前的资源释放掉,防止OOM。同时移除旧的bean,再添加新的bean,完成插件更新的功能。
  • 在new URLClassLoader的时候,需要指定他的父级ClassLoader,这一步很关键,这是为了让插件内部可以直接使用spring的各类bean。

三、实践

1.idea本地实践

1.1 插件接口

创建一个springboot应用程序,并在应用程序内部规定好插件的接口,但是不去做具体的实现。调用者通过获取bean的方式,来检查是否有可执行的插件,如果有就执行,没有则按照实际的业务自行处理。

import java.util.Map;

public interface IPluginService {

    /**
     * 安装插件
     */
    void install();

    /**
     * 插件是否安装完成
     */
    boolean installComplete();

    /**
     * 插件卸载
     */
    void uninstall();

    /**
     * 获取插件的作者信息
     */
    String getAuthor();

    /**
     * 获取插件的名称
     */
    String getPluginName();

    /**
     * 获取插件的版本号
     */
    String getVersion();

    /**
     * 插件的具体描述
     */
    String getDescription();

    default Object processIfComplete(Map<String, Object> params) throws Exception {
        if (!installComplete()) {
            throw new RuntimeException(getPluginName() + " 插件尚未安装完成!");
        }

        return process(params);
    }

    Object process(Map<String, Object> params) throws Exception;

}

1.2 创建ClassLoader,并加载插件

上传jar之后,需要使用URLClassLoader来对其进行加载。这里的父级ClassLoader可以使用JDK提供的 ClassLoader.getSystemClassLoader() 来获取,本地启动一个springboot项目后就可以看到实际获取的其实就是AppClassLoader,并且spring的几千上万个类基本都是由他来加载的,所以将其指定为父级之后,我们加载进来的插件就可以使用主应用里边的bean了。 

java服务热更新 jar 热更新_spring

接着我们通过new URLClassLoader结合 java.net.URL 加载自己的jar文件。

// 这里我们直接从本地获取,实际可能来自本地文件/网络等等地方
URL url = new URL("jar:file:/home/zhangsan/plugins/send-message.jar!/");
URLClassLoader classLoader = new URLClassLoader(new URL[]{url}, ClassLoader.getSystemClassLoader());

// 我们规定实现类的全限定类名就是 com.zhangsan.plugin.SendMessagePlugin,通过loadClass获取到他,然后注册成为bean
Class<?> clazz = classLoader.loadClass("com.zhangsan.plugin.SendMessagePlugin");
springUtil.registerBean(clazz.getName(), clazz);

// 注册完成之后,执行插件的安装操作
IPluginService pluginService = (IPluginService) springUtil.getBean("com.zhangsan.plugin.SendMessagePlugin");
pluginService.install();

assert pluginService.installComplete();

1.3 卸载插件

URLClassLoader提供了close()方法,可以用来关闭当前ClassLoader,但是已经被其加载的class不会被这个操作所释放掉,具体可以查阅其源码的注释。

// 关闭我们创建的ClassLoader
classLoader.close();
// 执行插件的卸载操作
pluginService.uninstall();
// 最后将该bean移除
springUtil.removeBean("com.zhangsan.plugin.SendMessagePlugin");

1.4 更新插件

其实就是先卸载插件,再次安装即可。

1.5 制作插件

我们已经完成了springboot主体应用的开发,然后我们新建一个项目,这个项目里边引入了springboot项目打包好的jar文件,从而完成插件的开发操作。插件代码编写完成之后,将其打包一下就成为了我们可以安装的插件。

import java.util.Map;


public class SendMessagePlugin implements IPluginService {

    @Override
    public void install() {
        System.out.println(getVersion() + "插件安装了");
    }

    @Override
    public boolean installComplete() {
        return true;
    }

    @Override
    public void uninstall() {
        System.out.println("插件卸载了");
    }

    @Override
    public String getAuthor() {
        return "张三";
    }

    @Override
    public String getPluginName() {
        return "发送消息插件";
    }

    @Override
    public String getVersion() {
        return "999999";
    }

    @Override
    public String getDescription() {
        return "发送短消息的插件";
    }

    @Override
    public Object process(Map<String, Object> map) throws Exception {
        System.err.println("版本为" + getVersion() + "的插件执行了");
        return null;
    }
}

1.6 本地idea测试

启动springboot应用,将打包好的jar放到 /home/zhangsan/plugins/send-message.jar 这里(你也可以写个接口去上传,模拟实际的情况),然后我们来触发一下应用程序的插件安装操作,在控制台可以看见,插件被成功的安装了。

java服务热更新 jar 热更新_spring boot_02

 接着试试看卸载

java服务热更新 jar 热更新_spring_03

最后,我们把插件版本修改一下,变成666666,然后打包之后,放到 /home/zhangsan/plugins/send-message.jar ,再次执行插件安装。

java服务热更新 jar 热更新_intellij-idea_04

1.7 测试结束

至此,在本地idea环境下,已然完全实现了我们的整体需求,插件可以在程序运行阶段正常的进行安装、卸载、更新操作,从而完成功能的扩展。

2.线上验证

我们通过maven将springboot项目打包成为jar,并以java -jar方式在测试服务器上运行起来。我们可以使用命令 java -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 -jar xxxxx.jar 运行,方便debug调试。

2.1 安装插件

这里我们通过接口上传jar,为jar创建一个临时文件,文件名每次都保证相同为send-message.jar,然后依然正常执行后续的插件安装操作,这个时候我们会发现报错了。

org.springframework.web.util.NestedServletException: Handler dispatch failed; nested exception is java.lang.NoClassDefFoundError: com/zhangsan/plugin/IPluginService
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1082) ~[spring-webmvc-5.3.18.jar!/:5.3.18]
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963) ~[spring-webmvc-5.3.18.jar!/:5.3.18]
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) ~[spring-webmvc-5.3.18.jar!/:5.3.18]
	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) ~[spring-webmvc-5.3.18.jar!/:5.3.18]
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:655) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) ~[spring-webmvc-5.3.18.jar!/:5.3.18]
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:764) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:227) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-9.0.60.jar!/:na]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-5.3.18.jar!/:5.3.18]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) ~[spring-web-5.3.18.jar!/:5.3.18]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-5.3.18.jar!/:5.3.18]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) ~[spring-web-5.3.18.jar!/:5.3.18]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.springframework.boot.actuate.metrics.web.servlet.WebMvcMetricsFilter.doFilterInternal(WebMvcMetricsFilter.java:96) ~[spring-boot-actuator-2.6.6.jar!/:2.6.6]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) ~[spring-web-5.3.18.jar!/:5.3.18]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-5.3.18.jar!/:5.3.18]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) ~[spring-web-5.3.18.jar!/:5.3.18]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:197) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:541) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:135) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:360) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:399) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:889) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1743) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) ~[tomcat-embed-core-9.0.60.jar!/:na]
	at java.base/java.lang.Thread.run(Thread.java:833) ~[na:na]
Caused by: java.lang.NoClassDefFoundError: com/zhangsan/plugin/IPluginService
	at java.base/java.lang.ClassLoader.defineClass1(Native Method) ~[na:na]
	at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1012) ~[na:na]
	at java.base/java.security.SecureClassLoader.defineClass(SecureClassLoader.java:150) ~[na:na]
	at java.base/java.net.URLClassLoader.defineClass(URLClassLoader.java:524) ~[na:na]
	at java.base/java.net.URLClassLoader$1.run(URLClassLoader.java:427) ~[na:na]
	at java.base/java.net.URLClassLoader$1.run(URLClassLoader.java:421) ~[na:na]
	at java.base/java.security.AccessController.doPrivileged(AccessController.java:712) ~[na:na]
	at java.base/java.net.URLClassLoader.findClass(URLClassLoader.java:420) ~[na:na]
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:587) ~[na:na]
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:520) ~[na:na]
	at com.zhangsan.plugin.PluginRegisterService.install(PluginRegisterService.java:79) ~[classes!/:1.0-SNAPSHOT]
	at com.zhangsan.controller.TestController.installPlugin(TestController.java:64) ~[classes!/:1.0-SNAPSHOT]
	at com.zhangsan.controller.TestController$$FastClassBySpringCGLIB$$230d6e46.invoke(<generated>) ~[classes!/:1.0-SNAPSHOT]
	at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) ~[spring-core-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:783) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.aspectj.AspectJAfterThrowingAdvice.invoke(AspectJAfterThrowingAdvice.java:64) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:175) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.adapter.AfterReturningAdviceInterceptor.invoke(AfterReturningAdviceInterceptor.java:57) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:175) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.adapter.MethodBeforeAdviceInterceptor.invoke(MethodBeforeAdviceInterceptor.java:58) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:175) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:698) ~[spring-aop-5.3.18.jar!/:5.3.18]
	at com.zhangsan.controller.TestController$$EnhancerBySpringCGLIB$$385afcc9.installPlugin(<generated>) ~[classes!/:1.0-SNAPSHOT]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205) ~[spring-web-5.3.18.jar!/:5.3.18]
	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:150) ~[spring-web-5.3.18.jar!/:5.3.18]
	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117) ~[spring-webmvc-5.3.18.jar!/:5.3.18]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895) ~[spring-webmvc-5.3.18.jar!/:5.3.18]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808) ~[spring-webmvc-5.3.18.jar!/:5.3.18]
	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-5.3.18.jar!/:5.3.18]
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1067) ~[spring-webmvc-5.3.18.jar!/:5.3.18]
	... 43 common frames omitted
Caused by: java.lang.ClassNotFoundException: com.zhangsan.plugin.IPluginService
	at java.base/java.net.URLClassLoader.findClass(URLClassLoader.java:445) ~[na:na]
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:587) ~[na:na]
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:520) ~[na:na]
	... 85 common frames omitted

这里最后显示 Caused by: java.lang.ClassNotFoundException: com.zhangsan.plugin.IPluginService ,结合前面的堆栈信息,可以看到是在loadClass的时候找不到这个类。也就是说,我们new出来的URLClassLoader,在加载我们插件里边那个实现类的时候找不到插件的接口了。

还记得前面的 ClassLoader.getSystemClassLoader() 么,我们现在再次来debug一下他,只不过现在是在测试服务器上远程debug了,可以看到如下的信息:

java服务热更新 jar 热更新_spring_05

这里的AppClassLoader,仅仅加载了47个类,前边debug的时候可是加载了5000多个类。并且在已有的47个类里边,是找不到我们的 com.zhangsan.plugin.IPluginService。也就是说这个class是被别的ClassLoader加载了!

如果熟悉springboot启动的小伙伴,应该知道这是什么原因了。服务器上我们是通过java -jar方式启动的,通过压缩文件打开我们运行的jar包,在里边找到 META-INF/MANIFEST.MF 文件,打开它就可以看到如下的信息:

java服务热更新 jar 热更新_java_06

其实它并不是直接通过我们项目里边的Application来启动的,而是通过spring自己的JarLauncher来启动的,打开JarLauncher源码找到main方法,然后进入launch方法,在这里我们可以看到spring自己创建了ClassLoader:

java服务热更新 jar 热更新_intellij-idea_07

继续跟入之后可以看到,实际上就是spring自己的LaunchedURLClassLoader,他也实现了URLClassLoader,并且重写了loadClass方法:

java服务热更新 jar 热更新_intellij-idea_08

2.2 替换父级ClassLoader

 spring提供了 ClassUtils.getDefaultClassLoader() 可以用来获取到LaunchedURLClassLoader(里边逻辑不做过多解释,自行参阅源码),所以回到最开始我们new URLClassLoader的地方,把这里获取父级ClassLoader替换即可。

注:tomcat自己的ClassLoader对这里是没有影响的,不需要去关注它。

2.3 再次验证

首先是本地,安装、卸载、更新都没问题。其实通过idea本地运行的时候,ClassUtils.getDefaultClassLoader() 和 ClassLoader.getSystemClassLoader() 都是获取到了AppClassLoader,可以自行查阅源码逻辑并且debug看一下。

接着测试服务器验证,安装、卸载一切正常,但是更新的时候出问题了!出现了很玄学的问题:

  • 上传了新的插件,卸载和安装操作都在正常执行,但是观察到了两种情况,一个是更新成功没有任何问题,一个是更新失败,插件还是旧的插件;
  • 更新失败时候是这样的现象:从执行插件install时候的输出来看,执行的还是最开始安装那个插件的install!而不是本次上传的;
  • 偶尔一次可以更新成功,输出都正常。但是继续更新还是存在失败的情况,并且失败时候install输出的内容,就是刚才更新成功时候的内容。

这是缓存?而且还是带有一定删除或过期机制的缓存?假如是过期,那么一般情况下他的过期时间应该比较的固定,但是实际测试下来并不是,有时候几十秒内连续五六个插件都可以更新成功,有时候两三分钟都不见得能够更新成功一次!

虽然插件都是保存到本地固定的 /home/zhangsan/plugins/send-message.jar 下,但是每次卸载的时候都是将其删除掉,然后安装时候重新写入的,所以这个文件每次都是最新的。

2.4 org.springframework.boot.loader.jar.Handler

我们先简单介绍一下org.springframework.boot.loader.jar.Handler。

还记得刚才spring启动的那张截图吗?我们再来看一下:

java服务热更新 jar 热更新_intellij-idea_09

这次的重点不再是ClassLoader了,而是 JarFile.registerUrlProtocolHandler(); 。我们跟进去:

java服务热更新 jar 热更新_spring boot_10

在这里,他把 org.springframework.boot.loader.jar.Handler 注册了进来,熟悉这玩意的小伙伴应该知道,他就是spring自己的URL处理器,它还支持jar in jar的加载等等。

2.5 检查jar资源的加载逻辑

我们自己创建的URLClassLoader父级是LaunchedURLClassLoader(tomcat的可以不去管它),LaunchedURLClassLoader的父级就是AppClassLoader。他们完整关系应该是这样的:

自己的URLClassLoader -> LaunchedURLClassLoader -> AppClassLoader -> PlatformClassLoader -> BootClassLoader

我们自己的URLClassLoader在加载jar资源的时候,通过双亲委派给父级,其实几个父级都是加载不了我们自己的jar(自行debug一下),那么最后还是回到了我们自己的URLClassLoader,从loadClass的逻辑我们可以知道,由于父级都加载不了,并且我们自己的URLClassLoader本地也没有这个class的时候,会执行到findClass方法:

java服务热更新 jar 热更新_spring_11

而URLClassLoader是重写了findClass方法的:

java服务热更新 jar 热更新_java_12

 在这里我们可以看到,findClass会通过 jdk.internal.loader.URLClassPath 去获取相关的资源,我们继续跟进去,发现这里有一个getResource的操作:

java服务热更新 jar 热更新_spring_13

 继续跟进,可以发现这里有一个打开资源的操作:

java服务热更新 jar 热更新_spring boot_14

跟进

java服务热更新 jar 热更新_java_15

跟进

java服务热更新 jar 热更新_spring_16

 这个时候我们发现,已经进入了 org.springframework.boot.loader.jar.Handler 里边(上边2.4的简单介绍),也就是说我们jar文件资源的加载是通过他来完成的,其实继续跟进下去就可以看到问题的根源所在:

java服务热更新 jar 热更新_spring_17

他就是在这里做了缓存!还记得我们插件放置的位置么,每次都是 /home/zhangsan/plugins/send-message.jar , 所以更新的时候,他会直接取缓存,而不是去加载我们新的jar文件,也就导致更新的时候,还是执行最开始的install,因为加载的jar文件还是以前的!

但是前边说了,还有更新成功的情况,这是怎么回事呢?找到这个缓存变量的定义:

java服务热更新 jar 热更新_spring boot_18

他是一个软引用!!!!所以才导致了时而成功时而失败的情况!

2.6 解决

至此,问题根源找到了,那么就来解决它。

  • 方式一:最简单粗暴,既然是文件名(路径)相同导致走了缓存,那么我每次换一个文件名就行了;而且这个缓存是软引用,也无需担心更新jar次数过多导致内存撑爆的问题!
  • 方式二:可以自己实现一个ClassLoader,然后自己加载这个jar,也就不存在缓存的问题了。参考一下LaunchedURLClassLoader的实现,我们可以继承URLClassLoader,然后重写loadClass和findClass方法,在loadClass的时候,如果是自己插件规范里边的类,就先本地加载,否则交给父级。

方式二可以参考如下代码:

import lombok.extern.slf4j.Slf4j;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;


@Slf4j
public class PluginClassLoader extends URLClassLoader {

    private static final int BUFFER_SIZE = 4096;
    private final static String CLASS_SUFFIX = ".class";

    private final Map<String, byte[]> classMap = new HashMap<>();

    public PluginClassLoader(URL url, ClassLoader parent) throws IOException {
        super(new URL[]{url}, parent);
        parseJar(url);
    }

    /**
     * 将jar文件里边的class都解析出来缓存到本地
     */
    private void parseJar(URL url) throws IOException {
        String path = url.getPath();
        path = path.substring(5, path.length() - 2);
        JarFile jarFile = new JarFile(path);

        //解析jar包每一项
        Enumeration<JarEntry> en = jarFile.entries();
        InputStream input = null;
        try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
            while (en.hasMoreElements()) {
                JarEntry jarEntry = en.nextElement();
                String name = jarEntry.getName();
                if (name.endsWith(CLASS_SUFFIX)) {
                    String className = name.replace(CLASS_SUFFIX, "").replaceAll("/", ".");
                    input = jarFile.getInputStream(jarEntry);
                    byte[] buffer = new byte[BUFFER_SIZE];
                    int index = 0;
                    while ((index = input.read(buffer)) != -1) {
                        bos.write(buffer, 0, index);
                    }

                    classMap.put(className, bos.toByteArray());
                    bos.reset();
                }
            }
        } catch (IOException e) {
            log.error(e.getClass().getSimpleName() + " " + e.getMessage(), e);
        } finally {
            if (input != null) {
                try {
                    input.close();
                } catch (IOException ignore) {
                }
            }
        }
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 改变加载逻辑,这里只要是 com.zhangsan.plugin 开头的,都是我们插件规范里边的类,直接加载本地的class
        if (name.startsWith("com.zhangsan.plugin.")) {
            try {
                return loadClassInLaunchedClassLoader(name);
            } catch (ClassNotFoundException ignore) {
                // 加载不了的,应该交由父类去加载
            }
        }

        // 其余的类还是交由父类去完成
        return super.loadClass(name);
    }

    private Class<?> loadClassInLaunchedClassLoader(String name) throws ClassNotFoundException {
        byte[] classBytes = classMap.get(name);
        if (classBytes == null) {
            throw new ClassNotFoundException(name);
        }

        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(classBytes);
             ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            byte[] buffer = new byte[BUFFER_SIZE];
            int bytesRead = -1;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
            }
            byte[] bytes = outputStream.toByteArray();
            return defineClass(name, bytes, 0, bytes.length);
        } catch (Exception e) {
            throw new ClassNotFoundException("Cannot load resource for class [" + name + "]", e);
        }
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String className = name.replace(CLASS_SUFFIX, "").replaceAll("/", ".");

        byte[] bytes = classMap.get(className);
        if (bytes == null) {
            throw new ClassNotFoundException(name);
        }

        return this.defineClass(name, bytes, 0, bytes.length);
    }

}

 在加载插件那里,创建类加载器的时候,我们就可以直接使用我们自定义的ClassLoader,同样可以解决问题。

2.7 测试

通过上诉方式,无论本地idea还是测试服务器都正常的完成了我们需要的功能,并且插件内部是可以直接注入springboot应用程序里边已有的bean。

四、问题

已经加载的类是会被卸载?如果无法被卸载是可能导致oom的。

其实JVM是会卸载类的,打开jdk提供的工具jconsole,选择Classes这一页,左下边是可以看到unload的类有多少个:

java服务热更新 jar 热更新_spring_19

但是一个类是否会被卸载,需要满足的条件还是蛮多的:

  • 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例;
  • 加载该类的ClassLoader已经被回收;
  • 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

五、总结

通过创建自己的URLCLassLoaderr,并指定正确的父级之后,便可以在插件内部使用各种bean,开发插件就和正常开发没有太多的区别。从而在运行阶段,动态的扩展程序的功能。

应用场景:其实对于toC的应用而言,应该是没有这类需求的,这种插件主要是针对私有化部署的应用。部署到客户这里之后难免存在对接、定制的情况,通常可以选择去修改源代码,但这是最不希望看到的一件事情。所以在程序设计阶段,可以将一些变化比较大,不是那么核心的业务预留好接口,交由插件去实现。

个人能力有限,如有错误之处,还望指出!