package org.springframework.roo.file.monitor.polling; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; import java.util.WeakHashMap; import java.util.jar.JarFile; import java.util.jar.Manifest; import java.util.logging.Logger; import java.util.zip.ZipEntry; import org.apache.commons.lang3.Validate; import org.springframework.roo.file.monitor.DirectoryMonitoringRequest; import org.springframework.roo.file.monitor.FileMonitorService; import org.springframework.roo.file.monitor.MonitoringRequest; import org.springframework.roo.file.monitor.NotifiableFileMonitorService; import org.springframework.roo.file.monitor.event.FileDetails; import org.springframework.roo.file.monitor.event.FileEvent; import org.springframework.roo.file.monitor.event.FileEventListener; import org.springframework.roo.file.monitor.event.FileOperation; import org.springframework.roo.shell.AbstractShell; import org.springframework.roo.support.logging.HandlerUtils; import org.springframework.roo.support.util.FileUtils; import org.springframework.roo.support.util.XmlUtils; import org.w3c.dom.Document; import org.w3c.dom.Element; /** * A simple polling-based {@link FileMonitorService}. * <p> * This implementation iterates over each of the {@link MonitoringRequest} * instances, building an active file index at the time of execution. It then * compares this active file index with the last time it was executed for that * particular {@link MonitoringRequest}. Events are then fired, and only when * the event firing process has completed is the next {@link MonitoringRequest} * examined. * <p> * This implementation does not recognize {@link FileOperation#RENAMED} events. * This implementation will ignore any monitored files with a filename starting * with a period (ie hidden files). * <p> * In the case of {@link FileOperation#DELETED} events, this implementation will * present in the {@link FileEvent} times equal to the last time a deleted file * was modified. The time does NOT represent the deletion time nor the time the * deletion was first detected. * * @author Ben Alex * @author Juan Carlos GarcĂ­a * @since 1.0 */ public class PollingFileMonitorService implements NotifiableFileMonitorService { protected final static Logger LOGGER = HandlerUtils.getLogger(PollingFileMonitorService.class); private final Set<String> allFiles = new HashSet<String>(); private final Map<String, Set<String>> changeMap = new HashMap<String, Set<String>>(); private final Set<FileEventListener> fileEventListeners = new HashSet<FileEventListener>(); private final Object lock = new Object(); private final Set<String> notifyChanged = new HashSet<String>(); private final Set<String> notifyCreated = new HashSet<String>(); private final Set<String> notifyDeleted = new HashSet<String>(); private final Map<MonitoringRequest, Map<File, Long>> priorExecution = new WeakHashMap<MonitoringRequest, Map<File, Long>>(); private final Set<MonitoringRequest> requests = new LinkedHashSet<MonitoringRequest>(); private final List<FileEvent> eventsPendingToPublish = new ArrayList<FileEvent>(); public final void add(final FileEventListener e) { synchronized (lock) { fileEventListeners.add(e); } } public boolean add(final MonitoringRequest request) { synchronized (lock) { Validate.notNull(request, "MonitoringRequest required"); // Ensure existing monitoring requests don't overlap with this new // request; // amend existing requests or ignore new request as appropriate if (request instanceof DirectoryMonitoringRequest) { final DirectoryMonitoringRequest dmr = (DirectoryMonitoringRequest) request; if (dmr.isWatchSubtree()) { for (final MonitoringRequest existing : requests) { if (existing instanceof DirectoryMonitoringRequest) { final DirectoryMonitoringRequest existingDmr = (DirectoryMonitoringRequest) existing; if (existingDmr.isWatchSubtree()) { // We have a new request and an existing // request, both for directories, and both which // monitor sub-trees String existingDmrPath; String newDmrPath; try { existingDmrPath = existingDmr.getFile().getCanonicalPath(); newDmrPath = dmr.getFile().getCanonicalPath(); } catch (final IOException ioe) { throw new IllegalStateException("Unable to resolve canonical name", ioe); } // If the new request is a sub-directory of the // existing request, ignore the new request as // it's unnecessary if (newDmrPath.startsWith(existingDmrPath)) { return false; } // If the existing request is a sub-directory of // the new request, remove the existing request // as this new request // will incorporate it if (existingDmrPath.startsWith(newDmrPath)) { remove(existing); } } } } } } return requests.add(request); } } /** * Adds one or more entries into the Map. The key of the Map is the File * object, and the value is the {@link File#lastModified()} time. * <p> * Specifically: * <ul> * <li>If invoked with a File that is actually a File, only the file is * added.</li> * <li>If invoked with a File that is actually a Directory, all files and * directories are added.</li> * <li>If invoked with a File that is actually a Directory, subdirectories * will be added only if "includeSubtree" is true.</li> * </ul> */ private void computeEntries(final Map<File, Long> map, final File currentFile, final boolean includeSubtree) { Validate.notNull(map, "Map required"); Validate.notNull(currentFile, "Current file is required"); if (!currentFile.exists() || currentFile.getName().length() > 1 && currentFile.getName().startsWith(".") || currentFile.getName().equals("log.roo") || currentFile.isDirectory() && isExcludedDirectory(currentFile.getPath())) { return; } map.put(currentFile, currentFile.lastModified()); try { allFiles.add(currentFile.getCanonicalPath()); } catch (final IOException ignored) { } if (currentFile.isDirectory()) { final File[] files = currentFile.listFiles(); if (files == null || files.length == 0) { return; } for (final File file : files) { if (file.isFile() || includeSubtree) { computeEntries(map, file, includeSubtree); } } } } public SortedSet<FileDetails> findMatchingAntPath(final String antPath) { Validate.notBlank(antPath, "Ant path required"); final SortedSet<FileDetails> result = new TreeSet<FileDetails>(); // Now we need to compute the starting directory by reference to the // first * in the Ant Path int index = antPath.indexOf("*"); // Conditionals are based on an index of 0 (not -1) to ensure the // detected character is not the only character in the string Validate.isTrue(index > 0, "'%s' is not an Ant Path as it fails to include an * character", antPath); String newPath = antPath.substring(0, index); index = newPath.lastIndexOf(File.separatorChar); Validate.isTrue(index > 0, "'%s' fails to include any '%s' directory separator", antPath, File.separatorChar); newPath = newPath.substring(0, index); final File somePath = new File(newPath); if (!somePath.exists()) { // Path at the start of the Ant expression doesn't exist, so there's // no way we'll find anything via a search return result; } Validate .isTrue( somePath.isDirectory(), "Ant path '%s' appears under file system path '%s' but this is not a directory that can be searched", antPath, somePath); recursiveAntMatch(antPath, somePath, result); return result; } public Collection<String> getDirtyFiles(final String requestingClass) { synchronized (lock) { final Collection<String> changesSinceLastRequest = changeMap.get(requestingClass); if (changesSinceLastRequest == null) { changeMap.put(requestingClass, new LinkedHashSet<String>()); return new LinkedHashSet<String>(allFiles); } final Collection<String> copyOfChangesSinceLastRequest = new LinkedHashSet<String>(changesSinceLastRequest); changesSinceLastRequest.clear(); return copyOfChangesSinceLastRequest; } } private List<FileEvent> getFileCreationEvents(final MonitoringRequest request, final Map<File, Long> priorFiles) { final List<FileEvent> createEvents = new ArrayList<FileEvent>(); for (final Iterator<String> iter = notifyCreated.iterator(); iter.hasNext();) { final String filePath = iter.next(); if (isWithin(request, filePath)) { iter.remove(); // We've processed it // Skip this file if it doesn't exist final File thisFile = new File(filePath); if (thisFile.exists()) { // Record the notification createEvents.add(new FileEvent(new FileDetails(thisFile, thisFile.lastModified()), FileOperation.CREATED, null)); // Update the prior execution map so it isn't notified again // next round priorFiles.put(thisFile, thisFile.lastModified()); } } } return createEvents; } private List<FileEvent> getFileDeletionEvents(final MonitoringRequest request, final Map<File, Long> priorFiles) { final List<FileEvent> deleteEvents = new ArrayList<FileEvent>(); for (final Iterator<String> iter = notifyDeleted.iterator(); iter.hasNext();) { final String filePath = iter.next(); if (isWithin(request, filePath)) { iter.remove(); // We've processed it // Skip this file if it suddenly exists again (it shouldn't be // in the notify deleted in this case!) final File thisFile = new File(filePath); if (!thisFile.exists()) { // Record the notification deleteEvents.add(new FileEvent(new FileDetails(thisFile, null), FileOperation.DELETED, null)); // Update the prior execution map so it isn't notified again // next round priorFiles.remove(thisFile); } } } return deleteEvents; } private List<FileEvent> getFileUpdateEvents(final MonitoringRequest request, final Map<File, Long> priorFiles) { final List<FileEvent> updateEvents = new ArrayList<FileEvent>(); for (final Iterator<String> iter = notifyChanged.iterator(); iter.hasNext();) { final String filePath = iter.next(); if (isWithin(request, filePath)) { iter.remove(); // We've processed it // Skip this file if it doesn't exist final File thisFile = new File(filePath); if (thisFile.exists()) { // Record the notification updateEvents.add(new FileEvent(new FileDetails(thisFile, thisFile.lastModified()), FileOperation.UPDATED, null)); // Update the prior execution map so it isn't notified again // next round priorFiles.put(thisFile, thisFile.lastModified()); // Also remove it from the created list, if it's in there if (notifyCreated.contains(filePath)) { notifyCreated.remove(filePath); } } } } return updateEvents; } public List<FileDetails> getMonitored() { synchronized (lock) { final List<FileDetails> monitored = new ArrayList<FileDetails>(); if (requests.isEmpty()) { return monitored; } for (final MonitoringRequest request : requests) { if (priorExecution.containsKey(request)) { final Map<File, Long> priorFiles = priorExecution.get(request); for (final Entry<File, Long> entry : priorFiles.entrySet()) { monitored.add(new FileDetails(entry.getKey(), entry.getValue())); } } } return monitored; } } public boolean isDirty() { synchronized (lock) { return !notifyChanged.isEmpty() || !notifyCreated.isEmpty() || !notifyDeleted.isEmpty(); } } private boolean isExcludedDirectory(final String path) { final boolean hasSrc = path.contains(File.separator + "src"); return !hasSrc && (path.contains(File.separator + "target") || path.contains(File.separator + "bin")) || hasSrc && path.contains(File.separator + "maven"); } /** * Decides whether we want to store this notification. This only happens if * a monitoring request has indicated it is interested in this request. See * ROO-794 for details. * * @param fileCanonicalPath to potentially keep * @return true if the notification is able to be kept */ private boolean isNotificationUnderKnownMonitoringRequest(final String fileCanonicalPath) { synchronized (lock) { for (final MonitoringRequest request : requests) { if (isWithin(request, fileCanonicalPath)) { return true; } } } return false; } private boolean isWithin(final MonitoringRequest request, final String filePath) { String requestCanonicalPath; try { requestCanonicalPath = request.getFile().getCanonicalPath(); } catch (final IOException e) { return false; } if (request instanceof DirectoryMonitoringRequest) { final DirectoryMonitoringRequest dmr = (DirectoryMonitoringRequest) request; if (dmr.isWatchSubtree()) { if (!filePath.startsWith(requestCanonicalPath)) { // Not within this directory or as ub-directory return false; } } else { if (!FileUtils.matchesAntPath(requestCanonicalPath + File.separator + "*", filePath)) { return false; // Not within this directory } } } else { if (!requestCanonicalPath.equals(filePath)) { return false; // Not a file } } return true; } private boolean noRequestsOrChanges() { return requests.isEmpty() || !isDirty(); } public void notifyChanged(final String fileCanonicalPath) { synchronized (lock) { updateChanges(fileCanonicalPath, false); if (isNotificationUnderKnownMonitoringRequest(fileCanonicalPath)) { notifyChanged.add(fileCanonicalPath); } } } public void notifyCreated(final String fileCanonicalPath) { synchronized (lock) { updateChanges(fileCanonicalPath, false); if (isNotificationUnderKnownMonitoringRequest(fileCanonicalPath)) { notifyCreated.add(fileCanonicalPath); } } } public void notifyDeleted(final String fileCanonicalPath) { synchronized (lock) { updateChanges(fileCanonicalPath, true); if (isNotificationUnderKnownMonitoringRequest(fileCanonicalPath)) { notifyDeleted.add(fileCanonicalPath); } } } /** * Publish the events, if needed. * <p> * This method assumes the caller has already acquired a synchronisation * lock. * * @param eventsToPublish to publish (not null, but can be empty) */ private void publish(final List<FileEvent> eventsToPublish) { if (eventsToPublish.isEmpty()) { return; } if (fileEventListeners.isEmpty() || eventsToPublish.isEmpty()) { return; } for (final FileEvent event : eventsToPublish) { updateChanges(event.getFileDetails().getCanonicalPath(), event.getOperation() == FileOperation.DELETED); for (final FileEventListener l : fileEventListeners) { l.onFileEvent(event); } } } private int publishRequestedFileEvents() { int eventsPublished = 0; for (final MonitoringRequest request : requests) { final List<FileEvent> eventsToPublish = new ArrayList<FileEvent>(); // See when each file was last checked Map<File, Long> priorFiles = priorExecution.get(request); if (priorFiles == null) { priorFiles = new HashMap<File, Long>(); priorExecution.put(request, priorFiles); } // Handle files apparently updated, created, or deleted since the // last execution eventsToPublish.addAll(getFileUpdateEvents(request, priorFiles)); eventsToPublish.addAll(getFileCreationEvents(request, priorFiles)); eventsToPublish.addAll(getFileDeletionEvents(request, priorFiles)); publish(eventsToPublish); eventsPublished += eventsToPublish.size(); } return eventsPublished; } /** * Locates all files under the specified current directory which patch the * given Ant Path. * * @param antPath to match (required) * @param currentDirectory an existing directory to search from (required) * @param result to append located files into (required) */ private void recursiveAntMatch(final String antPath, final File currentDirectory, final SortedSet<FileDetails> result) { Validate.notNull(currentDirectory, "Current directory required"); Validate.isTrue(currentDirectory.exists() && currentDirectory.isDirectory(), "Path '%s' does not exist or is not a directory", currentDirectory); Validate.notBlank(antPath, "Ant path required"); Validate.notNull(result, "Result required"); final File[] listFiles = currentDirectory.listFiles(); if (listFiles == null || listFiles.length == 0) { return; } for (final File f : listFiles) { try { if (FileUtils.matchesAntPath(antPath, f.getCanonicalPath())) { result.add(new FileDetails(f, f.lastModified())); } } catch (final IOException ignored) { } if (f.isDirectory()) { recursiveAntMatch(antPath, f, result); } } } public final void remove(final FileEventListener e) { synchronized (lock) { fileEventListeners.remove(e); } } public boolean remove(final MonitoringRequest request) { synchronized (lock) { Validate.notNull(request, "MonitoringRequest required"); // Advise of the cessation to monitoring if (priorExecution.containsKey(request)) { final List<FileEvent> eventsToPublish = new ArrayList<FileEvent>(); final Map<File, Long> priorFiles = priorExecution.get(request); for (final Entry<File, Long> entry : priorFiles.entrySet()) { final File thisFile = entry.getKey(); final Long lastModified = entry.getValue(); eventsToPublish.add(new FileEvent(new FileDetails(thisFile, lastModified), FileOperation.MONITORING_FINISH, null)); } publish(eventsToPublish); } priorExecution.remove(request); return requests.remove(request); } } public int scanAll() { synchronized (lock) { if (requests.isEmpty()) { return 0; } int changes = 0; for (final MonitoringRequest request : requests) { boolean includeSubtree = false; if (request instanceof DirectoryMonitoringRequest) { includeSubtree = ((DirectoryMonitoringRequest) request).isWatchSubtree(); } if (!request.getFile().exists()) { continue; } // Build contents of the monitored location final Map<File, Long> currentExecution = new HashMap<File, Long>(); computeEntries(currentExecution, request.getFile(), includeSubtree); final List<FileEvent> eventsToPublish = new ArrayList<FileEvent>(); if (priorExecution.containsKey(request)) { // Need to perform a comparison, as we have data from a // previous execution final Map<File, Long> priorFiles = priorExecution.get(request); // Locate created and modified files for (final Entry<File, Long> entry : currentExecution.entrySet()) { final File thisFile = entry.getKey(); final Long currentTimestamp = entry.getValue(); if (!priorFiles.containsKey(thisFile)) { // This file did not exist last execution, so it // must be new eventsToPublish.add(new FileEvent(new FileDetails(thisFile, currentTimestamp), FileOperation.CREATED, null)); try { // If this file was already going to be // notified, there is no need to do it twice notifyCreated.remove(thisFile.getCanonicalPath()); } catch (final IOException ignored) { } continue; } final Long previousTimestamp = priorFiles.get(thisFile); if (!currentTimestamp.equals(previousTimestamp)) { // Modified eventsToPublish.add(new FileEvent(new FileDetails(thisFile, currentTimestamp), FileOperation.UPDATED, null)); try { // If this file was already going to be // notified, there is no need to do it twice notifyChanged.remove(thisFile.getCanonicalPath()); } catch (final IOException ignored) { } } } // Now locate deleted files priorFiles.keySet().removeAll(currentExecution.keySet()); for (final Entry<File, Long> entry : priorFiles.entrySet()) { final File deletedFile = entry.getKey(); eventsToPublish.add(new FileEvent(new FileDetails(deletedFile, entry.getValue()), FileOperation.DELETED, null)); try { // If this file was already going to be notified, // there is no need to do it twice notifyDeleted.remove(deletedFile.getCanonicalPath()); } catch (final IOException ignored) { } } } else { // No data from previous execution, so it's a // newly-monitored location for (final Entry<File, Long> entry : currentExecution.entrySet()) { eventsToPublish.add(new FileEvent(new FileDetails(entry.getKey(), entry.getValue()), FileOperation.MONITORING_START, null)); } } // Record the monitored location's contents, ready for next // execution priorExecution.put(request, currentExecution); // We can discard the created and deleted notifications, as they // would have been correctly discovered in the above loop notifyCreated.clear(); notifyDeleted.clear(); // Explicitly handle any undiscovered update notifications, as // this indicates an identical millisecond update occurred for (final String canonicalPath : notifyChanged) { final File file = new File(canonicalPath); eventsToPublish.add(new FileEvent(new FileDetails(file, file.lastModified()), FileOperation.UPDATED, null)); } notifyChanged.clear(); // ROO-3622: Validate if version change if (!isDifferentVersion()) { // Publishing pending events if needed if (!eventsPendingToPublish.isEmpty()) { publish(eventsPendingToPublish); // Clear events pending to publish eventsPendingToPublish.clear(); } publish(eventsToPublish); } else { for (FileEvent event : eventsToPublish) { if (eventsPendingToPublish.indexOf(event) == -1) { eventsPendingToPublish.add(event); } } } changes += eventsToPublish.size(); } return changes; } } private String getRooProjectVersion() { String homePath = new File(".").getPath(); String pomPath = homePath + "/pom.xml"; File pom = new File(pomPath); try { if (pom.exists()) { InputStream is = new FileInputStream(pom); Document docXml = XmlUtils.readXml(is); Element document = docXml.getDocumentElement(); Element rooVersionElement = XmlUtils.findFirstElement("properties/roo.version", document); String rooVersion = rooVersionElement.getTextContent(); return rooVersion; } return "UNKNOWN"; } catch (FileNotFoundException e) { e.printStackTrace(); } return ""; } private boolean isDifferentVersion() { String rooVersion = getRooProjectVersion(); if ("UNKNOWN".equals(rooVersion)) { return false; } return !rooVersion.equals(versionInfoWithoutGit()); } public static String versionInfoWithoutGit() { // Try to determine the bundle version String bundleVersion = null; JarFile jarFile = null; try { final URL classContainer = AbstractShell.class.getProtectionDomain().getCodeSource().getLocation(); if (classContainer.toString().endsWith(".jar")) { // Attempt to obtain the "Bundle-Version" version from the // manifest jarFile = new JarFile(new File(classContainer.toURI()), false); final ZipEntry manifestEntry = jarFile.getEntry("META-INF/MANIFEST.MF"); final Manifest manifest = new Manifest(jarFile.getInputStream(manifestEntry)); bundleVersion = manifest.getMainAttributes().getValue("Bundle-Version"); } } catch (final IOException ignoreAndMoveOn) { } catch (final URISyntaxException ignoreAndMoveOn) { } finally { if (jarFile != null) { try { jarFile.close(); } catch (final IOException ignored) { } } } final StringBuilder sb = new StringBuilder(); if (bundleVersion != null) { sb.append(bundleVersion); } if (sb.length() == 0) { sb.append("UNKNOWN VERSION"); } return sb.toString(); } public int scanNotified() { synchronized (lock) { if (noRequestsOrChanges()) { return 0; } return publishRequestedFileEvents(); } } private void updateChanges(final String fileCanonicalPath, final boolean remove) { for (final String requestingClass : changeMap.keySet()) { if (remove) { changeMap.get(requestingClass).remove(fileCanonicalPath); } else { changeMap.get(requestingClass).add(fileCanonicalPath); } } if (remove) { allFiles.remove(fileCanonicalPath); } else { allFiles.add(fileCanonicalPath); } } }