I created a wrapper around WatchService to clean up many issues I have with the API. It is now much more testable. I am unsure about some of the concurrency issues in PathWatchService though and I have not done thorough testing of it.
New FileWatcher:
public class FileWatcher {
private final PathWatchService pathWatchService;
private final Path path;
private String data;
public FileWatcher(PathWatchService pathWatchService, Path path) {
this.path = path;
this.pathWatchService = pathWatchService;
try {
this.pathWatchService.register(path.toAbsolutePath().getParent());
} catch (IOException ex) {
throw new RuntimeException(ex);
}
load();
}
private void load() {
try (BufferedReader br = Files.newBufferedReader(path, Charset.defaultCharset())){
data = br.readLine();
} catch (IOException ex) {
data = "";
}
}
public void update(){
PathEvents pe;
while ((pe=pathWatchService.poll()) != null) {
for (WatchEvent we : pe.getEvents()){
if (path.equals(we.context())){
load();
return;
}
}
}
}
public String getData(){
update();
return data;
}
}
Wrapper:
public class PathWatchService implements AutoCloseable {
private final WatchService watchService;
private final BiMap<WatchKey, Path> watchKeyToPath = HashBiMap.create();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Queue<WatchKey> invalidKeys = new ConcurrentLinkedQueue<>();
/**
* Constructor.
*/
public PathWatchService() {
try {
watchService = FileSystems.getDefault().newWatchService();
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
/**
* Register the input path with the WatchService for all
* StandardWatchEventKinds. Registering a path which is already being
* watched has no effect.
*
* @param path
* @return
* @throws IOException
*/
public void register(Path path) throws IOException {
register(path, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
}
/**
* Register the input path with the WatchService for the input event kinds.
* Registering a path which is already being watched has no effect.
*
* @param path
* @param kinds
* @return
* @throws IOException
*/
public void register(Path path, WatchEvent.Kind... kinds) throws IOException {
try {
lock.writeLock().lock();
removeInvalidKeys();
WatchKey key = watchKeyToPath.inverse().get(path);
if (key == null) {
key = path.register(watchService, kinds);
watchKeyToPath.put(key, path);
}
} finally {
lock.writeLock().unlock();
}
}
/**
* Close the WatchService.
*
* @throws IOException
*/
@Override
public void close() throws IOException {
try {
lock.writeLock().lock();
watchService.close();
watchKeyToPath.clear();
invalidKeys.clear();
} finally {
lock.writeLock().unlock();
}
}
/**
* Retrieves and removes the next PathEvents object, or returns null if none
* are present.
*
* @return
*/
public PathEvents poll() {
return keyToPathEvents(watchService.poll());
}
/**
* Return a PathEvents object from the input key.
*
* @param key
* @return
*/
private PathEvents keyToPathEvents(WatchKey key) {
if (key == null) {
return null;
}
try {
lock.readLock().lock();
Path watched = watchKeyToPath.get(key);
List<WatchEvent<Path>> events = new ArrayList<>();
for (WatchEvent e : key.pollEvents()) {
events.add((WatchEvent<Path>) e);
}
boolean isValid = key.reset();
if (isValid == false) {
invalidKeys.add(key);
}
return new PathEvents(watched, events, isValid);
} finally {
lock.readLock().unlock();
}
}
/**
* Retrieves and removes the next PathEvents object, waiting if necessary up
* to the specified wait time, returns null if none are present after the
* specified wait time.
*
* @return
*/
public PathEvents poll(long timeout, TimeUnit unit) throws InterruptedException {
return keyToPathEvents(watchService.poll(timeout, unit));
}
/**
* Retrieves and removes the next PathEvents object, waiting if none are yet
* present.
*
* @return
*/
public PathEvents take() throws InterruptedException {
return keyToPathEvents(watchService.take());
}
/**
* Get all paths currently being watched. Any paths which were watched but
* have invalid keys are not returned.
*
* @return
*/
public Set<Path> getWatchedPaths() {
try {
lock.readLock().lock();
Set<Path> paths = new HashSet<>(watchKeyToPath.inverse().keySet());
WatchKey key;
while ((key = invalidKeys.poll()) != null) {
paths.remove(watchKeyToPath.get(key));
}
return paths;
} finally {
lock.readLock().unlock();
}
}
/**
* Cancel watching the specified path. Cancelling a path which is not being
* watched has no effect.
*
* @param path
*/
public void cancel(Path path) {
try {
lock.writeLock().lock();
removeInvalidKeys();
WatchKey key = watchKeyToPath.inverse().remove(path);
if (key != null) {
key.cancel();
}
} finally {
lock.writeLock().unlock();
}
}
/**
* Removes any invalid keys from internal data structures. Note this
* operation is also performed during register and cancel calls.
*/
public void cleanUp() {
try {
lock.writeLock().lock();
removeInvalidKeys();
} finally {
lock.writeLock().unlock();
}
}
/**
* Clean up method to remove invalid keys, must be called from inside an
* acquired write lock.
*/
private void removeInvalidKeys() {
WatchKey key;
while ((key = invalidKeys.poll()) != null) {
watchKeyToPath.remove(key);
}
}
}
Data class:
public class PathEvents {
private final Path watched;
private final ImmutableList<WatchEvent<Path>> events;
private final boolean isValid;
/**
* Constructor.
*
* @param watched
* @param events
* @param isValid
*/
public PathEvents(Path watched, List<WatchEvent<Path>> events, boolean isValid) {
this.watched = watched;
this.events = ImmutableList.copyOf(events);
this.isValid = isValid;
}
/**
* Return an immutable list of WatchEvent's.
* @return
*/
public List<WatchEvent<Path>> getEvents() {
return events;
}
/**
* True if the watched path is valid.
* @return
*/
public boolean isIsValid() {
return isValid;
}
/**
* Return the path being watched in which these events occurred.
*
* @return
*/
public Path getWatched() {
return watched;
}
@Override
public boolean equals(Object obj) {
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final PathEvents other = (PathEvents) obj;
if (!Objects.equals(this.watched, other.watched)) {
return false;
}
if (!Objects.equals(this.events, other.events)) {
return false;
}
if (this.isValid != other.isValid) {
return false;
}
return true;
}
@Override
public int hashCode() {
int hash = 7;
hash = 71 * hash + Objects.hashCode(this.watched);
hash = 71 * hash + Objects.hashCode(this.events);
hash = 71 * hash + (this.isValid ? 1 : 0);
return hash;
}
@Override
public String toString() {
return "PathEvents{" + "watched=" + watched + ", events=" + events + ", isValid=" + isValid + '}';
}
}
And finally the test, note this is not a complete unit test but demonstrates the way to write tests for this situation.
public class FileWatcherTest {
public FileWatcherTest() {
}
Path path = Paths.get("myFile.txt");
Path parent = path.toAbsolutePath().getParent();
private void write(String s) throws IOException {
try (BufferedWriter bw = Files.newBufferedWriter(path, Charset.defaultCharset())) {
bw.write(s);
}
}
@Test
public void test() throws IOException, InterruptedException{
write("hello");
PathWatchService real = new PathWatchService();
real.register(parent);
PathWatchService mock = mock(PathWatchService.class);
FileWatcher fileWatcher = new FileWatcher(mock, path);
verify(mock).register(parent);
Assert.assertEquals("hello", fileWatcher.getData());
write("goodbye");
PathEvents pe = real.poll(10, TimeUnit.SECONDS);
if (pe == null){
Assert.fail("Should have an event for writing good bye");
}
when(mock.poll()).thenReturn(pe).thenReturn(null);
Assert.assertEquals("goodbye", fileWatcher.getData());
}
}
PowerMock
(which I try to avoid unless totally necessary) – Custodian