spring-boot-devtools热部署原理解析

前言

在开发项目过程中,当修改了某些代码后需要本地验证时,需要重启本地服务进行验证,启动这个项目,如果项目庞大的话还是需要较长时间的,spring开发团队为我们带来了一个插件:spring-boot-devtools,很好的解决了本地验证缓慢的问题。

简单介绍

该原理其实很好说明,就是我们在编辑器上启动项目,然后改动相关的代码,然后编辑器自动触发编译替换掉历史的.class文件后,项目检测到有文件变更后会重启srpring-boot项目。
可以看看官网的触发描述:
As DevTools monitors classpath resources, the only way to trigger a restart is to update the classpath. The way in which you cause the classpath to be updated depends on the IDE that you are using. In Eclipse, saving a modified file causes the classpath to be updated and triggers a restart. In IntelliJ IDEA, building the project (Build +→+ Build Project) has the same effect.
可以看到,我们引入了插件后,插件会监控我们classpath的资源变化,当classpath有变化后,会触发重启。很多文章会介绍如何配置自动触发,本人觉得不是很喜欢这种配置,当我们改动代码时,并不是改动一下就改动完的,我还是喜欢自己点击Build Project来触发重启。
The restart technology provided by Spring Boot works by using two classloaders. Classes that do not change (for example, those from third-party jars) are loaded into a base classloader. Classes that you are actively developing are loaded into a restart classloader. When the application is restarted, the restart classloader is thrown away and a new one is created. This approach means that application restarts are typically much faster than “cold starts”, since the base classloader is already available and populated.
这里提到了,该插件重启快速的原因:这里对类加载采用了两种类加载器,对于第三方jar包采用base-classloader来加载,对于开发人员自己开发的代码则使用restartClassLoader来进行加载,这使得比停掉服务重启要快的多,因为使用插件只是重启开发人员编写的代码部分。
我这边做个简单的验证:

@Component
@Slf4j
public class Devtools implements InitializingBean {


    @Override
    public void afterPropertiesSet() {
        log.info("guava-jar classLoader: " + BloomFilter.class.getClassLoader().toString());
        log.info("Devtools ClassLoader: " + this.getClass().getClassLoader().toString());
    }


}

这边先去除spring-boot-devtools插件,跑下工程:

2020-01-21 22:26:27.182  INFO 16648 --- [           main] com.devtools.example.Devtools            : guava-jar classLoader: sun.misc.Launcher$AppClassLoader@18b4aac2
2020-01-21 22:26:27.182  INFO 16648 --- [           main] com.devtools.example.Devtools            : Devtools ClassLoader: sun.misc.Launcher$AppClassLoader@18b4aac2

可以看到,BloomFilter(第三方jar包)和Devtools(自己编写的类)使用的都是AppClassLoader加载的。
我们现在加上插件,然后执行下代码:

启动服务:
2020-01-22 10:05:37.575  INFO 20940 --- [  restartedMain] com.devtools.example.Devtools            : guava-jar classLoader:sun.misc.Launcher$AppClassLoader@18b4aac2
2020-01-22 10:05:37.575  INFO 20940 --- [  restartedMain] com.devtools.example.Devtools            : Devtools ClassLoader: org.springframework.boot.devtools.restart.classloader.RestartClassLoader@3540628f

修改了代码插件自动重启:
2020-01-22 10:07:06.394  INFO 20940 --- [  restartedMain] com.devtools.example.Devtools            : guava-jar classLoader: sun.misc.Launcher$AppClassLoader@18b4aac2
2020-01-22 10:07:06.394  INFO 20940 --- [  restartedMain] com.devtools.example.Devtools            : Devtools ClassLoader: org.springframework.boot.devtools.restart.classloader.RestartClassLoader@769a8133


发现第三方的jar包的类加载器确实是使用的系统的类加载器,而我们自己写的代码的类加载器为RestartClassLoader,并且每次重启,类加载器的实例都会改变。

修改代码前.png
修改代码后.png

上图为代码修改前后类文件的变更。

代码解析

对于springboot的插件,都是从其插件中的spring.factories的配置文件开始的。想探寻其原理,可以看看这篇文章:SpringFactoriesLoader原理解析

spring-boot-devtools热部署原理解析_第1张图片
devtools-spring.factories.png

顺便推荐下:这个字体为最新的idea公司自己定制的字体,据说对程序员非常友好,使用了下,确实很香:https://www.jetbrains.com/lp/mono/
这里直接到要害,本地开发工具的配置类为:
org.springframework.boot.devtools.autoconfigure.LocalDevToolsAutoConfiguration


    /**
     * Local Restart Configuration.
     */
    @Configuration
    @ConditionalOnProperty(prefix = "spring.devtools.restart", name = "enabled", matchIfMissing = true)
    static class RestartConfiguration {

        @Autowired
        private DevToolsProperties properties;

        @EventListener
        public void onClassPathChanged(ClassPathChangedEvent event) {
            if (event.isRestartRequired()) {
                Restarter.getInstance().restart(
                        new FileWatchingFailureHandler(fileSystemWatcherFactory()));
            }
        }

        @Bean
        @ConditionalOnMissingBean
        public ClassPathFileSystemWatcher classPathFileSystemWatcher() {
            URL[] urls = Restarter.getInstance().getInitialUrls();
            ClassPathFileSystemWatcher watcher = new ClassPathFileSystemWatcher(
                    fileSystemWatcherFactory(), classPathRestartStrategy(), urls);
            watcher.setStopWatcherOnRestart(true);
            return watcher;
        }

        @Bean
        @ConditionalOnMissingBean
        public ClassPathRestartStrategy classPathRestartStrategy() {
            return new PatternClassPathRestartStrategy(
                    this.properties.getRestart().getAllExclude());
        }

        @Bean
        public HateoasObjenesisCacheDisabler hateoasObjenesisCacheDisabler() {
            return new HateoasObjenesisCacheDisabler();
        }

        @Bean
        public FileSystemWatcherFactory fileSystemWatcherFactory() {
            return new FileSystemWatcherFactory() {

                @Override
                public FileSystemWatcher getFileSystemWatcher() {
                    return newFileSystemWatcher();
                }

            };
        }

        private FileSystemWatcher newFileSystemWatcher() {
            Restart restartProperties = this.properties.getRestart();
            FileSystemWatcher watcher = new FileSystemWatcher(true,
                    restartProperties.getPollInterval(),
                    restartProperties.getQuietPeriod());
            String triggerFile = restartProperties.getTriggerFile();
            if (StringUtils.hasLength(triggerFile)) {
                watcher.setTriggerFilter(new TriggerFileFilter(triggerFile));
            }
            List additionalPaths = restartProperties.getAdditionalPaths();
            for (File path : additionalPaths) {
                watcher.addSourceFolder(path.getAbsoluteFile());
            }
            return watcher;
        }

    }

其中,

@EventListener
        public void onClassPathChanged(ClassPathChangedEvent event) {
            if (event.isRestartRequired()) {
                Restarter.getInstance().restart(
                        new FileWatchingFailureHandler(fileSystemWatcherFactory()));
            }
        }

该类为监听到classpath的classpath的文件变更后,会触发ClassPathChangedEvent 事件,并会触发springboot的重启,其内部原理为使用了spring的事件监听机制,如果想补补这方面的内容可以看看我自己 写的这篇文章:Spring观察者模式原理解析

文件监听机制

下面看看其文件是如何被监听的

@Bean
        @ConditionalOnMissingBean
        public ClassPathFileSystemWatcher classPathFileSystemWatcher() {
            URL[] urls = Restarter.getInstance().getInitialUrls();
            ClassPathFileSystemWatcher watcher = new ClassPathFileSystemWatcher(
                    fileSystemWatcherFactory(), classPathRestartStrategy(), urls);
            watcher.setStopWatcherOnRestart(true);
            return watcher;
        }

核心为该配置类,该类中包含了重启触发策略ClassPathRestartStrategy,以及监听的路径url和真正监听的实体类FileSystemWatcher。

public ClassPathFileSystemWatcher(FileSystemWatcherFactory fileSystemWatcherFactory,
            ClassPathRestartStrategy restartStrategy, URL[] urls) {
        Assert.notNull(fileSystemWatcherFactory,
                "FileSystemWatcherFactory must not be null");
        Assert.notNull(urls, "Urls must not be null");
        this.fileSystemWatcher = fileSystemWatcherFactory.getFileSystemWatcher();
        this.restartStrategy = restartStrategy;
        this.fileSystemWatcher.addSourceFolders(new ClassPathFolders(urls));
    }

打断点进去发现:


spring-boot-devtools热部署原理解析_第2张图片
调试.png

其传入的urls即为IDE编译代码的路径,已经其触发重启策略中已剔除掉配置项和一些测试的二进制文件。
该类ClassPathFileSystemWatcher实例化之后会调用其afterPropertiesSet方法(实现了InitializingBean)

@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();
    }

可以看到其加入了个ClassPathFileChangeListener对象,后续该对象是触发ClassPathChangedEvent事件的实现者。

ClassPathFileChangeListener(ApplicationEventPublisher eventPublisher,
            ClassPathRestartStrategy restartStrategy,
            FileSystemWatcher fileSystemWatcherToStop) {
        Assert.notNull(eventPublisher, "EventPublisher must not be null");
        Assert.notNull(restartStrategy, "RestartStrategy must not be null");
        this.eventPublisher = eventPublisher;
        this.restartStrategy = restartStrategy;
        this.fileSystemWatcherToStop = fileSystemWatcherToStop;
    }   
    @Override
    public void onChange(Set changeSet) {
        boolean restart = isRestartRequired(changeSet);
        publishEvent(new ClassPathChangedEvent(this, changeSet, restart));
    }

    private void publishEvent(ClassPathChangedEvent event) {
        this.eventPublisher.publishEvent(event);
        if (event.isRestartRequired() && this.fileSystemWatcherToStop != null) {
            this.fileSystemWatcherToStop.stop();
        }
    }

接着上述代码分析,this.fileSystemWatcher.start(),该代码为监听文件变化的核心,看看其源码

/**
     * Start monitoring the source folder for changes.
     */
    public void start() {
        synchronized (this.monitor) {
            saveInitialSnapshots();
            if (this.watchThread == null) {
                Map localFolders = new HashMap();
                localFolders.putAll(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();
            }
        }
    }

首先,先保存urls路径下文件及文件夹的快照信息,包括文件的长度以及其最后修改时间,该信息以FolderSnapshot、FileSnapshot类中进行保存。文件的快照信息在该属性中保存:fileSystemWatcher中的private final Map folders = new HashMap();
往下分析,创建了一个File Watcher的线程,将文件快照信息和listeners(触发文件变更事件)当做属性以Watcher对象(实现了Runnable接口)传入线程中,并启动线程。

private static final class Watcher implements Runnable {
private Watcher(AtomicInteger remainingScans, List listeners,
                FileFilter triggerFilter, long pollInterval, long quietPeriod,
                Map folders) {
            this.remainingScans = remainingScans;
            this.listeners = listeners;
            this.triggerFilter = triggerFilter;
            this.pollInterval = pollInterval;
            this.quietPeriod = quietPeriod;
            this.folders = folders;
        }

        @Override
        public void run() {
            int remainingScans = this.remainingScans.get();//-1(AtomicInteger)
            while (remainingScans > 0 || remainingScans == -1) {
                try {
                    if (remainingScans > 0) {
                        this.remainingScans.decrementAndGet();
                    }
                    scan();
                }
                catch (InterruptedException ex) {
                    Thread.currentThread().interrupt();
                }
                remainingScans = this.remainingScans.get();
            }
        };

        private void scan() throws InterruptedException {
            Thread.sleep(this.pollInterval - this.quietPeriod);
            Map previous;
            Map current = this.folders;
            do {
                previous = current;
                current = getCurrentSnapshots();
                Thread.sleep(this.quietPeriod);
            }
            while (isDifferent(previous, current));
            if (isDifferent(this.folders, current)) {
                updateSnapshots(current.values());
            }
        }

        private boolean isDifferent(Map previous,
                Map current) {
            if (!previous.keySet().equals(current.keySet())) {
                return true;
            }
            for (Map.Entry entry : previous.entrySet()) {
                FolderSnapshot previousFolder = entry.getValue();
                FolderSnapshot currentFolder = current.get(entry.getKey());
                if (!previousFolder.equals(currentFolder, this.triggerFilter)) {
                    return true;
                }
            }
            return false;
        }

        private Map getCurrentSnapshots() {
            Map snapshots = new LinkedHashMap();
            for (File folder : this.folders.keySet()) {
                snapshots.put(folder, new FolderSnapshot(folder));
            }
            return snapshots;
        }

        private void updateSnapshots(Collection snapshots) {
            Map updated = new LinkedHashMap();
            Set 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;
        }

        private void fireListeners(Set changeSet) {
            for (FileChangeListener listener : this.listeners) {
                listener.onChange(changeSet);
            }
        }

    }

可以看到,线程在scan中不断的做文件的扫描判断,看看当前的文件快照和前一个文件的快照是否有变化(毫秒级),若有变化则会执行updateSnapshots方法,并触发listener.onChange(changeSet)方法,发布ClassPathChangedEvent事件,引发重启。


spring-boot-devtools热部署原理解析_第3张图片
File-Watcher
"File Watcher" #51 daemon prio=5 os_prio=0 tid=0x0000000017276000 nid=0x3c04 waiting on condition [0x000000001a66f000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
        at java.lang.Thread.sleep(Native Method)
        at org.springframework.boot.devtools.filewatch.FileSystemWatcher$Watcher.scan(FileSystemWatcher.java:250)
        at org.springframework.boot.devtools.filewatch.FileSystemWatcher$Watcher.run(FileSystemWatcher.java:240)
        at java.lang.Thread.run(Thread.java:748)

   Locked ownable synchronizers:
        - None

可以看到,后台确实是启动了一个线程不断的在做文件快照的检查工作。
这里,文件检测并触发的逻辑已经介绍完毕,后续接着介绍服务重启的详细流程。

你可能感兴趣的:(spring-boot-devtools热部署原理解析)