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

前言

在開發(fā)項(xiàng)目過程中捎泻,當(dāng)修改了某些代碼后需要本地驗(yàn)證時诬乞,需要重啟本地服務(wù)進(jìn)行驗(yàn)證俊抵,啟動這個項(xiàng)目谁不,如果項(xiàng)目龐大的話還是需要較長時間的,spring開發(fā)團(tuán)隊(duì)為我們帶來了一個插件:spring-boot-devtools务蝠,很好的解決了本地驗(yàn)證緩慢的問題拍谐。

簡單介紹

該原理其實(shí)很好說明,就是我們在編輯器上啟動項(xiàng)目馏段,然后改動相關(guān)的代碼轩拨,然后編輯器自動觸發(fā)編譯替換掉歷史的.class文件后,項(xiàng)目檢測到有文件變更后會重啟srpring-boot項(xiàng)目院喜。
可以看看官網(wǎng)的觸發(fā)描述:
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.
可以看到亡蓉,我們引入了插件后,插件會監(jiān)控我們classpath的資源變化喷舀,當(dāng)classpath有變化后砍濒,會觸發(fā)重啟淋肾。很多文章會介紹如何配置自動觸發(fā),本人覺得不是很喜歡這種配置爸邢,當(dāng)我們改動代碼時樊卓,并不是改動一下就改動完的,我還是喜歡自己點(diǎn)擊Build Project來觸發(fā)重啟杠河。
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.
這里提到了碌尔,該插件重啟快速的原因:這里對類加載采用了兩種類加載器,對于第三方j(luò)ar包采用base-classloader來加載券敌,對于開發(fā)人員自己開發(fā)的代碼則使用restartClassLoader來進(jìn)行加載唾戚,這使得比停掉服務(wù)重啟要快的多,因?yàn)槭褂貌寮皇侵貑㈤_發(fā)人員編寫的代碼部分待诅。
我這邊做個簡單的驗(yàn)證:

@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(第三方j(luò)ar包)和Devtools(自己編寫的類)使用的都是AppClassLoader加載的卑雁。
我們現(xiàn)在加上插件募书,然后執(zhí)行下代碼:

啟動服務(wù):
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


發(fā)現(xiàn)第三方的jar包的類加載器確實(shí)是使用的系統(tǒng)的類加載器,而我們自己寫的代碼的類加載器為RestartClassLoader序厉,并且每次重啟锐膜,類加載器的實(shí)例都會改變。

修改代碼前.png
修改代碼后.png

上圖為代碼修改前后類文件的變更弛房。

代碼解析

對于springboot的插件,都是從其插件中的spring.factories的配置文件開始的而柑。想探尋其原理文捶,可以看看這篇文章:SpringFactoriesLoader原理解析

devtools-spring.factories.png

順便推薦下:這個字體為最新的idea公司自己定制的字體,據(jù)說對程序員非常友好媒咳,使用了下粹排,確實(shí)很香:https://www.jetbrains.com/lp/mono/
這里直接到要害,本地開發(fā)工具的配置類為:
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<File> 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()));
            }
        }

該類為監(jiān)聽到classpath的classpath的文件變更后顽耳,會觸發(fā)ClassPathChangedEvent 事件,并會觸發(fā)springboot的重啟妙同,其內(nèi)部原理為使用了spring的事件監(jiān)聽機(jī)制射富,如果想補(bǔ)補(bǔ)這方面的內(nèi)容可以看看我自己 寫的這篇文章:Spring觀察者模式原理解析

文件監(jiān)聽機(jī)制

下面看看其文件是如何被監(jiān)聽的

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

核心為該配置類,該類中包含了重啟觸發(fā)策略ClassPathRestartStrategy粥帚,以及監(jiān)聽的路徑url和真正監(jiān)聽的實(shí)體類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));
    }

打斷點(diǎn)進(jìn)去發(fā)現(xiàn):


調(diào)試.png

其傳入的urls即為IDE編譯代碼的路徑,已經(jīng)其觸發(fā)重啟策略中已剔除掉配置項(xiàng)和一些測試的二進(jìn)制文件芒涡。
該類ClassPathFileSystemWatcher實(shí)例化之后會調(diào)用其afterPropertiesSet方法(實(shí)現(xiàn)了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對象柴灯,后續(xù)該對象是觸發(fā)ClassPathChangedEvent事件的實(shí)現(xiàn)者卖漫。

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<ChangedFiles> 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()赠群,該代碼為監(jiān)聽文件變化的核心羊始,看看其源碼

/**
     * Start monitoring the source folder for changes.
     */
    public void start() {
        synchronized (this.monitor) {
            saveInitialSnapshots();
            if (this.watchThread == null) {
                Map<File, FolderSnapshot> localFolders = new HashMap<File, FolderSnapshot>();
                localFolders.putAll(this.folders);
                this.watchThread = new Thread(new Watcher(this.remainingScans,
                        new ArrayList<FileChangeListener>(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類中進(jìn)行保存叹誉。文件的快照信息在該屬性中保存:fileSystemWatcher中的private final Map<File, FolderSnapshot> folders = new HashMap<File, FolderSnapshot>();
往下分析鸯两,創(chuàng)建了一個File Watcher的線程,將文件快照信息和listeners(觸發(fā)文件變更事件)當(dāng)做屬性以Watcher對象(實(shí)現(xiàn)了Runnable接口)傳入線程中长豁,并啟動線程钧唐。

private static final class Watcher implements Runnable {
private Watcher(AtomicInteger remainingScans, List<FileChangeListener> listeners,
                FileFilter triggerFilter, long pollInterval, long quietPeriod,
                Map<File, FolderSnapshot> 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<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());
            }
        }

        private boolean isDifferent(Map<File, FolderSnapshot> previous,
                Map<File, FolderSnapshot> current) {
            if (!previous.keySet().equals(current.keySet())) {
                return true;
            }
            for (Map.Entry<File, FolderSnapshot> 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<File, FolderSnapshot> getCurrentSnapshots() {
            Map<File, FolderSnapshot> snapshots = new LinkedHashMap<File, FolderSnapshot>();
            for (File folder : this.folders.keySet()) {
                snapshots.put(folder, new FolderSnapshot(folder));
            }
            return snapshots;
        }

        private void updateSnapshots(Collection<FolderSnapshot> snapshots) {
            Map<File, FolderSnapshot> updated = new LinkedHashMap<File, FolderSnapshot>();
            Set<ChangedFiles> changeSet = new LinkedHashSet<ChangedFiles>();
            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<ChangedFiles> changeSet) {
            for (FileChangeListener listener : this.listeners) {
                listener.onChange(changeSet);
            }
        }

    }

可以看到,線程在scan中不斷的做文件的掃描判斷匠襟,看看當(dāng)前的文件快照和前一個文件的快照是否有變化(毫秒級)钝侠,若有變化則會執(zhí)行updateSnapshots方法,并觸發(fā)listener.onChange(changeSet)方法酸舍,發(fā)布ClassPathChangedEvent事件帅韧,引發(fā)重啟。


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

可以看到啃勉,后臺確實(shí)是啟動了一個線程不斷的在做文件快照的檢查工作忽舟。
這里,文件檢測并觸發(fā)的邏輯已經(jīng)介紹完畢淮阐,后續(xù)接著介紹服務(wù)重啟的詳細(xì)流程叮阅。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市泣特,隨后出現(xiàn)的幾起案子浩姥,更是在濱河造成了極大的恐慌,老刑警劉巖状您,帶你破解...
    沈念sama閱讀 212,816評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件勒叠,死亡現(xiàn)場離奇詭異,居然都是意外死亡膏孟,警方通過查閱死者的電腦和手機(jī)眯分,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,729評論 3 385
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來骆莹,“玉大人颗搂,你說我怎么就攤上這事∧豢眩” “怎么了丢氢?”我有些...
    開封第一講書人閱讀 158,300評論 0 348
  • 文/不壞的土叔 我叫張陵傅联,是天一觀的道長。 經(jīng)常有香客問我疚察,道長蒸走,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,780評論 1 285
  • 正文 為了忘掉前任貌嫡,我火速辦了婚禮比驻,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘岛抄。我一直安慰自己别惦,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,890評論 6 385
  • 文/花漫 我一把揭開白布夫椭。 她就那樣靜靜地躺著掸掸,像睡著了一般。 火紅的嫁衣襯著肌膚如雪蹭秋。 梳的紋絲不亂的頭發(fā)上扰付,一...
    開封第一講書人閱讀 50,084評論 1 291
  • 那天,我揣著相機(jī)與錄音仁讨,去河邊找鬼羽莺。 笑死,一個胖子當(dāng)著我的面吹牛洞豁,可吹牛的內(nèi)容都是我干的盐固。 我是一名探鬼主播,決...
    沈念sama閱讀 39,151評論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼族跛,長吁一口氣:“原來是場噩夢啊……” “哼闰挡!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起礁哄,我...
    開封第一講書人閱讀 37,912評論 0 268
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎溪北,沒想到半個月后桐绒,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,355評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡之拨,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,666評論 2 327
  • 正文 我和宋清朗相戀三年茉继,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蚀乔。...
    茶點(diǎn)故事閱讀 38,809評論 1 341
  • 序言:一個原本活蹦亂跳的男人離奇死亡烁竭,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出吉挣,到底是詐尸還是另有隱情派撕,我是刑警寧澤婉弹,帶...
    沈念sama閱讀 34,504評論 4 334
  • 正文 年R本政府宣布,位于F島的核電站终吼,受9級特大地震影響镀赌,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜际跪,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,150評論 3 317
  • 文/蒙蒙 一商佛、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧姆打,春花似錦良姆、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,882評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至评抚,卻和暖如春豹缀,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背慨代。 一陣腳步聲響...
    開封第一講書人閱讀 32,121評論 1 267
  • 我被黑心中介騙來泰國打工邢笙, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人侍匙。 一個月前我還...
    沈念sama閱讀 46,628評論 2 362
  • 正文 我出身青樓氮惯,卻偏偏與公主長得像,于是被迫代替她去往敵國和親想暗。 傳聞我的和親對象是個殘疾皇子妇汗,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,724評論 2 351

推薦閱讀更多精彩內(nèi)容