Spring DevTools
是干嘛的。试想一个场景:”一个设计师,在桌面端应用进行手机端APP UI的绘制修改工作,放在桌面上的手机展示着设计师正在编辑的图,设计师每进行一次保存,手机上的画面就会进行一次刷新,只需要低头就可以看到效果”。DevTools
就是这种效率提升工具,在你完成代码修改后自动刷新应用,而不需要你再去在意重启应用生效的问题。
你得先知道这个
原本是想照着老规矩,先上一个工具的使用例子,但是奈何DevTools
的使用实在是太简单了,只需要引入一个包,你就完成了全部的准备工作。
1 | # Apache Maven |
1 | # Gradle |
DevTools
的引入固然简单快捷,但是在去了解这个组件怎么工作之前我们还需要了解几个小玩意,至于为什么要了解,过会儿你就知道了。
ClassLoader
类加载器这玩意想必各位应该是不陌生,至少应该知道我们程序运行时加载类和资源的工作都是由它完成的。编译器会将我们的Java源文件编译成Class文件,而后运行时会由类加载器查找运行所需的类,转化为byte数值,交由native逻辑解析包装为InstanceKlass
并装载进Java虚拟机的元空间,这是Java类的大致加载流程。
在我们普通的应用开发过程中,大抵接触到最多的类加载器就是AppClassLoader
,它会加载classpath
下,或者说我们编写的类。而其之上的ExtClassLoader
则负责加载$JAVA_HOME/lib/ext
,即JRE
的扩展类。最上层的BootstrapClassLoader则加载的是Java程序的基础rt.jar
。ClassLoader间具有一定的父子层级关系,这就扯出来了到处都在提的双亲委派逻辑:在试图加载一个类时,类加载器会先尝试让父加载器先进行加载,若父加载器无法加载,才会由自身对类进行加载
。
要注意一点,不同的ClassLoader
所加载的类是相互隔离的,什么叫相互隔离?在native逻辑层面,ClassLoaderA
去尝试加载com.shawjie.Staff
,在解析包装为InstanceKlass
实例之后,会将其保存在自己的Directory中,而后ClassLoaderB
也尝试加载了Staff
类,解析包装完成后,也会将其保存在自己的Direcotory中。虽然在概念上加载的都是Staff
类,但是在本质上,这是两个InstanceKlass
实例,被保存在了不同的Directory中。因为都是native层面的东西,我这么说可能不太好理解,我们可以直接看一段简单的逻辑。
1 | // 定义Staff类 com.shaw.demo.classloader.Staff |
那么,先来说说为什么采用双亲委派的设计逻辑。依着上面的例子,若是加载器A/B都通过自己对Staff类进行加载,那么在同一个应用中,一个Staff类会有两个类定义,无法互相转换,一般没有特殊需求的话,这样同一个类的两个类定义于我们是不利的。但若是依据双亲委派模型,无论是类加载器A还是类加载器B,都会先通过其父加载器AppClassLoader
对Staff类进行加载,这样得到的类定义也是一致的。
回到我们最初的那个目的,在对代码完成修改之后应用自动刷新,不需要重启应用,以实现热部署,这要怎么实现呢?结合我们刚才了解到的几点:
- 当运行过程中需要这个类的时候,会对类执行加载操作
- 每个类加载器所加载的类定义在Native层面是相互隔离的
- 类加载器的默认加载逻辑是双亲委派,但是加载类的逻辑可以被重写
基于这些点,结合我们的实际开发场景,我们项目所引用的外部Jar
包是确定的,我们不会其内部类的逻辑,我们自己所编写的类是不确定的,在开发过程中,随时有可能对这些类进行修改,我们开发中的一些静态资源,如模板框架所使用的页面模板,或Css、Js之类的也是不确定的(虽然说现在大部分情况前后端分离一般也用不上)。我们是不是可以引入一个自定义类加载器,这个类加载器只负责加载我们自己编写的类,而引入的Jar
包,则交给AppClassLoader
进行加载,当我们对编写的类或者静态资源进行修改时,触发一个信号,而后丢弃老的自定义加载器,新建新自定义加载器,对修改后的类进行解析加载,这样是不是就能达到我们热部署的目的呢?
大体来说,是的。而且Spring DevTools
正巧也是这么干的,那既然我们也有了大致思路,不如就一起来看看Spring是如何实现制这个需求的好了。哦对了,它的实现里还有部分Applcation Listener和Spring 自动装配 / SPI相关的内容,了解了这些,看这篇内容的时候会更舒心哦,正巧这些我也有写,可以点这里去我的博客看看。
正主 / DevTools
在正文开始之前我们要对DevTools
有个大概的认知。DevTools
是SpringBoot项目的一个开发组件,虽然官方宣称The spring-boot-devtools module can be included in any project to provide additional development-time features. (可以被包含在任何项目中以提供附加的即时开发特性),但是在DevTools的包中包含了大部分Spring运行必须的的组件,包括Spring-Context、AutoConfiguration等,相对来说是一个比较重的部件,而并非独立的轻量级开发者工具。所以一般情况下我还是只建议基于SpringBoot的项目使用该组件。本文相关内容依旧还是可以基于
v2.2.9.RELEASE 版本的 spring-boot-smoke-tests下的子项目
spring-boot-smoke-test-devtools`进行辅助阅读理解。
先找到自定义加载器
在上一小节的末尾,我们提到了需要引入一个自定义的类加载器加载我们自己编写的类,那么这个自定义加载器在DevTools
里是什么呢?诶,还是脱离不开我们的自动装配模块AutoConfiguration
。在spring-boot-devTools
项目中我们可以找到Springd
的SPI文件spring.factories
,让我们来看看它的具体内容:
1 | # Application Listeners |
DevTools
的SPI文件不止这些内容,我择了其中与我们内容相关性较强的配置信息出来。关于SPI的加载逻辑可以看看我之前写的自动装配篇内容,这边就不做过多讲解分析了。SpringBoot应用在启动过程中,会加载类路径下所有的spring.factories
文件,并在初始化SpringApplication
对象时,会将SPI文件中ApplicationListener
配置指向的监听器配置进监听器列表中。
1 | // org.springframework.boot.SpringApplication#SpringApplication |
Spring ApplicationListener
我们在上一篇内容有讲到过,我们可以具体分析一下RestartApplicationListener
所接收的事件及其处理流程。
RestartApplicationListener
实现了Ordered
接口,并标记其优先级为最高,这意味着在装载监听器列表时,这个监听器会被第一个执行。其接接收得事件类型是ApplicationEvent
,即ApplicationEvent
得所有子类事件被触发时都会通知到这个监听器。ApplicationEvent
得子事件有很多,如ApplicationStartingEvent
、ApplicationEvnviromentPrepareEvnet
、ApplicationPrepareEvent
等,几乎所有Spring应用相关得事件都继承于此,但我们无需关注那么多,这回我们主要关注ApplicationStartingEvent
事件就好了,这个事件会在SpringApplication#Run
方法中通过listeners.starting()
触发,我们可以看看RestartApplicationListener
对于这个事件处理得具体实现。
1 | // org.springframework.boot.devtools.restart.RestartApplicationListener#onApplicationStartingEvent |
跟到这,可以说我们已经近乎贴到了真相。Restarter
实例的initialize
方法会通过在构造方法里初始化的LeakSafeThread(泄露安全线程)
通过start() + join()
的模式插入到当前线程进行具体Restarter
的启动流程。由于这段逻辑中间的碎片方法比较多,就让我们直接跳到核心部分。
1 | // org.springframework.boot.devtools.restart.Restarter#doStart |
在ResarterLauncher
线程的run
方法中,使用方法反射调用了我们启动类的main方法,并原封不动的传入了启动参数,悄咪咪的用restartMain
线程置换掉了我们默认的main
线程,重新启动了一次我们的应用。那main
线程又去哪呢?还记得我们刚才说的被插入到main线程的LakeSafeThread
线程么?在RestarterLauncher
完成再次启动应用的流程后,泄露安全线程因为所有逻辑已执行完毕且守护线程已关闭,所以会直接退出,而main线程则会通过SilentExitExceptionHandler.exitCurrentThread();
方法调用被退出。(线程退出方法在org.springframework.boot.devtools.restart.Restarter#immediateRestart
,感兴趣的朋友可以去瞅瞅)
换句话说,之后承载我们Spring
应用运行的是restartMain
线程而非平时的main
线程。这两者的区别在于restartMain
线程的上下文类加载器是自定义的restartClassLoader
,而main
线程的上下文类加载器是AppClassLoader
。
在ClassLoader
小节中我特意加粗了这部分描述:”运行时会由类加载器查找运行所需的类“。在第一次应用启动时,由于启动流程被RestartApplicationListener
截胡并重新以RestartLauncher
启动应用,在此之前Spring还并没有到Bean扫描阶段,因此我们所编写的类也还未被加载。在第二次通过反射方法调用启动应用之后,不会再被截胡,正常流转到Bean
扫描装配流程,此时才会由RestarterClassLoader
去尝试加载我们所编写的类。而RestartClassLoader
实现于URLClassLoader
基础之上(URLClassLoader
会尝试去构造时传入的URL下搜寻类,在RestartClassLoader
中就是我们的编写的类),并重写了loadClass
方法,若搜寻不到目标类,则会由父类加载器AppClassLoader
进行类加载动作。即RestartClassLoader
会加载所有运行时所用到的我们编写的类,而JAR中的类则交付予了AppClassLoader
或更上层的类加载器进行加载。
至此,我们完成了流程的第一步,找到了只加载我们的类的自定义类加载器。这是整个DevTools
比较精髓的部分,整体实现也相对复杂,也希望大家静下心就着代码慢慢理解。
怎么知道资源变动了
当然,光找到自定义类加载器还不够,DevTools
还需要知道我们对类、对静态资源的修改动作,并以此为依据触发信号通知我们的Restarter
执行重启操作。在DevTools
项目的spring.facotiries
文件中我们可以看到一个自动装配类LocalDevToolsAutoConfiguration
,让我们把它拎出来看看。(老规矩,我还是会忽略掉一些和本节相关性不强的内容)
1 |
|
ClassPathFileSystemWatch
实现了InitialingBean
,在被IoC容器进行管理之后,会通过钩子方法afterPropertiesSet
向监视器注册处理逻辑的监听器而后启动监听器。在监视器启动前,会针对可被修改资源创建快照,而后启动一个监视器线程,根据配置的轮询时间对可被修改资源创建快照,并将新快照与老快照进行对比,若快照文件集不同(对比文件集Hash)则会扫描更新具体被修改的文件列表(通过比对文件是否存在、长度、最后更新时间),并通知监视器内部的监听器。
监听器则会根据被修改的文件列表,判断其中是否含有除静态资源、测试类、git属性文件或自定义附加的一些文件之外的内容,如果有,则标记为需要重启,反之则标记为不需要重启。完成标记判断后,会通过ApplicationEventPubliusher
触发ClassPathChangedEvent
事件。诶,在刚刚的自动装配类里出现的监听器就派上用场了。
在监听器中通过restart
方法对应用进行自动重启。而具体操作逻辑就是关闭Spring应用上下文,清除缓存,尝试触发垃圾回收,以及执行对象的finalize
方法,Spring应用上下文关闭后,原本承载应用运行的RestartLauncher
也会停止,在停止流程完成后,会通过doStart
方法启动应用,这个方法的执行逻辑已经在上一小节瞅过了,这边儿就不再重复了,而原本绑定在RestartLauncher
线程上的类加载器也会由于线程的停止被标记为不可达并等待GC的回收,应用启动之后又会有一个空的类加载器在等待着加载我们的类。
用文字书写的逻辑到底还是稍显宽泛,所有大伙儿可以先把流程了解一下,我们套进具体链路把实现再看一遍。(Ps: 接下来会有很长一段逻辑,但是我都标注了做用,可以慢慢看)
1 | // org.springframework.boot.devtools.classpath.ClassPathFileSystemWatcher#afterPropertiesSet |
小结
至此,DevTools
的主流程及其实现我们是走完了,也顺应着最初的推论找到了DevTools
的两个核心,自定义类加载器,及文件修改的监视器。总而言之ClassLoader
这玩意是个可玩性很高的东西,除了在本地开发实现热更新之外,你甚至能在生产环境通过它实现热部署,譬如Tomcat的WebappClassLoader
。当然,整个DevTools
工具除了自动重启的功能,还有自动刷新web以使其重新加载资源,以及我们内容里体到的LocalDevToolsAutoConfiguration
自然也有个相对的RemoteDevToolsAutoConfiguration
以实现远程自动重启更新应用的功能支持。
LiveReload
在自动装配类LocalDevToolsAutoConfiguration
里还有个LiveReloadServer
,与其配合的还有一个Chrome插件LiveReload。LiveReloadServer
在没有配置spring.devtools.livereload.enable=false
时会默认进行启动,并开启一个WebScoket与应用外部的插件建立联系。当我们的开发时,若只对静态资源做了修改,譬如Js、Css文件,文件修改监视器会发出标记为restartRequire
为false
的事件,而LiveReloadServerEventListener
在接受到这个事件之后会通过WebSocket向与之建立连接的客户端,也就是我们的浏览器插件发出一个重新加载信号,插件会触发window.localtion.reload()
方法以重新加载资源。
哦对了,被认为不需要进行重启的文件覆盖范围是META-INF/maven/**, META-INF/resources/**,resources/**,static/**,public/**,templates/**, **/*Test.class,**/*Tests.class,git.properties,META-INF/build-info.properties
,若还需要新增范围,可通过配置spring.devtools.restart.additionalExclude
,复写范围也可以通过配置spring.devtools.restart.exclude
。
RemoteDevTools
RemoteDevTools
支持远程自动重启更新应用,但是实现的主逻辑还是脱离不开最核心的自定义类加载器和文件变更监视器,与本地环境的区别也只是类加载器的重载动作在远端,文件扫描监视器的扫描动作在本地罢了。需要远端调试开发的应用配置spring.devtools.remote.secret
属性,这是一个开关,用于激活RemoteDevToolsAutoConfiguration
自动装配类,也用于与远端连接进行身份验证,远端的应用在部署完成之后会向外暴露一个restart
接口,而本地的应用以RemoteSpringApplication
作为启动类,在ClassPathChangedEvent
事件被触发时,调用接口上传被更新的文件并在远端触发restart
动作,而后流程与本地环境开发无异。
杂的
在读DevTools
的时候看到了一段Spring挺有意思的源码,是用于在Restarter
执行重启动作时候用于清除软引用 / 弱引用的,但是依据默认逻辑并不会执行,觉得有意思所以放出来给大家看看。
1 | // org.springframework.boot.devtools.restart.Restarter#forceReferenceCleanup |
尾巴
四月初去长沙转了一圈儿,长沙的东西确实好吃,茶颜悦色也确实好喝。人嘛,总是怠惰的,回来之后开始准备这个月的内容,折腾着这那的到底还是磨蹭到了近乎月底。Spring的组件我觉着我已经写了挺多的,剩下一些可能还会揉在一起再写一篇?大概吧。再之后…可能会去看点别的内容,譬如RocketMq
、Kafka
之类的。总之,还是谢谢你能看到这里,也辛苦我写到这里,爱人如己,这是我这段时间听到的一个最喜欢的词,希望你能加油,我也是…晚安。