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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | 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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | 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