Spring Boot 开发环境热部署(HotSwap)详解

简介: 前言Spring Boot 提出了多项开箱即用的功能特性,但归根到底还是围绕简化应用的创建、开发、运行。开发环境下我们经常对项目代码进行变动,如果每次都重新启动应用会浪费我们大量时间,为此就产生了多种进行热部署的方案,可以在不重启的情况下使用新的代码。

前言


Spring Boot 提出了多项开箱即用的功能特性,但归根到底还是围绕简化应用的创建、开发、运行。开发环境下我们经常对项目代码进行变动,如果每次都重新启动应用会浪费我们大量时间,为此就产生了多种进行热部署的方案,可以在不重启的情况下使用新的代码。


热部署常用实现方案


然而,在 Java 中实现热部署并不是一件容易的事情。


1. ClassLoader 重新加载


Java 作为一种静态语言,类一经加载到 JVM 中,便无法修改,而且同一个类加载器对于同一个类只能加载一次,因此热部署常用的一种解决方案是创建新的 ClassLoader 加载新的 class 文件,然后替换之前创建的对象。


2. Java Agent


另一种解决方案是使用 Java Agent,Java Agent 可以理解为 JVM 层面的 AOP,可以在类加载时将 class 文件的内容修改为自定义的内容,并且支持修改已加载到 JVM 的 class,不过对于已加载到 JVM 的 class 只能修改方法体,因此具有一定的局限性。


spring-boot-devtools


spring-boot-devtools 快速上手

Spring Boot 通过 Maven 插件 spring-boot-devtools 提供对热部署的支持,只要将这个依赖添加到类路径,当类路径下的 class 发生变化时就会自动重启应用上下文,从而使用新的 class 文件中的代码。这个插件的坐标如下。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <optional>true</optional>
</dependency>

引入依赖时指定 optional 避免依赖传递,同时 spring-boot-maven-plugin 打包时也会忽略 spring-boot-devtools 插件。


spring-boot-devtools 功能特性


spring-boot-devtools 作为一个开发环境的插件,不仅支持热部署,具体来说有以下特性。


将第三方库(如 thymeleaf、freemarker)缓存相关的属性配置到 Environment,以便开发环境禁用缓存。

类路径下的 class 文件发生变更时触发 ApplicationContext 重启。

内嵌 LiveReload 服务器,资源发生变化时触发浏览器刷新。

支持全局配置,所有的 Spring Boot 应用的 spring-boot-devtools 插件使用同一套配置,如指定检查 class 文件变化的轮训时间。

支持远程触发热部署(不推荐使用)。


spring-boot-devtools 实现原理


虽然 spring-boot-devtools 支持添加配置用来修改自身行为,通常情况下我们使用默认配置即可,不再赘述配置相关内容。下面我们把重点放到 spring-boot-devtools 热部署的具体实现上。


spring-boot-devtools 热部署使用了 ClassLoader 重新加载 的实现方式,具体来说使用两类 ClassLoader,一类是加载第三方库的 CladdLoader,另一类是加载应用类路径下 class 的自定义 RestartClassLoader,应用类路径下 class 变化会触发应用重新启动,由于不需要重新加载第三方库的 class,因此相比重新启动整个应用速度上会快一些。


那具体到代码层面怎么实现的呢?spring-boot-devtools 利用 Spring Boot 应用自动装配的特性,在 spring.factories 文件中添加了很多配置。


64.png


1. SpringApplication 启动时触发应用重启


spring-boot-devtools 通过 RestartApplicationListener 监听 SpringApplication 的启动,监听到启动时关闭当前线程,并重启应用,重启时使用自定义的 RestartClassLoader 加载应用类路径下的 class。监听 Spring Boot 应用启动的核心代码如下。


public class RestartApplicationListener implements ApplicationListener<ApplicationEvent>, Ordered {
  @Override
  public void onApplicationEvent(ApplicationEvent event) {
    if (event instanceof ApplicationStartingEvent) {
      onApplicationStartingEvent((ApplicationStartingEvent) event);
    }
    ... 省略部分代码
  }
  private void onApplicationStartingEvent(ApplicationStartingEvent event) {
    String enabled = System.getProperty(ENABLED_PROPERTY);
    if (enabled == null || Boolean.parseBoolean(enabled)) {
      String[] args = event.getArgs();
      DefaultRestartInitializer initializer = new DefaultRestartInitializer();
      boolean restartOnInitialize = !AgentReloader.isActive();
      // 初始化 Restarter
      Restarter.initialize(args, false, initializer, restartOnInitialize);
    } else {
      Restarter.disable();
    }
  }
}

RestartApplicationListener 监听到 SpringApplication 启动事件后开始对 Restarter 进行初始化,Restarter 是重启应用的核心类,Restarter 初始化过程仅仅实例化自身并调用其初始化方法,初始化的核心代码如下。


public class Restarter {
  protected void initialize(boolean restartOnInitialize) {
    preInitializeLeakyClasses();
    if (this.initialUrls != null) {
      this.urls.addAll(Arrays.asList(this.initialUrls));
      if (restartOnInitialize) {
        this.logger.debug("Immediately restarting application");
        immediateRestart();
      }
    }
  }
  private void immediateRestart() {
    try {
      // 等待新线程执行结束
      getLeakSafeThread().callAndWait(() -> {
        start(FailureHandler.NONE);
        cleanupCaches();
        return null;
      });
    } catch (Exception ex) {
      this.logger.warn("Unable to initialize restarter", ex);
    }
    // 再通过抛出异常的方式退出主线程
    SilentExitExceptionHandler.exitCurrentThread();
  }
}


Restarter 首先收集类路径的 URL,然后立即调用 #immediateRestart 方法重启应用,待新线程重启应用后再通过抛出异常的方式关闭 main 线程。启动应用的核心代码如下。


public class Restarter {
  private Throwable doStart() throws Exception {
    Assert.notNull(this.mainClassName, "Unable to find the main class to restart");
    URL[] urls = this.urls.toArray(new URL[0]);
    ClassLoaderFiles updatedFiles = new ClassLoaderFiles(this.classLoaderFiles);
    // 使用新的类加载器加载变化的类
    ClassLoader classLoader = new RestartClassLoader(this.applicationClassLoader, urls, updatedFiles, this.logger);
    if (this.logger.isDebugEnabled()) {
      this.logger.debug("Starting application " + this.mainClassName + " with URLs " + Arrays.asList(urls));
    }
    return relaunch(classLoader);
  }
}


Restarter 先根据类路径下 URL 收集文件系统中的 class 文件到 ClassLoaderFiles,然后使用新的类加载器 RestartClassLoader 对应用重启,剩下的就很简单了,直接调用 main 方法即可。


2. 类路径 class 文件变化时触发应用重启


除了首次应用启动时切换 ClassLoader 重启应用,对开发者而言,最重要的就是 class 文件发生变化时重启应用了。自动配置类位于 LocalDevToolsAutoConfiguration.RestartConfiguration,spring-boot-devtools 提供了一个 ClassPathFileSystemWatcher bean 用于监听 class 文件的变化。


@Configuration(proxyBeanMethods = false)
@ConditionalOnInitializedRestarter
@EnableConfigurationProperties(DevToolsProperties.class)
public class LocalDevToolsAutoConfiguration {
  @Lazy(false)
  @Configuration(proxyBeanMethods = false)
  @ConditionalOnProperty(prefix = "spring.devtools.restart", name = "enabled", matchIfMissing = true)
  static class RestartConfiguration {
    @Bean
    @ConditionalOnMissingBean
    ClassPathFileSystemWatcher classPathFileSystemWatcher(FileSystemWatcherFactory fileSystemWatcherFactory,
                                ClassPathRestartStrategy classPathRestartStrategy) {
      URL[] urls = Restarter.getInstance().getInitialUrls();
      ClassPathFileSystemWatcher watcher = new ClassPathFileSystemWatcher(fileSystemWatcherFactory,
          classPathRestartStrategy, urls);
      watcher.setStopWatcherOnRestart(true);
      return watcher;
    }
    }
}


ClassPathFileSystemWatcher 实现了 InitializingBean 接口,会在初始化时启动一个线程监听 class 文件的变化,然后发送一个 ClassPathChangedEvent 事件,因此 spring-boot-devtools 还提供了一个对应的监听器。


@Configuration(proxyBeanMethods = false)
@ConditionalOnInitializedRestarter
@EnableConfigurationProperties(DevToolsProperties.class)
public class LocalDevToolsAutoConfiguration {
  @Lazy(false)
  @Configuration(proxyBeanMethods = false)
  @ConditionalOnProperty(prefix = "spring.devtools.restart", name = "enabled", matchIfMissing = true)
  static class RestartConfiguration {
    @Bean
    ApplicationListener<ClassPathChangedEvent> restartingClassPathChangedEventListener(
        FileSystemWatcherFactory fileSystemWatcherFactory) {
      return (event) -> {
        if (event.isRestartRequired()) {
          // 类路径发生变化时重启应用上下文
          Restarter.getInstance().restart(new FileWatchingFailureHandler(fileSystemWatcherFactory));
        }
      };
    }
}


监听器监听到 class 文件变化后通过 Restarter 再次重启了应用,流程与首次重启时类似,不再赘述。


JRebel


除了 spring-boot-devtools,Spring 官方推荐的另一个热部署工具是 JRebel。JRebel 的核心是一个普通的 jar 包,内置了对多种框架的支持,通过 java -jar 启动时指定 -javaagent 即可使用 JRebel,而无需修改代码。同时 JRebel 也提供了多种的 IDE 插件,避免了手动启动指定 agent。


JRebel 在 Idea 中的使用


由于目前大家多使用 Idea 作为 IDE,因此这里介绍下 JRebel 在 Idea 中的使用。


1. 下载


首先在 Idea 的插件市场搜索 JRebel,并选择 JRebel and XRebel,然后 install,之后重启 IDE 使插件生效。


65.png


2. 激活


然后点击 Help->JRebel->Activation 进入激活页面。


66.png


选择 Team URL,在 https://jrebel.qekang.com/ 网站可以查找 可用的 Team URL,然后输入任意邮箱即可激活。


3. 项目支持配置


选择 View->Tool Windows->JRebel 对项目进行配置。


67.png


勾选项目名称右侧的第一个复选框即可快速开启 JRebel 对项目的支持。此时将在 resources 目录下生成一个 rebel.xml 文件,这个文件用于配置 JRebel 监听的类路径。


4. 自动编译配置


访问 Setting,在 Compiler 页面下勾选 Build project automatically 开启自动构建功能。


68.png

访问 Setting 页面,在 System Settings 页面下勾选 Save file if the IDE is idle for


69.png


5. 启动项目


然后使用 JRebel 进行 debug 就可以啦,当代码变更触发 IDE 构建后,JRebel 会自动使用新的 class 代码。


image.png


JRebel 实现原理


虽然 JRebel 在 Idea 中的使用方式比较简单,但当我试图探究其实现方式时却发现并没有那么容易。网上的文章前篇一律介绍的是其使用方式,即便其官网也只是简简单单概述为:JRebel 主要在 ClassLoader 级别与 JVM 及应用集成。它不会创建新的 ClassLoader,当监测到 class 文件发生变化时通过扩展类加载器更新应用。


从官网的描述来看,也并没有深入到具体的实现方式上,真是 听君一席话,如听一席话,由于 JRebel 并未开源,并且其提供的 .jar 文件也进行了代码混淆,因此这里只能对其实现方式进行推测,并逐步验证。这里将推测及分析过程分享给大家。


推测:JRebel 通过 Java Agent 进行实现


JRebel 核心为一个普通的 jar 包,并通过 -javaagent 指定这个 jar 包,因此可以猜测它使用到了 Java Agent 的某些特性。


Java Agent 的主要作用为替换加载的 class,运行时修改方法体。由于 JRebel 支持在运行时添加、删除方法,因此 JRebel 必然不是通过运行时修改已加载到 JVM 的类路径下 class 方法体的方式来实现热部署的。那么大概率 JRebel 是修改了某些加载到 JVM 的 class。


推测:JRebel 会在 class 文件发生变化后重新加载 class 文件


推测 JRebel 使用了 Java Agent 之后,我们还是不能了解其主要实现方式,不过当我们的 class 文件发生变动后,JRebel 必然会重新加载变动后的 class 文件,以便执行新的代码,因此我们可以在 ClassLoader 加载类的某个流程上打上断点,以便查看堆栈信息。


Spring Boot 项目的示例代码如下。


@SpringBootApplication
@RestController
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
    @GetMapping("/hello")
    public String hello() {
        return "hallo";
    }
}


不过呢,由于我们误将 hello 拼成了 hallo,因此需要修改代码,改动后成功进入了我们的断点。


71.png


JRebel 在 rebel-change-detector-thread 线程监测 class 文件的变动,文件变动后使用 AppClassLoader 加载了 com.zzuhkp.DemoApplication 开头的类,并且类名后还带了 $$M$_jr_ 开头的后缀。可以想到的是同一个 ClassLoader 只能加载一个类,因此 JRebel 对类名进行了修改。这也是官网所描述的,不创建新的 ClassLoader,当 class 发生变化时更新应用。


问题:JRebel 如何替换新的 class 的?


JRebel 加载新的 class 后必然会实例化,然后替换旧的对象,那么它是怎么实例化的呢?有多个构造方法时又该如何选择?


修改我们的示例代码如下。


@SpringBootApplication
@RestController
public class DemoApplication {
    private String str;
    public DemoApplication() {
        this.str = "你好";
    }
    public DemoApplication(String str) {
        this.str = "你好呀";
    }
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
    @GetMapping("/hello")
    public String hello() {
        return str;
    }
}


这里添加了一个成员变量和两个构造方法,访问 /hello 接口,发现返回 你好 二字,可以看出 JRebel 会自动使用无参的构造方法实例化对象。


那么 JRebel 又是怎么替换旧对象的呢?我们知道,对于注解驱动的 Spring MVC,Controller 方法会被当做一个 handler 处理请求,如果新添加一个 Controller 方法,那么它必然被注册为 handler 才能处理请求。我们添加一个 hello2 的方法,并在注册 handler 的流程上打断点。


    @GetMapping("/hello2")
    public String hello2() {
        return str;
    }


当访问 /hello2 果然进入了断点。


72.png


从堆栈信息来看,多出了 JRebel 的相关类,可以断定,JRebel 对 Spring 的某些 class 做出了修改,当 class 发生变动后,JRebel 自动使用新的 class 实例化的对象注册到 Spring 内部。


JRebel 小结


从上述推测和验证的过程来看,JRebel 对热部署的支持利用 Java Agent 修改了 Spring 的某些 class,当应用的 class 发生变化时,JRebel 自动加载新的 class 文件,并利用 Spring 的API 替换 Spring 中的旧对象,从而支持了 Spring 的热部署。


总结

由于 spring-boot-devtools 会引入新的依赖,并且 class 文件变更会引起应用重启,而 JRebel 只会加载变动的 class 并利用 Spring 的 API 替换新的对象,因此 JRebel 比 spring-boot-devtools 会快上不少,相对来说比较个人比较支持使用 JRebel。


目录
相关文章
|
29天前
|
缓存 IDE Java
SpringBoot入门(7)- 配置热部署devtools工具
SpringBoot入门(7)- 配置热部署devtools工具
49 1
SpringBoot入门(7)- 配置热部署devtools工具
|
1月前
|
缓存 IDE Java
SpringBoot入门(7)- 配置热部署devtools工具
SpringBoot入门(7)- 配置热部署devtools工具
46 2
 SpringBoot入门(7)- 配置热部署devtools工具
WXM
|
5月前
|
Java 应用服务中间件 Maven
|
1月前
|
存储 运维 安全
Spring运维之boot项目多环境(yaml 多文件 proerties)及分组管理与开发控制
通过以上措施,可以保证Spring Boot项目的配置管理在专业水准上,并且易于维护和管理,符合搜索引擎收录标准。
43 2
|
2月前
|
SQL JSON Java
mybatis使用三:springboot整合mybatis,使用PageHelper 进行分页操作,并整合swagger2。使用正规的开发模式:定义统一的数据返回格式和请求模块
这篇文章介绍了如何在Spring Boot项目中整合MyBatis和PageHelper进行分页操作,并且集成Swagger2来生成API文档,同时定义了统一的数据返回格式和请求模块。
76 1
mybatis使用三:springboot整合mybatis,使用PageHelper 进行分页操作,并整合swagger2。使用正规的开发模式:定义统一的数据返回格式和请求模块
|
2月前
|
Java 测试技术 开发者
springboot学习四:Spring Boot profile多环境配置、devtools热部署
这篇文章主要介绍了如何在Spring Boot中进行多环境配置以及如何整合DevTools实现热部署,以提高开发效率。
102 2
|
2月前
|
缓存 NoSQL Java
Springboot自定义注解+aop实现redis自动清除缓存功能
通过上述步骤,我们不仅实现了一个高度灵活的缓存管理机制,还保证了代码的整洁与可维护性。自定义注解与AOP的结合,让缓存清除逻辑与业务逻辑分离,便于未来的扩展和修改。这种设计模式非常适合需要频繁更新缓存的应用场景,大大提高了开发效率和系统的响应速度。
87 2
|
4月前
|
前端开发 Java 开发者
Spring Boot DevTools 热部署神器,助你在开发浪潮中乘风破浪,一骑绝尘!
【8月更文挑战第29天】在快速迭代的软件开发领域,高效开发至关重要。Spring Boot DevTools 作为一款优秀的热部署工具,可自动重新加载代码修改,无需手动重启应用,大幅节省时间,即时预览修改效果,简化开发流程。通过简单示例,展示了其自动刷新静态资源和模板文件的功能,有效提升了开发效率,使开发者更专注于功能实现。它就像是开发者的得力助手,显著减少等待时间,带来更高效、流畅的开发体验。
50 0
|
5月前
|
Java Maven 开发工具
Spring Boot开发环境的搭建
IDEA Maven JDK Spring Boot
52 3
|
4月前
|
Java
SpringBoot 开发环境热部署
SpringBoot 开发环境热部署
34 0