org.springframework.boot.devtools.filewatch.FileSystemWatcher Maven / Gradle / Ivy
The newest version!
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.devtools.filewatch;
import java.io.File;
import java.io.FileFilter;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.util.Assert;
/**
* Watches specific directories for file changes.
*
* @author Andy Clement
* @author Phillip Webb
* @since 1.3.0
* @see FileChangeListener
*/
public class FileSystemWatcher {
private static final Duration DEFAULT_POLL_INTERVAL = Duration.ofMillis(1000);
private static final Duration DEFAULT_QUIET_PERIOD = Duration.ofMillis(400);
private final List listeners = new ArrayList<>();
private final boolean daemon;
private final long pollInterval;
private final long quietPeriod;
private final SnapshotStateRepository snapshotStateRepository;
private final AtomicInteger remainingScans = new AtomicInteger(-1);
private final Map directories = new HashMap<>();
private Thread watchThread;
private FileFilter triggerFilter;
private final Object monitor = new Object();
/**
* Create a new {@link FileSystemWatcher} instance.
*/
public FileSystemWatcher() {
this(true, DEFAULT_POLL_INTERVAL, DEFAULT_QUIET_PERIOD);
}
/**
* Create a new {@link FileSystemWatcher} instance.
* @param daemon if a daemon thread used to monitor changes
* @param pollInterval the amount of time to wait between checking for changes
* @param quietPeriod the amount of time required after a change has been detected to
* ensure that updates have completed
*/
public FileSystemWatcher(boolean daemon, Duration pollInterval, Duration quietPeriod) {
this(daemon, pollInterval, quietPeriod, null);
}
/**
* Create a new {@link FileSystemWatcher} instance.
* @param daemon if a daemon thread used to monitor changes
* @param pollInterval the amount of time to wait between checking for changes
* @param quietPeriod the amount of time required after a change has been detected to
* ensure that updates have completed
* @param snapshotStateRepository the snapshot state repository
* @since 2.4.0
*/
public FileSystemWatcher(boolean daemon, Duration pollInterval, Duration quietPeriod,
SnapshotStateRepository snapshotStateRepository) {
Assert.notNull(pollInterval, "PollInterval must not be null");
Assert.notNull(quietPeriod, "QuietPeriod must not be null");
Assert.isTrue(pollInterval.toMillis() > 0, "PollInterval must be positive");
Assert.isTrue(quietPeriod.toMillis() > 0, "QuietPeriod must be positive");
Assert.isTrue(pollInterval.toMillis() > quietPeriod.toMillis(),
"PollInterval must be greater than QuietPeriod");
this.daemon = daemon;
this.pollInterval = pollInterval.toMillis();
this.quietPeriod = quietPeriod.toMillis();
this.snapshotStateRepository = (snapshotStateRepository != null) ? snapshotStateRepository
: SnapshotStateRepository.NONE;
}
/**
* Add listener for file change events. Cannot be called after the watcher has been
* {@link #start() started}.
* @param fileChangeListener the listener to add
*/
public void addListener(FileChangeListener fileChangeListener) {
Assert.notNull(fileChangeListener, "FileChangeListener must not be null");
synchronized (this.monitor) {
checkNotStarted();
this.listeners.add(fileChangeListener);
}
}
/**
* Add source directories to monitor. Cannot be called after the watcher has been
* {@link #start() started}.
* @param directories the directories to monitor
*/
public void addSourceDirectories(Iterable directories) {
Assert.notNull(directories, "Directories must not be null");
synchronized (this.monitor) {
directories.forEach(this::addSourceDirectory);
}
}
/**
* Add a source directory to monitor. Cannot be called after the watcher has been
* {@link #start() started}.
* @param directory the directory to monitor
*/
public void addSourceDirectory(File directory) {
Assert.notNull(directory, "Directory must not be null");
Assert.isTrue(!directory.isFile(), () -> "Directory '" + directory + "' must not be a file");
synchronized (this.monitor) {
checkNotStarted();
this.directories.put(directory, null);
}
}
/**
* Set an optional {@link FileFilter} used to limit the files that trigger a change.
* @param triggerFilter a trigger filter or null
*/
public void setTriggerFilter(FileFilter triggerFilter) {
synchronized (this.monitor) {
this.triggerFilter = triggerFilter;
}
}
private void checkNotStarted() {
Assert.state(this.watchThread == null, "FileSystemWatcher already started");
}
/**
* Start monitoring the source directory for changes.
*/
public void start() {
synchronized (this.monitor) {
createOrRestoreInitialSnapshots();
if (this.watchThread == null) {
Map localDirectories = new HashMap<>(this.directories);
Watcher watcher = new Watcher(this.remainingScans, new ArrayList<>(this.listeners), this.triggerFilter,
this.pollInterval, this.quietPeriod, localDirectories, this.snapshotStateRepository);
this.watchThread = new Thread(watcher);
this.watchThread.setName("File Watcher");
this.watchThread.setDaemon(this.daemon);
this.watchThread.start();
}
}
}
@SuppressWarnings("unchecked")
private void createOrRestoreInitialSnapshots() {
Map restored = (Map) this.snapshotStateRepository.restore();
this.directories.replaceAll((f, v) -> {
DirectorySnapshot restoredSnapshot = (restored != null) ? restored.get(f) : null;
return (restoredSnapshot != null) ? restoredSnapshot : new DirectorySnapshot(f);
});
}
/**
* Stop monitoring the source directories.
*/
public void stop() {
stopAfter(0);
}
/**
* Stop monitoring the source directories.
* @param remainingScans the number of remaining scans
*/
void stopAfter(int remainingScans) {
Thread thread;
synchronized (this.monitor) {
thread = this.watchThread;
if (thread != null) {
this.remainingScans.set(remainingScans);
if (remainingScans <= 0) {
thread.interrupt();
}
}
this.watchThread = null;
}
if (thread != null && Thread.currentThread() != thread) {
try {
thread.join();
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}
private static final class Watcher implements Runnable {
private final AtomicInteger remainingScans;
private final List listeners;
private final FileFilter triggerFilter;
private final long pollInterval;
private final long quietPeriod;
private Map directories;
private final SnapshotStateRepository snapshotStateRepository;
private Watcher(AtomicInteger remainingScans, List listeners, FileFilter triggerFilter,
long pollInterval, long quietPeriod, Map directories,
SnapshotStateRepository snapshotStateRepository) {
this.remainingScans = remainingScans;
this.listeners = listeners;
this.triggerFilter = triggerFilter;
this.pollInterval = pollInterval;
this.quietPeriod = quietPeriod;
this.directories = directories;
this.snapshotStateRepository = snapshotStateRepository;
}
@Override
public void run() {
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();
}
}
private void scan() throws InterruptedException {
Thread.sleep(this.pollInterval - this.quietPeriod);
Map previous;
Map current = this.directories;
do {
previous = current;
current = getCurrentSnapshots();
Thread.sleep(this.quietPeriod);
}
while (isDifferent(previous, current));
if (isDifferent(this.directories, 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()) {
DirectorySnapshot previousDirectory = entry.getValue();
DirectorySnapshot currentDirectory = current.get(entry.getKey());
if (!previousDirectory.equals(currentDirectory, this.triggerFilter)) {
return true;
}
}
return false;
}
private Map getCurrentSnapshots() {
Map snapshots = new LinkedHashMap<>();
for (File directory : this.directories.keySet()) {
snapshots.put(directory, new DirectorySnapshot(directory));
}
return snapshots;
}
private void updateSnapshots(Collection snapshots) {
Map updated = new LinkedHashMap<>();
Set changeSet = new LinkedHashSet<>();
for (DirectorySnapshot snapshot : snapshots) {
DirectorySnapshot previous = this.directories.get(snapshot.getDirectory());
updated.put(snapshot.getDirectory(), snapshot);
ChangedFiles changedFiles = previous.getChangedFiles(snapshot, this.triggerFilter);
if (!changedFiles.getFiles().isEmpty()) {
changeSet.add(changedFiles);
}
}
this.directories = updated;
this.snapshotStateRepository.save(updated);
if (!changeSet.isEmpty()) {
fireListeners(Collections.unmodifiableSet(changeSet));
}
}
private void fireListeners(Set changeSet) {
for (FileChangeListener listener : this.listeners) {
listener.onChange(changeSet);
}
}
}
}