View Javadoc
1   /**
2    * This file Copyright (c) 2014-2018 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.FileVisitOption;
46  import java.nio.file.FileVisitResult;
47  import java.nio.file.Files;
48  import java.nio.file.LinkOption;
49  import java.nio.file.Path;
50  import java.nio.file.SimpleFileVisitor;
51  import java.nio.file.WatchEvent;
52  import java.nio.file.WatchKey;
53  import java.nio.file.WatchService;
54  import java.nio.file.attribute.BasicFileAttributes;
55  import java.util.ArrayList;
56  import java.util.Collections;
57  import java.util.HashMap;
58  import java.util.List;
59  import java.util.Map;
60  
61  import org.apache.commons.lang3.EnumUtils;
62  import org.apache.commons.lang3.StringUtils;
63  import org.slf4j.Logger;
64  import org.slf4j.LoggerFactory;
65  
66  import com.google.common.base.Predicate;
67  
68  /**
69   * The {@link DirectoryWatcher} covers implementation of the low level WatchService API.
70   * <p>
71   * Highly inspired by http://docs.oracle.com/javase/tutorial/displayCode.html?code=http://docs.oracle.com/javase/tutorial/essential/io/examples/WatchDir.java
72   * 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)
73   * JDK on OSX doesn't have a native impl for 7 or 8: https://bugs.openjdk.java.net/browse/JDK-7133447
74   * See http://wiki.netbeans.org/NativeFileNotifications
75   * https://code.google.com/p/barbarywatchservice/
76   */
77  public class DirectoryWatcher implements Runnable {
78  
79      private static final Logger log = LoggerFactory.getLogger(DirectoryWatcher.class);
80  
81      private static final String WATCHER_SENSITIVITY = "magnolia.resources.watcher.sensitivity";
82      private static final String DEFAULT_WATCHER_SENSITIVITY_VALUE = "high";
83  
84      private final WatchService watcher;
85      private final Map<WatchKey, Path> keys = new HashMap<>();
86  
87      private final boolean recursive;
88      private final boolean followSymLinks;
89      private final List<WatcherRegistration> registrations = Collections.synchronizedList(new ArrayList<>());
90      private final boolean devMode;
91      private final String watcherSensitivity;
92  
93      public DirectoryWatcher(boolean recursive, boolean followLinks, MagnoliaConfigurationProperties properties) throws IOException {
94          this.recursive = recursive;
95          this.followSymLinks = followLinks;
96          this.watcher = FileSystems.getDefault().newWatchService();
97          this.devMode = properties.getBooleanProperty("magnolia.develop");
98          this.watcherSensitivity = getWatcherSensitivity(properties.getProperty(WATCHER_SENSITIVITY), DEFAULT_WATCHER_SENSITIVITY_VALUE);
99      }
100 
101     /**
102      * @deprecated Since 5.4.5, Please use {@link #DirectoryWatcher(boolean, boolean, MagnoliaConfigurationProperties)} instead.
103      */
104     @Deprecated
105     public DirectoryWatcher(boolean recursive, boolean followLinks) throws IOException {
106         this(recursive, followLinks, Components.getComponent(MagnoliaConfigurationProperties.class));
107     }
108 
109     public void register(Path dir, Predicate<Path> filterPredicate, WatcherCallback callback) throws IOException {
110         if (recursive) {
111             log.debug("Scanning {}", dir);
112             registerRecursively(dir, filterPredicate);
113             log.debug("Done scanning {}", dir);
114         } else {
115             registerDirectory(dir);
116         }
117         registrations.add(new WatcherRegistration(dir, filterPredicate, callback));
118     }
119 
120     /**
121      * Register the given directory with the WatchService.
122      */
123     protected void registerDirectory(Path dir) throws IOException {
124         final WatchKey key = dir.register(watcher, new WatchEvent.Kind[]{ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY}, getWatchEventModifiers(watcher));
125         // Would have used ExtendedWatchEventModifier.FILE_TREE, but it's not supported on osx nor linux;
126         // 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...
127         // Presumably, this is why we do recursion ourselves in here
128 
129         if (log.isDebugEnabled()) {
130             Path prev = keys.get(key);
131             if (prev == null) {
132                 log.debug("* register: {}", dir);
133             } else {
134                 log.debug("* update: {} -> {}", prev, dir);
135             }
136         }
137         keys.put(key, dir);
138     }
139 
140     protected WatchEvent.Modifier[] getWatchEventModifiers(WatchService watchService) {
141         // Improve watcher performance on OSX by using com.sun.nio.file.SensitivityWatchEventModifier if available.
142         try {
143             // If the WatchService is a PollingWatchService, which it is on OS X, AIX and Solaris prior to version 11
144             Class<?> pollingWatchService = Class.forName("sun.nio.fs.PollingWatchService");
145             if (pollingWatchService.isInstance(watchService)) {
146                 // Try to find enum value of SensitivityWatchEventModifier in magnolia.properties (high, medium, low)
147                 // Use Class.forName to avoid importing sun package for the SensitivityWatchEventModifier
148                 Class clazz = Class.forName("com.sun.nio.file.SensitivityWatchEventModifier");
149                 // Get enum value of the modifier or if the value is wrong set it to high sensitivity
150                 WatchEvent.Modifier sensitivityModifier = (WatchEvent.Modifier) EnumUtils.getEnum(clazz, StringUtils.upperCase(watcherSensitivity));
151                 if (sensitivityModifier == null) {
152                     sensitivityModifier = (WatchEvent.Modifier) EnumUtils.getEnum(clazz, "HIGH");
153                 }
154                 return new WatchEvent.Modifier[] { sensitivityModifier };
155             }
156         } catch (ClassNotFoundException ignored) {
157             // This is expected on JVMs where PollingWatchService or SensitivityWatchEventModifier are not available
158         }
159         return new WatchEvent.Modifier[0];
160     }
161 
162     /**
163      * Register the given directory, and all its sub-directories, with the WatchService.
164      */
165     protected void registerRecursively(final Path start, final Predicate<Path> filterPredicate) throws IOException {
166 
167         // register directory and sub-directories
168         Files.walkFileTree(start, this.followSymLinks ? Collections.singleton(FileVisitOption.FOLLOW_LINKS) : Collections.emptySet(), Integer.MAX_VALUE, new SimpleFileVisitor<Path>() {
169 
170             @Override
171             public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
172                 if (!filterPredicate.apply(dir)) {
173                     return FileVisitResult.SKIP_SUBTREE;
174                 }
175 
176                 registerDirectory(dir);
177                 return FileVisitResult.CONTINUE;
178             }
179 
180             @Override
181             public FileVisitResult visitFileFailed(Path file, IOException e) throws IOException {
182                 log.warn("Visiting failed for {}", file);
183                 return FileVisitResult.SKIP_SUBTREE;
184             }
185         });
186     }
187 
188     @Override
189     public void run() {
190         try {
191             while (!Thread.currentThread().isInterrupted()) {
192 
193                 // 1. wait for key to be signaled
194                 final WatchKey key;
195                 try {
196                     key = watcher.take();
197                 } catch (InterruptedException x) {
198                     Thread.currentThread().interrupt();
199                     return;
200                 }
201 
202                 final Path dir = keys.get(key);
203                 if (dir == null) {
204                     log.debug("WatchKey not recognized!!");
205                     continue;
206                 }
207 
208                 // 2. retrieve each pending event for the key
209                 final List<WatchEvent<?>> watchEvents = key.pollEvents();
210                 log.debug("   {} events", watchEvents.size());
211 
212                 for (WatchEvent<?> event : watchEvents) {
213                     final WatchEvent.Kind<?> kind = event.kind();
214                     log.debug("event: {}", event);
215                     log.debug("kind: {}", kind);
216 
217                     // TODO OVERFLOW indicates that events may been lost or discarded.
218                     if (kind == OVERFLOW) {
219                         continue;
220                     }
221 
222                     final Path completePath = getCompletePath(dir, event);
223                     log.debug("--> {} : {}: {}", Thread.currentThread(), event.kind().name(), completePath);
224 
225                     // 2a. register new directories
226                     if (Files.isDirectory(completePath, this.followSymLinks ? new LinkOption[0] : new LinkOption[] {NOFOLLOW_LINKS})) {
227                         if (recursive && (kind == ENTRY_CREATE || kind == ENTRY_MODIFY)) {
228                             // MAGNOLIA-6944 multiple threads may try to iterate and modify the collection
229                             // https://docs.oracle.com/javase/8/docs/api/java/util/Collections.html#synchronizedList-java.util.List-
230                             synchronized (registrations) {
231                                 // Loop over registrations to re-register accordingly
232                                 try {
233                                     for (WatcherRegistration registration : registrations) {
234                                         if (!completePath.startsWith(registration.getRootPath())) {
235                                             continue;
236                                         }
237                                         Predicate<Path> filterPredicate = registration.getFilterPredicate();
238 
239                                         if (!filterPredicate.apply(dir)) {
240                                             continue;
241                                         }
242 
243                                         registerRecursively(completePath, filterPredicate);
244                                     }
245                                 } catch (IOException x) {
246                                     log.warn("Unable to register all subdirectories because of the following exception:", x);
247                                 }
248                             }
249                         }
250                         // TODO shouldn't we unregister deleted directories ?
251                         log.debug(" --> watch keys {}", keys.values());
252                     }
253 
254                     // 2b. process event
255                     try {
256                         processEvent(kind, completePath, event);
257                     } catch (Throwable t) {
258                         log.error("Exception when executing callback for {}: {}", event.context(), ExceptionUtil.exceptionToWords(t), t);
259                         // if we throw here we kill the thread
260                     }
261                 }
262 
263                 // 3. reset key and remove from set if directory no longer accessible
264                 boolean valid = key.reset();
265                 if (!valid) {
266                     keys.remove(key);
267 
268                     // all directories are inaccessible
269                     if (keys.isEmpty()) {
270                         break;
271                     }
272                 }
273             }
274 
275         } catch (Throwable t) {
276             log.error("Exception occurred in DirectoryWatcher: {}", t, t);
277             throw t; // This kills the thread
278         }
279     }
280 
281     /**
282      * Process given pending {@link WatchEvent} as needed.
283      *
284      * @param kind the event {@link WatchEvent.Kind kind}
285      * @param completePath an absolute {@link Path} where the event occurred
286      * @param watchEvent the raw event for further custom processing
287      */
288     protected void processEvent(final WatchEvent.Kind<?> kind, final Path completePath, final WatchEvent<?> watchEvent) {
289         logEvent(kind, completePath);
290 
291         if (kind != ENTRY_CREATE && kind != ENTRY_MODIFY && kind != ENTRY_DELETE) {
292             throw new RuntimeException("Unknown event type " + kind + " for " + completePath.toAbsolutePath().toString());
293         }
294         // MAGNOLIA-6944 multiple threads may try to iterate and modify the collection
295         // https://docs.oracle.com/javase/8/docs/api/java/util/Collections.html#synchronizedList-java.util.List-
296         synchronized (registrations) {
297             for (WatcherRegistration registration : registrations) {
298                 if (!completePath.startsWith(registration.getRootPath())) {
299                     continue;
300                 }
301 
302                 if (!registration.getFilterPredicate().apply(completePath)) {
303                     continue;
304                 }
305 
306                 WatcherCallback callback = registration.getCallback();
307                 if (kind == ENTRY_CREATE) {
308                     callback.added(completePath);
309                 } else if (kind == ENTRY_DELETE) {
310                     callback.removed(completePath);
311                 } else if (kind == ENTRY_MODIFY) {
312                     callback.modified(completePath);
313                 }
314             }
315         }
316     }
317 
318     /**
319      * Logs file system events, the severity of log statements depends on the state of {@link #devMode dev mode flag}:
320      * <ul>
321      * <li>if it is on - severity level is {@code INFO};</li>
322      * <li>otherwise - severity level is {@code DEBUG}.</li>
323      * </ul>
324      */
325     private void logEvent(WatchEvent.Kind<?> kind, Path completePath) {
326         String message;
327         final String resourceType;
328         if (!Files.exists(completePath)) {
329             resourceType = "File resource(s)";
330         } else {
331             resourceType = Files.isDirectory(completePath) ? "Directory" : "File resource";
332         }
333 
334         if (kind == ENTRY_CREATE) {
335             message = "{} added at [{}]";
336         } else if (kind == ENTRY_DELETE) {
337             message = "{} deleted at [{}]";
338         } else if (kind == ENTRY_MODIFY) {
339             message = "{} modified at [{}]";
340         } else {
341             message = String.format("{} event of unhandled type (%s) occurred at [{}]", kind);
342         }
343 
344         if (devMode) {
345             log.info(message, resourceType, completePath);
346         } else {
347             log.debug(message, resourceType, completePath);
348         }
349     }
350 
351     /**
352      * WatchEvent.context() returns a Path relative to the watchKey it was created for; we reconstruct it here.
353      */
354     private Path getCompletePath(Path parent, WatchEvent<?> watchEvent) {
355         // Theoretically, there could be events with other types than Path. Practically, not so sure.
356         final Path path = cast(watchEvent).context();
357         return parent.resolve(path);
358     }
359 
360     private String getWatcherSensitivity(String property, String defaultValue) {
361         if (StringUtils.isBlank(property)) {
362             return defaultValue;
363         }
364         if (StringUtils.equalsIgnoreCase(property, "high") || StringUtils.equalsIgnoreCase(property, "medium") || StringUtils.equalsIgnoreCase(property, "low")) {
365             return property;
366         }
367         return defaultValue;
368     }
369 
370     @SuppressWarnings("unchecked")
371     private WatchEvent<Path> cast(WatchEvent<?> event) {
372         return (WatchEvent<Path>) event;
373     }
374 
375     private static class WatcherRegistration {
376 
377         private final Path rootPath;
378         private final Predicate<Path> filterPredicate;
379         private final WatcherCallback callback;
380 
381         public WatcherRegistration(Path rootPath, Predicate<Path> filterPredicate, WatcherCallback callback) {
382             this.rootPath = rootPath;
383             this.filterPredicate = filterPredicate;
384             this.callback = callback;
385         }
386 
387         public Path getRootPath() {
388             return rootPath;
389         }
390 
391         public Predicate<Path> getFilterPredicate() {
392             return filterPredicate;
393         }
394 
395         public WatcherCallback getCallback() {
396             return callback;
397         }
398     }
399 }