001/* 002 * Copyright 2012-2018 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.boot.devtools.filewatch; 018 019import java.io.File; 020import java.io.FileFilter; 021import java.time.Duration; 022import java.util.ArrayList; 023import java.util.Collection; 024import java.util.Collections; 025import java.util.HashMap; 026import java.util.LinkedHashMap; 027import java.util.LinkedHashSet; 028import java.util.List; 029import java.util.Map; 030import java.util.Set; 031import java.util.concurrent.atomic.AtomicInteger; 032 033import org.springframework.util.Assert; 034 035/** 036 * Watches specific folders for file changes. 037 * 038 * @author Andy Clement 039 * @author Phillip Webb 040 * @since 1.3.0 041 * @see FileChangeListener 042 */ 043public class FileSystemWatcher { 044 045 private static final Duration DEFAULT_POLL_INTERVAL = Duration.ofMillis(1000); 046 047 private static final Duration DEFAULT_QUIET_PERIOD = Duration.ofMillis(400); 048 049 private final List<FileChangeListener> listeners = new ArrayList<>(); 050 051 private final boolean daemon; 052 053 private final long pollInterval; 054 055 private final long quietPeriod; 056 057 private final AtomicInteger remainingScans = new AtomicInteger(-1); 058 059 private final Map<File, FolderSnapshot> folders = new HashMap<>(); 060 061 private Thread watchThread; 062 063 private FileFilter triggerFilter; 064 065 private final Object monitor = new Object(); 066 067 /** 068 * Create a new {@link FileSystemWatcher} instance. 069 */ 070 public FileSystemWatcher() { 071 this(true, DEFAULT_POLL_INTERVAL, DEFAULT_QUIET_PERIOD); 072 } 073 074 /** 075 * Create a new {@link FileSystemWatcher} instance. 076 * @param daemon if a daemon thread used to monitor changes 077 * @param pollInterval the amount of time to wait between checking for changes 078 * @param quietPeriod the amount of time required after a change has been detected to 079 * ensure that updates have completed 080 */ 081 public FileSystemWatcher(boolean daemon, Duration pollInterval, 082 Duration quietPeriod) { 083 Assert.notNull(pollInterval, "PollInterval must not be null"); 084 Assert.notNull(quietPeriod, "QuietPeriod must not be null"); 085 Assert.isTrue(pollInterval.toMillis() > 0, "PollInterval must be positive"); 086 Assert.isTrue(quietPeriod.toMillis() > 0, "QuietPeriod must be positive"); 087 Assert.isTrue(pollInterval.toMillis() > quietPeriod.toMillis(), 088 "PollInterval must be greater than QuietPeriod"); 089 this.daemon = daemon; 090 this.pollInterval = pollInterval.toMillis(); 091 this.quietPeriod = quietPeriod.toMillis(); 092 } 093 094 /** 095 * Add listener for file change events. Cannot be called after the watcher has been 096 * {@link #start() started}. 097 * @param fileChangeListener the listener to add 098 */ 099 public void addListener(FileChangeListener fileChangeListener) { 100 Assert.notNull(fileChangeListener, "FileChangeListener must not be null"); 101 synchronized (this.monitor) { 102 checkNotStarted(); 103 this.listeners.add(fileChangeListener); 104 } 105 } 106 107 /** 108 * Add source folders to monitor. Cannot be called after the watcher has been 109 * {@link #start() started}. 110 * @param folders the folders to monitor 111 */ 112 public void addSourceFolders(Iterable<File> folders) { 113 Assert.notNull(folders, "Folders must not be null"); 114 synchronized (this.monitor) { 115 for (File folder : folders) { 116 addSourceFolder(folder); 117 } 118 } 119 } 120 121 /** 122 * Add a source folder to monitor. Cannot be called after the watcher has been 123 * {@link #start() started}. 124 * @param folder the folder to monitor 125 */ 126 public void addSourceFolder(File folder) { 127 Assert.notNull(folder, "Folder must not be null"); 128 Assert.isTrue(!folder.isFile(), "Folder '" + folder + "' must not be a file"); 129 synchronized (this.monitor) { 130 checkNotStarted(); 131 this.folders.put(folder, null); 132 } 133 } 134 135 /** 136 * Set an optional {@link FileFilter} used to limit the files that trigger a change. 137 * @param triggerFilter a trigger filter or null 138 */ 139 public void setTriggerFilter(FileFilter triggerFilter) { 140 synchronized (this.monitor) { 141 this.triggerFilter = triggerFilter; 142 } 143 } 144 145 private void checkNotStarted() { 146 synchronized (this.monitor) { 147 Assert.state(this.watchThread == null, "FileSystemWatcher already started"); 148 } 149 } 150 151 /** 152 * Start monitoring the source folder for changes. 153 */ 154 public void start() { 155 synchronized (this.monitor) { 156 saveInitialSnapshots(); 157 if (this.watchThread == null) { 158 Map<File, FolderSnapshot> localFolders = new HashMap<>(); 159 localFolders.putAll(this.folders); 160 this.watchThread = new Thread(new Watcher(this.remainingScans, 161 new ArrayList<>(this.listeners), this.triggerFilter, 162 this.pollInterval, this.quietPeriod, localFolders)); 163 this.watchThread.setName("File Watcher"); 164 this.watchThread.setDaemon(this.daemon); 165 this.watchThread.start(); 166 } 167 } 168 } 169 170 private void saveInitialSnapshots() { 171 for (File folder : this.folders.keySet()) { 172 this.folders.put(folder, new FolderSnapshot(folder)); 173 } 174 } 175 176 /** 177 * Stop monitoring the source folders. 178 */ 179 public void stop() { 180 stopAfter(0); 181 } 182 183 /** 184 * Stop monitoring the source folders. 185 * @param remainingScans the number of remaining scans 186 */ 187 void stopAfter(int remainingScans) { 188 Thread thread; 189 synchronized (this.monitor) { 190 thread = this.watchThread; 191 if (thread != null) { 192 this.remainingScans.set(remainingScans); 193 if (remainingScans <= 0) { 194 thread.interrupt(); 195 } 196 } 197 this.watchThread = null; 198 } 199 if (thread != null && Thread.currentThread() != thread) { 200 try { 201 thread.join(); 202 } 203 catch (InterruptedException ex) { 204 Thread.currentThread().interrupt(); 205 } 206 } 207 } 208 209 private static final class Watcher implements Runnable { 210 211 private final AtomicInteger remainingScans; 212 213 private final List<FileChangeListener> listeners; 214 215 private final FileFilter triggerFilter; 216 217 private final long pollInterval; 218 219 private final long quietPeriod; 220 221 private Map<File, FolderSnapshot> folders; 222 223 private Watcher(AtomicInteger remainingScans, List<FileChangeListener> listeners, 224 FileFilter triggerFilter, long pollInterval, long quietPeriod, 225 Map<File, FolderSnapshot> folders) { 226 this.remainingScans = remainingScans; 227 this.listeners = listeners; 228 this.triggerFilter = triggerFilter; 229 this.pollInterval = pollInterval; 230 this.quietPeriod = quietPeriod; 231 this.folders = folders; 232 } 233 234 @Override 235 public void run() { 236 int remainingScans = this.remainingScans.get(); 237 while (remainingScans > 0 || remainingScans == -1) { 238 try { 239 if (remainingScans > 0) { 240 this.remainingScans.decrementAndGet(); 241 } 242 scan(); 243 } 244 catch (InterruptedException ex) { 245 Thread.currentThread().interrupt(); 246 } 247 remainingScans = this.remainingScans.get(); 248 } 249 } 250 251 private void scan() throws InterruptedException { 252 Thread.sleep(this.pollInterval - this.quietPeriod); 253 Map<File, FolderSnapshot> previous; 254 Map<File, FolderSnapshot> current = this.folders; 255 do { 256 previous = current; 257 current = getCurrentSnapshots(); 258 Thread.sleep(this.quietPeriod); 259 } 260 while (isDifferent(previous, current)); 261 if (isDifferent(this.folders, current)) { 262 updateSnapshots(current.values()); 263 } 264 } 265 266 private boolean isDifferent(Map<File, FolderSnapshot> previous, 267 Map<File, FolderSnapshot> current) { 268 if (!previous.keySet().equals(current.keySet())) { 269 return true; 270 } 271 for (Map.Entry<File, FolderSnapshot> entry : previous.entrySet()) { 272 FolderSnapshot previousFolder = entry.getValue(); 273 FolderSnapshot currentFolder = current.get(entry.getKey()); 274 if (!previousFolder.equals(currentFolder, this.triggerFilter)) { 275 return true; 276 } 277 } 278 return false; 279 } 280 281 private Map<File, FolderSnapshot> getCurrentSnapshots() { 282 Map<File, FolderSnapshot> snapshots = new LinkedHashMap<>(); 283 for (File folder : this.folders.keySet()) { 284 snapshots.put(folder, new FolderSnapshot(folder)); 285 } 286 return snapshots; 287 } 288 289 private void updateSnapshots(Collection<FolderSnapshot> snapshots) { 290 Map<File, FolderSnapshot> updated = new LinkedHashMap<>(); 291 Set<ChangedFiles> changeSet = new LinkedHashSet<>(); 292 for (FolderSnapshot snapshot : snapshots) { 293 FolderSnapshot previous = this.folders.get(snapshot.getFolder()); 294 updated.put(snapshot.getFolder(), snapshot); 295 ChangedFiles changedFiles = previous.getChangedFiles(snapshot, 296 this.triggerFilter); 297 if (!changedFiles.getFiles().isEmpty()) { 298 changeSet.add(changedFiles); 299 } 300 } 301 if (!changeSet.isEmpty()) { 302 fireListeners(Collections.unmodifiableSet(changeSet)); 303 } 304 this.folders = updated; 305 } 306 307 private void fireListeners(Set<ChangedFiles> changeSet) { 308 for (FileChangeListener listener : this.listeners) { 309 listener.onChange(changeSet); 310 } 311 } 312 313 } 314 315}