Let’s say we want to write a .NET Core class that watches for file changes in a single directory. The implementation should only notify for explicitly subscribed files. For example, if DIR
is being watched and DIR\file1.txt
is subscribed, we should not receive any events for DIR\file2.txt
, nor for DIR\INNER\file1.txt
. Further, let’s presume that any time after subscribing, the caller could decide to unsubscribe. Finally, the entire watch procedure should be able to be stopped in one shot, so IDisposable
support is a must.
Clearly, we would be wise to start from FileSystemWatcher. This gives us 90% of the solution already (basic file and folder change detection) and cross-platform support to boot! Given that FileSystemWatcher allows many complicated patterns with subdirectories, custom filters, different event types, and so on, we would rather create a façade — we’ll call it DirectoryWatcher
.
Here is a basic implementation of DirectoryWatcher that should support the requirements:
public sealed class DirectoryWatcher : IDisposable { private readonly string path; private readonly ConcurrentDictionary<string, Subscription> subscriptions; private readonly FileSystemWatcher watcher; public DirectoryWatcher(DirectoryInfo path) { this.path = path.FullName; this.subscriptions = new ConcurrentDictionary<string, Subscription>(StringComparer.OrdinalIgnoreCase); this.watcher = new FileSystemWatcher(this.path); this.watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime; this.watcher.Changed += (o, e) => this.OnUpdated(e.FullPath); this.watcher.Created += (o, e) => this.OnUpdated(e.FullPath); this.watcher.Renamed += (o, e) => this.OnUpdated(e.FullPath); this.watcher.EnableRaisingEvents = true; } public void Dispose() => this.watcher.Dispose(); public IDisposable Subscribe(string file, Action<FileInfo> onUpdate) { FileInfo fullPath = new FileInfo(Path.Combine(this.path, file)); if (!string.Equals(fullPath.DirectoryName, this.path, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException( "The file '{file}' is not directly within directory '{path}'.", nameof(file)); } string key = fullPath.FullName; Subscription subscription = this.subscriptions.GetOrAdd( key, k => new Subscription(fullPath, onUpdate, () => this.subscriptions.TryRemove(k, out _))); if (!object.ReferenceEquals(subscription.FullPath, fullPath)) { throw new InvalidOperationException($"A subscription for '{key}' already exists."); } return subscription; } private void OnUpdated(string fullPath) { if (this.subscriptions.TryGetValue(fullPath, out Subscription subscription)) { subscription.Invoke(); } } private sealed class Subscription : IDisposable { private readonly Action<FileInfo> callback; private readonly Action onDispose; public Subscription(FileInfo fullPath, Action<FileInfo> callback, Action onDispose) { this.FullPath = fullPath; this.callback = callback; this.onDispose = onDispose; } public FileInfo FullPath { get; } public void Invoke() => this.callback(this.FullPath); public void Dispose() => this.onDispose(); } }
To show it in action, we can write a sample app; here is the relevant calling code for using the DirectoryWatcher
:
private static void RunWatcher(string[] files) { using DirectoryWatcher watcher = new DirectoryWatcher(new DirectoryInfo(".")); Action<FileInfo> onUpdated = f => Log($"Got an update for '{f.Name}'"); IDisposable lastFile = null; foreach (string file in files) { Console.WriteLine($"Subscribing to '{file}'"); lastFile = watcher.Subscribe(file, onUpdated); } Console.WriteLine("Press ENTER unsubscribe last file."); Console.ReadLine(); using (lastFile) { } Console.WriteLine("Press ENTER to dispose."); Console.ReadLine(); }
Running the sample app gives us plausibly correct output:
Subscribing to 'file1.txt' Subscribing to 'file2.txt' Press ENTER unsubscribe last file. [000.000/T5] ** delete 'file2.txt' [000.577/T5] ** append 'file2.txt' [000.579/T4] Got an update for 'file2.txt' [000.584/T4] Got an update for 'file2.txt' [001.171/T5] ** delete 'file2.txt' [001.744/T5] ** delete 'file1.txt' [001.824/T5] ** delete 'file2.txt' [002.678/T5] ** move to 'file1.txt' [002.686/T4] Got an update for 'file1.txt' [002.687/T6] Got an update for 'file1.txt' [002.711/T5] ** move to 'file2.txt' [002.717/T6] Got an update for 'file2.txt' [002.717/T4] Got an update for 'file2.txt' [002.868/T5] ** overwrite 'file2.txt' [002.868/T4] Got an update for 'file2.txt' [002.873/T4] Got an update for 'file2.txt' [003.806/T5] ** overwrite 'file2.txt' [003.807/T4] Got an update for 'file2.txt' [003.812/T4] Got an update for 'file2.txt' Press ENTER to dispose. [004.053/T5] ** append 'file1.txt' [004.058/T4] Got an update for 'file1.txt' [004.165/T5] ** append 'file1.txt' [004.170/T4] Got an update for 'file1.txt' [004.975/T5] ** overwrite 'file2.txt' [005.288/T5] ** overwrite 'file2.txt' [005.899/T5] ** move to 'file2.txt' [006.557/T7] ** append 'file2.txt' [006.974/T5] ** move to 'file2.txt' [007.795/T5] ** move to 'file2.txt' [008.741/T7] ** delete 'file2.txt' [008.927/T5] ** overwrite 'file1.txt' [008.928/T4] Got an update for 'file1.txt' [008.932/T4] Got an update for 'file1.txt' Press ENTER to quit. [009.461/T5] ** overwrite 'file2.txt' [010.100/T5] ** append 'file1.txt' [010.293/T7] ** append 'file1.txt' [010.438/T7] ** overwrite 'file1.txt' DirectoryWatcherSample\DirectoryWatcherSample.App\bin\Debug\netcoreapp3.1\DirectoryWatcherSample.App.exe (process 13048) exited with code 0. Press any key to close this window . . .
There are just a couple issues here. The first is evident in the program output, where we get more than one event for certain types of updates on a single file. Of course, this is well documented over the years by vexed users and insiders alike. The other issue is more of a self-inflicted design problem: the code is not easily testable since it has direct file system dependencies, and there is a lot of logic in there which has no platform dependencies that we want to be sure we got right.
Stay tuned for a few more posts which delve deeper into the design choices and address some of the issues raised. Until next time…
Pingback: Watching a directory: testability – WriteAsync .NET