View Javadoc
1   /**
2    * This file Copyright (c) 2014-2016 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.dirwatch;
35  
36  import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
37  import static java.nio.file.StandardWatchEventKinds.*;
38  
39  import info.magnolia.cms.util.ExceptionUtil;
40  import info.magnolia.init.MagnoliaConfigurationProperties;
41  import info.magnolia.objectfactory.Components;
42  
43  import java.io.IOException;
44  import java.nio.file.FileSystems;
45  import java.nio.file.FileVisitResult;
46  import java.nio.file.Files;
47  import java.nio.file.LinkOption;
48  import java.nio.file.Path;
49  import java.nio.file.SimpleFileVisitor;
50  import java.nio.file.WatchEvent;
51  import java.nio.file.WatchKey;
52  import java.nio.file.WatchService;
53  import java.nio.file.attribute.BasicFileAttributes;
54  import java.util.ArrayList;
55  import java.util.HashMap;
56  import java.util.List;
57  import java.util.Map;
58  
59  import org.apache.commons.lang3.EnumUtils;
60  import org.apache.commons.lang3.StringUtils;
61  import org.slf4j.Logger;
62  import org.slf4j.LoggerFactory;
63  
64  import com.google.common.base.Predicate;
65  
66  /**
67   * The {@link DirectoryWatcher} covers implementation of the low level WatchService API.
68   * <p>
69   * Highly inspired by http://docs.oracle.com/javase/tutorial/displayCode.html?code=http://docs.oracle.com/javase/tutorial/essential/io/examples/WatchDir.java
70   * Since java.nio.file.Watch* is not guaranteed to be using native events, we'll probably want to extract an interface for this. (non-native implementations use polling and can be quite slow)
71   * JDK on OSX doesn't have a native impl for 7 or 8: https://bugs.openjdk.java.net/browse/JDK-7133447
72   * See http://wiki.netbeans.org/NativeFileNotifications
73   * https://code.google.com/p/barbarywatchservice/
74   */
75  public class DirectoryWatcher implements Runnable {
76  
77      private static final Logger log = LoggerFactory.getLogger(DirectoryWatcher.class);
78  
79      private static final String WATCHER_SENSITIVITY = "magnolia.resources.watcher.sensitivity";
80  
81      private final WatchService watcher;
82      private final Map<WatchKey, Path> keys = new HashMap<>();
83  
84      private final boolean recursive;
85      private final LinkOption[] visitorOptions;
86      private final List<WatcherRegistration> registrations = new ArrayList<>();
87      private final boolean devMode;
88      private final String watcherSensitivity;
89  
90      public DirectoryWatcher(boolean recursive, boolean followLinks, MagnoliaConfigurationProperties properties) throws IOException {
91          this.recursive = recursive;
92          this.visitorOptions = followLinks ? new LinkOption[]{NOFOLLOW_LINKS} : new LinkOption[0];
93          this.watcher = FileSystems.getDefault().newWatchService();
94          this.devMode = properties.getBooleanProperty("magnolia.develop");
95          // turned off by default on 5.4
96          this.watcherSensitivity = getWatcherSensitivity(properties.getProperty(WATCHER_SENSITIVITY), null);
97      }
98  
99      /**
100      * @deprecated Since 5.4.5, Please use {@link #DirectoryWatcher(boolean, boolean, MagnoliaConfigurationProperties)} instead.
101      */
102     @Deprecated
103     public DirectoryWatcher(boolean recursive, boolean followLinks) throws IOException {
104         this(recursive, followLinks, Components.getComponent(MagnoliaConfigurationProperties.class));
105     }
106 
107     public void register(Path dir, Predicate<Path> filterPredicate, WatcherCallback callback) throws IOException {
108         if (recursive) {
109             log.debug("Scanning {}", dir);
110             registerRecursively(dir, filterPredicate);
111             log.debug("Done scanning {}", dir);
112         } else {
113             registerDirectory(dir);
114         }
115         registrations.add(new WatcherRegistration(dir, filterPredicate, callback));
116     }
117 
118     /**
119      * Register the given directory with the WatchService.
120      */
121     protected void registerDirectory(Path dir) throws IOException {
122         final WatchKey key = dir.register(watcher, new WatchEvent.Kind[]{ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY}, getWatchEventModifiers(watcher));
123         // Would have used ExtendedWatchEventModifier.FILE_TREE, but it's not supported on osx nor linux;
124         // AFAIK, there is no way to query the WatchService to see if it supports a given modifier without passing it and getting an UnsupportedOperationException thrown at us, so...
125         // Presumably, this is why we do recursion ourselves in here
126 
127         if (log.isDebugEnabled()) {
128             Path prev = keys.get(key);
129             if (prev == null) {
130                 log.debug("* register: {}", dir);
131             } else {
132                 log.debug("* update: {} -> {}", prev, dir);
133             }
134         }
135         keys.put(key, dir);
136     }
137 
138     protected WatchEvent.Modifier[] getWatchEventModifiers(WatchService watchService) {
139         // Improve watcher performance on OSX by using com.sun.nio.file.SensitivityWatchEventModifier if available.
140         try {
141             // If the WatchService is a PollingWatchService, which it is on OS X, AIX and Solaris prior to version 11
142             Class<?> pollingWatchService = Class.forName("sun.nio.fs.PollingWatchService");
143             if (pollingWatchService.isInstance(watchService)) {
144                 // Try to find enum value of SensitivityWatchEventModifier in magnolia.properties (high, medium, low)
145                 // Use Class.forName to avoid importing sun package for the SensitivityWatchEventModifier
146                 Class clazz = Class.forName("com.sun.nio.file.SensitivityWatchEventModifier");
147                 // Get enum value of the modifier or if the value is null/wrong return empty modifier array
148                 WatchEvent.Modifier sensitivityModifier = (WatchEvent.Modifier) EnumUtils.getEnum(clazz, StringUtils.upperCase(watcherSensitivity));
149                 if (sensitivityModifier == null) {
150                     return new WatchEvent.Modifier[0];
151                 }
152                 return new WatchEvent.Modifier[] { sensitivityModifier };
153             }
154         } catch (ClassNotFoundException ignored) {
155             // This is expected on JVMs where PollingWatchService or SensitivityWatchEventModifier are not available
156         }
157         return new WatchEvent.Modifier[0];
158     }
159 
160     /**
161      * Register the given directory, and all its sub-directories, with the WatchService.
162      */
163     protected void registerRecursively(final Path start, final Predicate<Path> filterPredicate) throws IOException {
164 
165         // register directory and sub-directories
166         Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
167 
168             @Override
169             public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
170                 if (!filterPredicate.apply(dir)) {
171                     return FileVisitResult.SKIP_SUBTREE;
172                 }
173 
174                 registerDirectory(dir);
175                 return FileVisitResult.CONTINUE;
176             }
177         });
178     }
179 
180     @Override
181     public void run() {
182         try {
183             while (!Thread.currentThread().isInterrupted()) {
184 
185                 // 1. wait for key to be signaled
186                 final WatchKey key;
187                 try {
188                     key = watcher.take();
189                 } catch (InterruptedException x) {
190                     Thread.currentThread().interrupt();
191                     return;
192                 }
193 
194                 final Path dir = keys.get(key);
195                 if (dir == null) {
196                     log.debug("WatchKey not recognized!!");
197                     continue;
198                 }
199 
200                 // 2. retrieve each pending event for the key
201                 final List<WatchEvent<?>> watchEvents = key.pollEvents();
202                 log.debug("   {} events", watchEvents.size());
203 
204                 for (WatchEvent<?> event : watchEvents) {
205                     final WatchEvent.Kind<?> kind = event.kind();
206                     log.debug("event: {}", event);
207                     log.debug("kind: {}", kind);
208 
209                     // TODO OVERFLOW indicates that events may been lost or discarded.
210                     if (kind == OVERFLOW) {
211                         continue;
212                     }
213 
214                     final Path completePath = getCompletePath(key, event);
215                     log.debug("--> {} : {}: {}", Thread.currentThread(), event.kind().name(), completePath);
216 
217                     // 2a. register new directories
218                     if (Files.isDirectory(completePath, visitorOptions)) {
219                         if (recursive && (kind == ENTRY_CREATE)) {
220 
221                             // Loop over registrations to re-register accordingly
222                             try {
223                                 for (WatcherRegistration registration : registrations) {
224                                     if (!completePath.startsWith(registration.getRootPath())) {
225                                         continue;
226                                     }
227                                     Predicate<Path> filterPredicate = registration.getFilterPredicate();
228                                     registerRecursively(completePath, filterPredicate);
229                                 }
230                             } catch (IOException x) {
231                                 log.warn("Unable to register all subdirectories because of the following exception:", x);
232                             }
233                         }
234                         // TODO shouldn't we unregister deleted directories ?
235                         log.debug(" --> watch keys {}", keys.values());
236                     }
237 
238                     // 2b. process event
239                     try {
240                         processEvent(kind, completePath, event);
241                     } catch (Throwable t) {
242                         log.error("Exception when executing callback for {}: {}", event.context(), ExceptionUtil.exceptionToWords(t), t);
243                         // if we throw here we kill the thread
244                     }
245                 }
246 
247                 // 3. reset key and remove from set if directory no longer accessible
248                 boolean valid = key.reset();
249                 if (!valid) {
250                     keys.remove(key);
251 
252                     // all directories are inaccessible
253                     if (keys.isEmpty()) {
254                         break;
255                     }
256                 }
257             }
258 
259         } catch (Throwable t) {
260             log.error("Exception occurred in DirectoryWatcher: {}", t, t);
261             throw t; // This kills the thread
262         }
263     }
264 
265     /**
266      * Process given pending {@link WatchEvent} as needed.
267      *
268      * @param kind the event {@link WatchEvent.Kind kind}
269      * @param completePath an absolute {@link Path} where the event occurred
270      * @param watchEvent the raw event for further custom processing
271      */
272     protected void processEvent(final WatchEvent.Kind<?> kind, final Path completePath, final WatchEvent<?> watchEvent) {
273         logEvent(kind, completePath);
274 
275         if (kind != ENTRY_CREATE && kind != ENTRY_MODIFY && kind != ENTRY_DELETE) {
276             throw new RuntimeException("Unknown event type " + kind + " for " + completePath.toAbsolutePath().toString());
277         }
278 
279         for (WatcherRegistration registration : registrations) {
280             if (!completePath.startsWith(registration.getRootPath())) {
281                 continue;
282             }
283 
284             WatcherCallback callback = registration.getCallback();
285             if (kind == ENTRY_CREATE) {
286                 callback.added(completePath);
287             } else if (kind == ENTRY_DELETE) {
288                 callback.removed(completePath);
289             } else if (kind == ENTRY_MODIFY) {
290                 callback.modified(completePath);
291             }
292         }
293     }
294 
295     /**
296      * Logs file system events, the severity of log statements depends on the state of {@link #devMode dev mode flag}:
297      * <ul>
298      * <li>if it is on - severity level is {@code INFO};</li>
299      * <li>otherwise - severity level is {@code DEBUG}.</li>
300      * </ul>
301      */
302     private void logEvent(WatchEvent.Kind<?> kind, Path completePath) {
303         String message;
304         final String resourceType;
305         if (!Files.exists(completePath)) {
306             resourceType = "File resource(s)";
307         } else {
308             resourceType = Files.isDirectory(completePath) ? "Directory" : "File resource";
309         }
310 
311         if (kind == ENTRY_CREATE) {
312             message = "{} added at [{}]";
313         } else if (kind == ENTRY_DELETE) {
314             message = "{} deleted at [{}]";
315         } else if (kind == ENTRY_MODIFY) {
316             message = "{} modified at [{}]";
317         } else {
318             message = String.format("{} event of unhandled type (%s) occurred at [{}]", kind);
319         }
320 
321         if (devMode) {
322             log.info(message, resourceType, completePath);
323         } else {
324             log.debug(message, resourceType, completePath);
325         }
326     }
327 
328     /**
329      * WatchEvent.context() returns a Path relative to the watchKey it was created for; we reconstruct it here.
330      */
331     private Path getCompletePath(WatchKey key, WatchEvent<?> watchEvent) {
332         // Theoretically, there could be events with other types than Path. Practically, not so sure.
333         final Path path = cast(watchEvent).context();
334         final Path parent = (Path) key.watchable();
335         return parent.resolve(path);
336     }
337 
338     private String getWatcherSensitivity(String property, String defaultValue) {
339         if (StringUtils.isBlank(property)) {
340             return defaultValue;
341         }
342         if (StringUtils.equalsIgnoreCase(property, "high") || StringUtils.equalsIgnoreCase(property, "medium") || StringUtils.equalsIgnoreCase(property, "low")) {
343             return property;
344         }
345         return defaultValue;
346     }
347 
348     @SuppressWarnings("unchecked")
349     private WatchEvent<Path> cast(WatchEvent<?> event) {
350         return (WatchEvent<Path>) event;
351     }
352 
353     private static class WatcherRegistration {
354 
355         private final Path rootPath;
356         private final Predicate<Path> filterPredicate;
357         private final WatcherCallback callback;
358 
359         public WatcherRegistration(Path rootPath, Predicate<Path> filterPredicate, WatcherCallback callback) {
360             this.rootPath = rootPath;
361             this.filterPredicate = filterPredicate;
362             this.callback = callback;
363         }
364 
365         public Path getRootPath() {
366             return rootPath;
367         }
368 
369         public Predicate<Path> getFilterPredicate() {
370             return filterPredicate;
371         }
372 
373         public WatcherCallback getCallback() {
374             return callback;
375         }
376     }
377 }