/* (c) 2014-2015 Open Source Geospatial Foundation - all rights reserved * (c) 2014 OpenPlans * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.platform.resource; import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import org.geoserver.platform.resource.ResourceNotification.Kind; import org.springframework.beans.factory.DisposableBean; import org.springframework.scheduling.concurrent.CustomizableThreadFactory; /** * Active object (using a ScheduledExecutorService) used to watch file system for changes. * <p> * This implementation currently polls the file system and should be updated with Java 7 WatchService when available. The internal design is similar * to WatchService, WatchKey and WatchEvent in order to facilitate this transition. * <p> * This implementation makes a few concessions to being associated with ResourceStore, reporting changes with resource paths rather than files. * * @author Jody Garnett (Boundless) */ public class FileSystemWatcher implements ResourceNotificationDispatcher, DisposableBean { interface FileExtractor { public File getFile(String path); } /** * Change to file system */ static class Delta { final File context; final Kind kind; final List<File> created; final List<File> removed; final List<File> modified; public Delta(File context, Kind kind) { this.context = context; this.kind = kind; this.created = null; this.removed = null; this.modified = null; } public Delta(File context, Kind kind, List<File> created, List<File> removed, List<File> modified) { this.context = context; this.kind = Kind.ENTRY_MODIFY; this.created = created != null ? created : (List<File>) Collections.EMPTY_LIST; this.removed = removed != null ? removed : (List<File>) Collections.EMPTY_LIST; this.modified = modified != null ? modified : (List<File>) Collections.EMPTY_LIST; } @Override public String toString() { return "Delta [context=" + context + ", created=" + created + ", removed=" + removed + ", modified=" + modified + "]"; } } /** * Record of a ResourceListener that wishes to be notified of changes to a path. */ private class Watch implements Comparable<Watch> { /** File being watched */ final File file; /** Path to use during notification */ final String path; List<ResourceListener> listeners = new CopyOnWriteArrayList<ResourceListener>(); /** When last notification was sent */ long last = 0; /** Used to track resource creation / deletion */ boolean exsists; File[] contents; // directory contents at last check public Watch(File file, String path) { this.file = file; this.path = path; this.exsists = file.exists(); this.last = exsists ? file.lastModified() : 0; if (file.isDirectory()) { contents = file.listFiles(); } } public void addListener(ResourceListener listener){ listeners.add(listener); } public void removeListener(ResourceListener listener){ listeners.remove(listener); } /** Path used for notification */ public String getPath() { return path; } public List<ResourceListener> getListeners() { return listeners; } public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((file == null) ? 0 : file.hashCode()); result = prime * result + ((path == null) ? 0 : path.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Watch other = (Watch) obj; if (file == null) { if (other.file != null) return false; } else if (!file.equals(other.file)) return false; if (path == null) { if (other.path != null) return false; } else if (!path.equals(other.path)) return false; return true; } @Override public String toString() { return "Watch [path=" + path + ", file=" + file + ", listeners="+listeners.size()+"]"; } @Override public int compareTo(Watch other) { return path.compareTo(other.path); } public Delta changed(long now) { if (!file.exists()) { if (exsists) { exsists = false; if (contents != null) { // delete directory List<File> deleted = Arrays.asList(contents); this.last = now; this.contents = null; return new Delta(file, Kind.ENTRY_DELETE, null, deleted, null); } else { // file has been deleted! this.last = now; return new Delta(file, Kind.ENTRY_DELETE); } } else { return null; // no change file still deleted! } } if (file.isFile()) { long fileModified = file.lastModified(); if (fileModified > last || !exsists) { if (exsists) { this.last = fileModified; return new Delta(file, Kind.ENTRY_MODIFY); } else { exsists = true; this.last = fileModified; return new Delta(file, Kind.ENTRY_CREATE); } } else { return null; // no change! } } if (file.isDirectory()) { Kind kind = null; long fileModified = file.lastModified(); if (fileModified > last || !exsists) { kind = exsists ? Kind.ENTRY_MODIFY : Kind.ENTRY_CREATE; exsists = true; } else { // boolean win = System.getProperty("os.name").startsWith("Windows"); // if( !win ){ // // not windows - so no need to check directory contents // return null; // no change // } } File[] files = file.listFiles(); List<File> removed = new ArrayList<File>(files.length); List<File> created = new ArrayList<File>(files.length); List<File> modified = new ArrayList<File>(files.length); removed.addAll(Arrays.asList(this.contents)); removed.removeAll(Arrays.asList(files)); if( !removed.isEmpty() ){ fileModified = Math.max(fileModified, last+1); } created.addAll(Arrays.asList(files)); created.removeAll(Arrays.asList(this.contents)); for(File check : created ){ long checkModified = check.lastModified(); fileModified = Math.max(fileModified, checkModified); } // check contents List<File> review = new ArrayList<File>(files.length); review.addAll(Arrays.asList(files)); review.removeAll(created); // no need to check these they are new for (File check : review) { long checkModified = check.lastModified(); if (checkModified > last) { modified.add(check); fileModified = Math.max(fileModified, checkModified); } } if (kind == null) { if (removed.isEmpty() && created.isEmpty() && modified.isEmpty()) { // win only check of directory contents return null; // no change to directory contents } else { kind = Kind.ENTRY_MODIFY; } } this.last = fileModified; this.contents = files; return new Delta(file, kind, created, removed, modified); } return null; // no change } public boolean isMatch(File file, String path) { if (this.file == null) { if (file != null){ return false; } } else if (!this.file.equals(file)){ return false; } if (this.path == null) { if (path != null){ return false; } } else if (!this.path.equals(path)){ return false; } return true; } } private ScheduledExecutorService pool; private FileExtractor fileExtractor; protected long lastmodified; CopyOnWriteArrayList<Watch> watchers = new CopyOnWriteArrayList<Watch>(); /** * Note we have a single runnable here to review all outstanding Watch instances. * The focus is on using minimal system resources while we wait for Java 7 WatchService * (to be more efficient). */ private Runnable sync = new Runnable() { @Override public void run() { long now = System.currentTimeMillis(); for (Watch watch : watchers) { if( watch.getListeners().isEmpty()){ watchers.remove(watch); continue; } Delta delta = watch.changed(now); if (delta != null) { /** Created based on created/removed/modified files */ List<ResourceNotification.Event> events = ResourceNotification.delta( watch.file, delta.created, delta.removed, delta.modified); ResourceNotification notify = new ResourceNotification( watch.getPath(), delta.kind, watch.last, events); for (ResourceListener listener : watch.getListeners()) { try { listener.changed(notify); } catch (Throwable t) { Logger logger = Logger.getLogger(listener.getClass().getPackage() .getName()); logger.log(Level.FINE, "Unable to notify " + watch + ":" + t.getMessage(), t); } } } } } }; private ScheduledFuture<?> monitor; private TimeUnit unit = TimeUnit.SECONDS; private long delay = 10; private static CustomizableThreadFactory tFactory; static { tFactory = new CustomizableThreadFactory("FileSystemWatcher-"); tFactory.setDaemon(true); } /** * FileSystemWatcher used to track file changes. * <p> * Internally a single threaded schedule executor is used to monitor files. */ FileSystemWatcher(FileExtractor fileExtractor) { this.pool = Executors.newSingleThreadScheduledExecutor(tFactory); this.fileExtractor = fileExtractor; } FileSystemWatcher() { this (new FileExtractor() { @Override public File getFile(String path) { return new File(path.replace('/', File.separatorChar)); } }); } private Watch watch(File file, String path ){ if( file == null || path == null ){ return null; } for( Watch watch : watchers ){ if( watch.isMatch(file,path)){ return watch; } } return null; // not found } public synchronized void addListener(String path, ResourceListener listener) { File file = fileExtractor.getFile(path); if( file == null ){ throw new NullPointerException("File to watch is required"); } if( path == null ){ throw new NullPointerException("Path for notification is required"); } Watch watch = watch( file, path ); if( watch == null ){ watch = new Watch(file, path); watchers.add(watch); if( monitor == null){ monitor = pool.scheduleWithFixedDelay(sync, delay, delay, unit); } } watch.addListener(listener); } public synchronized boolean removeListener(String path, ResourceListener listener) { File file = fileExtractor.getFile(path); if( file == null ){ throw new NullPointerException("File to watch is required"); } if( path == null ){ throw new NullPointerException("Path for notification is required"); } Watch watch = watch( file, path ); boolean removed = false; if( watch != null ){ watch.removeListener(listener); if( watch.getListeners().isEmpty()){ removed = watchers.remove(watch); } } if (removed && watchers.isEmpty()) { if (monitor != null) { monitor.cancel(false); // stop watching nobody is looking monitor = null; } } return removed; } /** * Package visibility to allow test cases to set a shorter delay for testing. * * @param delay * @param unit */ void schedule(long delay, TimeUnit unit) { this.delay = delay; this.unit = unit; if (monitor != null) { monitor.cancel(false); monitor = pool.scheduleWithFixedDelay(sync, delay, delay, unit); } } @Override public void destroy() throws Exception { pool.shutdown(); } @Override public void changed(ResourceNotification notification) { throw new UnsupportedOperationException(); } }