Spring DevTools 之 你总是喜新厌旧
ShawJie

Spring DevTools是干嘛的。试想一个场景:”一个设计师,在桌面端应用进行手机端APP UI的绘制修改工作,放在桌面上的手机展示着设计师正在编辑的图,设计师每进行一次保存,手机上的画面就会进行一次刷新,只需要低头就可以看到效果”。DevTools就是这种效率提升工具,在你完成代码修改后自动刷新应用,而不需要你再去在意重启应用生效的问题。

你得先知道这个

​ 原本是想照着老规矩,先上一个工具的使用例子,但是奈何DevTools的使用实在是太简单了,只需要引入一个包,你就完成了全部的准备工作。

1
2
3
4
5
# Apache Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
</dependency>
1
2
# Gradle
implementation 'org.springframework.boot:spring-boot-devtools:2.4.4'

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 定义Staff类 com.shaw.demo.classloader.Staff
public class Staff {}

/*
* 定义类加载器A/B 并重写了loadClass方法
* A/B类加载器逻辑相同 只有类名不同 因此只在此写出ClassLoaderA定义
*/
public class ClassLoaderA extends URLClassLoader {
public ClassLoaderA(URL[] urls) {
super(urls);
}

@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
if (name.startsWith("com.shaw.demo.classloader")) {
return findClass(name);
}
return super.loadClass(name);
}
}

public class ClassLoaderDemo {
public static void main(String[] args) throws Exception {
// 获取Classpath路径
URL url = Thread.currentThread().getContextClassLoader().getResource("");
URL[] urls = {url};

// 初始化类加载器A/B
ClassLoaderA classLoaderA = new ClassLoaderA(urls);
ClassLoaderB classLoaderB = new ClassLoaderB(urls);

// 通过类加载器A/B对Staff类进行加载
Class loadedClassA = classLoaderA.loadClass("com.shaw.demo.classloader.Staff");
Class loadedClassB = classLoaderB.loadClass("com.shaw.demo.classloader.Staff");

// 对比加载结果
// false
System.out.println(loadedClassA == loadedClassB);
}
}

​ 那么,先来说说为什么采用双亲委派的设计逻辑。依着上面的例子,若是加载器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
2
3
4
5
6
7
8
9
10
11
# Application Listeners
org.springframework.context.ApplicationListener=\
org.springframework.boot.devtools.restart.RestartApplicationListener

# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.devtools.autoconfigure.LocalDevToolsAutoConfiguration

# Environment Post Processors
org.springframework.boot.env.EnvironmentPostProcessor=\
org.springframework.boot.devtools.env.DevToolsPropertyDefaultsPostProcessor

DevTools的SPI文件不止这些内容,我择了其中与我们内容相关性较强的配置信息出来。关于SPI的加载逻辑可以看看我之前写的自动装配篇内容,这边就不做过多讲解分析了。SpringBoot应用在启动过程中,会加载类路径下所有的spring.factories文件,并在初始化SpringApplication对象时,会将SPI文件中ApplicationListener配置指向的监听器配置进监听器列表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
// org.springframework.boot.SpringApplication#SpringApplication
public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources) {
this.resourceLoader = resourceLoader;
// 配置主要来源类
this.primarySources = new LinkedHashSet<>(Arrays.asList(primarySources));
this.webApplicationType = WebApplicationType.deduceFromClasspath();
// 从SPI文件中读取配置初始化器
setInitializers((Collection) getSpringFactoriesInstances(ApplicationContextInitializer.class));
// 从SPI文件中读取配置初始化器
setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));
// 推断Main启动类
this.mainApplicationClass = deduceMainApplicationClass();
}

Spring ApplicationListener我们在上一篇内容有讲到过,我们可以具体分析一下RestartApplicationListener所接收的事件及其处理流程。

RestartApplicationListener实现了Ordered接口,并标记其优先级为最高,这意味着在装载监听器列表时,这个监听器会被第一个执行。其接接收得事件类型是ApplicationEvent,即ApplicationEvent得所有子类事件被触发时都会通知到这个监听器。ApplicationEvent得子事件有很多,如ApplicationStartingEventApplicationEvnviromentPrepareEvnetApplicationPrepareEvent等,几乎所有Spring应用相关得事件都继承于此,但我们无需关注那么多,这回我们主要关注ApplicationStartingEvent事件就好了,这个事件会在SpringApplication#Run方法中通过listeners.starting()触发,我们可以看看RestartApplicationListener对于这个事件处理得具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// org.springframework.boot.devtools.restart.RestartApplicationListener#onApplicationStartingEvent
private void onApplicationStartingEvent(ApplicationStartingEvent event) {
/*
* 从系统变量中读取 spring.devtools.restart.enabled
* 判断是否要进行重开器的初始化动作 于此也意味着这个属性配置在配置文件中是无效的
*/
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.initialize(args, false, initializer, restartOnInitialize);
} else {
// 标记重启器不可用
Restarter.disable();
}
}

// org.springframework.boot.devtools.restart.Restarter#initialize
public static void initialize(String[] args, boolean forceReferenceCleanup, RestartInitializer initializer,
boolean restartOnInitialize) {
/*
* 双重检查锁初始化了Restarter单例对象
* 并在对象中保存了包括启动类,可被修改资源的路径,启动参数等信息
* 由于是单例对象 所以localInstance对象只会被赋值一次
* 即 Restarter.initialize()方法也只会被执行一次
*/
Restarter localInstance = null;
synchronized (INSTANCE_MONITOR) {
if (instance == null) {
localInstance = new Restarter(Thread.currentThread(), args, forceReferenceCleanup, initializer);
instance = localInstance;
}
}
if (localInstance != null) {
// 触发Restarter的初始化动作
localInstance.initialize(restartOnInitialize);
}
}

​ 跟到这,可以说我们已经近乎贴到了真相。Restarter实例的initialize方法会通过在构造方法里初始化的LeakSafeThread(泄露安全线程)通过start() + join()的模式插入到当前线程进行具体Restarter的启动流程。由于这段逻辑中间的碎片方法比较多,就让我们直接跳到核心部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// org.springframework.boot.devtools.restart.Restarter#doStart
private Throwable doStart() throws Exception {
// 将我们的可被修改资源的路径构建为URL数组
URL[] urls = this.urls.toArray(new URL[0]);
// 终究还是让我们找到了 自定义的类加载器
ClassLoader classLoader = new RestartClassLoader(this.applicationClassLoader, urls, updatedFiles, this.logger);
return relaunch(classLoader);
}

protected Throwable relaunch(ClassLoader classLoader) throws Exception {
/*
* RestartLauncher 继承于Thread类 在构造方法中
* 将刚创建的自定义类加载器设定为上下文的类加载器
* 并保存了启动类,启动参数信息
*/
RestartLauncher launcher = new RestartLauncher(classLoader, this.mainClassName, this.args,
this.exceptionHandler);
// 启动线程
launcher.start();
// 切入当前线程
launcher.join();
return launcher.getError();
}

​ 在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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Configuration(proxyBeanMethods = false)
/*
* 自动装配的OnCondition逻辑
* 在Restarter对象实例没有被初始化时 该类不会被自动装配
*/
@ConditionalOnInitializedRestarter
// 将配置信息装载进DevToolsProperties对象中
@EnableConfigurationProperties(DevToolsProperties.class)
public class LocalDevToolsAutoConfiguration {

@Configuration(proxyBeanMethods = false)
/*
* 只有当显示配置了spring.devtools.restart.enable=false时
* 该配置类才不会被装载 反之则会进行装载配置动作
*/
@ConditionalOnProperty(prefix = "spring.devtools.restart", name = "enabled", matchIfMissing = true)
static class RestartConfiguration {

private final DevToolsProperties properties;

RestartConfiguration(DevToolsProperties properties) {
this.properties = properties;
}


// 这里创建了一个监听器,监听ClassPathChangedEvent事件
@Bean
ApplicationListener<ClassPathChangedEvent> restartingClassPathChangedEventListener(
FileSystemWatcherFactory fileSystemWatcherFactory) {
return (event) -> {
// 当事件标记为需要重启时通过Restarter实例的restart方法进行重启动作
if (event.isRestartRequired()) {
// 那么现在重启动作的执行者已经找到了 现在就要找到信号的发出者了
Restarter.getInstance().restart(new FileWatchingFailureHandler(fileSystemWatcherFactory));
}
};
}

/*
* 于此创建了一个ClassPathFileSystemWatcher对象
* 光从对象名称上就能知道这是个监控类文件系统的对象 我们大概能于此着手
*/
@Bean
@ConditionalOnMissingBean
ClassPathFileSystemWatcher classPathFileSystemWatcher(FileSystemWatcherFactory fileSystemWatcherFactory,
ClassPathRestartStrategy classPathRestartStrategy) {
// 这里获取到的initialUrls就是可被修改资源的路径
URL[] urls = Restarter.getInstance().getInitialUrls();
ClassPathFileSystemWatcher watcher = new ClassPathFileSystemWatcher(fileSystemWatcherFactory,
classPathRestartStrategy, urls);
// 配置监视器再重启时暂停工作
watcher.setStopWatcherOnRestart(true);
return watcher;
}
}
}

ClassPathFileSystemWatch实现了InitialingBean,在被IoC容器进行管理之后,会通过钩子方法afterPropertiesSet向监视器注册处理逻辑的监听器而后启动监听器。在监视器启动前,会针对可被修改资源创建快照,而后启动一个监视器线程,根据配置的轮询时间对可被修改资源创建快照,并将新快照与老快照进行对比,若快照文件集不同(对比文件集Hash)则会扫描更新具体被修改的文件列表(通过比对文件是否存在、长度、最后更新时间),并通知监视器内部的监听器。

​ 监听器则会根据被修改的文件列表,判断其中是否含有除静态资源、测试类、git属性文件或自定义附加的一些文件之外的内容,如果有,则标记为需要重启,反之则标记为不需要重启。完成标记判断后,会通过ApplicationEventPubliusher触发ClassPathChangedEvent事件。诶,在刚刚的自动装配类里出现的监听器就派上用场了。

​ 在监听器中通过restart方法对应用进行自动重启。而具体操作逻辑就是关闭Spring应用上下文,清除缓存,尝试触发垃圾回收,以及执行对象的finalize方法,Spring应用上下文关闭后,原本承载应用运行的RestartLauncher也会停止,在停止流程完成后,会通过doStart方法启动应用,这个方法的执行逻辑已经在上一小节瞅过了,这边儿就不再重复了,而原本绑定在RestartLauncher线程上的类加载器也会由于线程的停止被标记为不可达并等待GC的回收,应用启动之后又会有一个空的类加载器在等待着加载我们的类。

​ 用文字书写的逻辑到底还是稍显宽泛,所有大伙儿可以先把流程了解一下,我们套进具体链路把实现再看一遍。(Ps: 接下来会有很长一段逻辑,但是我都标注了做用,可以慢慢看)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// org.springframework.boot.devtools.classpath.ClassPathFileSystemWatcher#afterPropertiesSet
@Override
public void afterPropertiesSet() throws Exception {
/*
* 若有配置重启策略,则会将其配置进监视器的监听器中
* 用于后续的的逻辑处理 但是实际上如果没有重启策略
* 以导致于此不会向监视器中添加监听器 从而导致监视器即使检测到了文件改动
* 也没有任何逻辑会去把这个信号向外发出
*/
if (this.restartStrategy != null) {
FileSystemWatcher watcherToStop = null;
// 限制监视器是否要在重启阶段停止运行
if (this.stopWatcherOnRestart) {
watcherToStop = this.fileSystemWatcher;
}
// 添加监听器
this.fileSystemWatcher.addListener(
new ClassPathFileChangeListener(this.applicationContext, this.restartStrategy, watcherToStop));
}
// 启动监视器
this.fileSystemWatcher.start();
}

// org.springframework.boot.devtools.filewatch.FileSystemWatcher#start
public void start() {
synchronized (this.monitor) {
// 针对可被修改资源创建文件快照
saveInitialSnapshots();
if (this.watchThread == null) {
Map<File, FolderSnapshot> localFolders = new HashMap<>(this.folders);
// 配置监视器线程
this.watchThread = new Thread(
new Watcher(
this.remainingScans,
new ArrayList<>(this.listeners),
this.triggerFilter,
this.pollInterval,
this.quietPeriod,
localFolders)
);
this.watchThread.setName("File Watcher");
this.watchThread.setDaemon(this.daemon);
// 启动监视器线程
this.watchThread.start();
}
}
}

/*
* 由于构建监视器的Watcher对象实现了Runnable类,所以我们可以直接看它的run方法
* org.springframework.boot.devtools.filewatch.FileSystemWatcher.Watcher#run
*/
public void run() {
// 获取剩余扫描次数 若为-1则代表无限次扫描
int remainingScans = this.remainingScans.get();
while (remainingScans > 0 || remainingScans == -1) {
try {
if (remainingScans > 0) {
this.remainingScans.decrementAndGet();
}
// 具体扫描逻辑
scan();
}
catch (InterruptedException ex) {
/*
* 若配置了在重启阶段停止运行
* 则会在停止方法中触发中断信号退出当前线程
*/
Thread.currentThread().interrupt();
}
remainingScans = this.remainingScans.get();
}
}

// org.springframework.boot.devtools.filewatch.FileSystemWatcher.Watcher#scan
private void scan() throws InterruptedException {
// 根据配置的轮询间隔进行线程停顿
Thread.sleep(this.pollInterval - this.quietPeriod);
Map<File, FolderSnapshot> previous;
Map<File, FolderSnapshot> current = this.folders;
do {
previous = current;
// 获取当前的可变资源快照
current = getCurrentSnapshots();
Thread.sleep(this.quietPeriod);
}
// 将当前的快照和目前的快照进行对比
while (isDifferent(previous, current));
if (isDifferent(this.folders, current)) {
// 更新快照内容
updateSnapshots(current.values());
}
}

// org.springframework.boot.devtools.filewatch.FileSystemWatcher.Watcher#updateSnapshots
private void updateSnapshots(Collection<FolderSnapshot> snapshots) {
Map<File, FolderSnapshot> updated = new LinkedHashMap<>();
Set<ChangedFiles> changeSet = new LinkedHashSet<>();
for (FolderSnapshot snapshot : snapshots) {
// 获取到文件集快照
FolderSnapshot previous = this.folders.get(snapshot.getFolder());
updated.put(snapshot.getFolder(), snapshot);
// 通过对比文件是否存在、长度、最后修改时间判定文件是否被修改
ChangedFiles changedFiles = previous.getChangedFiles(snapshot, this.triggerFilter);
if (!changedFiles.getFiles().isEmpty()) {
changeSet.add(changedFiles);
}
}
if (!changeSet.isEmpty()) {
// 触发监听器
fireListeners(Collections.unmodifiableSet(changeSet));
}
this.folders = updated;
}

/*
* ClassPathFileChangeListener在监视器对象初始化时被添加
* 因此我们可以看看它的对于事件的处理逻辑
* 监视器内部的监听器不同于普通的SpringApplicationListener
* 因此也并不能通过实现bean进行自动注入
* org.springframework.boot.devtools.classpath.ClassPathFileChangeListener#onChange
*/
@Override
public void onChange(Set<ChangedFiles> changeSet) {
// 根据策略判断是否需要重启
boolean restart = isRestartRequired(changeSet);
// 通过ApplicationEventPublisher发布类路径修改的事件
publishEvent(new ClassPathChangedEvent(this, changeSet, restart));
}

小结

​ 至此,DevTools的主流程及其实现我们是走完了,也顺应着最初的推论找到了DevTools的两个核心,自定义类加载器,及文件修改的监视器。总而言之ClassLoader这玩意是个可玩性很高的东西,除了在本地开发实现热更新之外,你甚至能在生产环境通过它实现热部署,譬如Tomcat的WebappClassLoader。当然,整个DevTools工具除了自动重启的功能,还有自动刷新web以使其重新加载资源,以及我们内容里体到的LocalDevToolsAutoConfiguration自然也有个相对的RemoteDevToolsAutoConfiguration以实现远程自动重启更新应用的功能支持。

LiveReload

​ 在自动装配类LocalDevToolsAutoConfiguration里还有个LiveReloadServer,与其配合的还有一个Chrome插件LiveReloadLiveReloadServer在没有配置spring.devtools.livereload.enable=false时会默认进行启动,并开启一个WebScoket与应用外部的插件建立联系。当我们的开发时,若只对静态资源做了修改,譬如Js、Css文件,文件修改监视器会发出标记为restartRequirefalse的事件,而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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// org.springframework.boot.devtools.restart.Restarter#forceReferenceCleanup
private void forceReferenceCleanup() {
try {
final List<long[]> memory = new LinkedList<>();
while (true) {
/*
* 不断的向数组内存放大对象 用于促使GC回收软引用弱引用、
* 当抛出OutOfMemory错误时 确保已经清除了所有软引用/弱引用
*/
memory.add(new long[102400]);
}
}
catch (OutOfMemoryError ex) {
// 期望
}
}

尾巴

​ 四月初去长沙转了一圈儿,长沙的东西确实好吃,茶颜悦色也确实好喝。人嘛,总是怠惰的,回来之后开始准备这个月的内容,折腾着这那的到底还是磨蹭到了近乎月底。Spring的组件我觉着我已经写了挺多的,剩下一些可能还会揉在一起再写一篇?大概吧。再之后…可能会去看点别的内容,譬如RocketMqKafka之类的。总之,还是谢谢你能看到这里,也辛苦我写到这里,爱人如己,这是我这段时间听到的一个最喜欢的词,希望你能加油,我也是…晚安。