using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; using System.Web; namespace NetCoreServer { /// /// File cache is used to cache files in memory with optional timeouts. /// FileSystemWatcher is used to monitor file system changes in cached /// directories. /// /// Thread-safe. public class FileCache : IDisposable { public delegate bool InsertHandler(FileCache cache, string key, byte[] value, TimeSpan timeout); #region Cache items access /// /// Is the file cache empty? /// public bool Empty => _entriesByKey.Count == 0; /// /// Get the file cache size /// public int Size => _entriesByKey.Count; /// /// Add a new cache value with the given timeout into the file cache /// /// Key to add /// Value to add /// Cache timeout (default is 0 - no timeout) /// 'true' if the cache value was added, 'false' if the given key was not added public bool Add(string key, byte[] value, TimeSpan timeout = new TimeSpan()) { using (new WriteLock(_lockEx)) { // Try to find and remove the previous key _entriesByKey.Remove(key); // Update the cache entry _entriesByKey.Add(key, new MemCacheEntry(value, timeout)); return true; } } /// /// Try to find the cache value by the given key /// /// Key to find /// 'true' and cache value if the cache value was found, 'false' if the given key was not found public (bool, byte[]) Find(string key) { using (new ReadLock(_lockEx)) { // Try to find the given key if (!_entriesByKey.TryGetValue(key, out var cacheValue)) return (false, new byte[0]); return (true, cacheValue.Value); } } /// /// Remove the cache value with the given key from the file cache /// /// Key to remove /// 'true' if the cache value was removed, 'false' if the given key was not found public bool Remove(string key) { using (new WriteLock(_lockEx)) { return _entriesByKey.Remove(key); } } #endregion #region Cache management methods /// /// Insert a new cache path with the given timeout into the file cache /// /// Path to insert /// Cache prefix (default is "/") /// Cache filter (default is "*.*") /// Cache timeout (default is 0 - no timeout) /// Cache insert handler (default is 'return cache.Add(key, value, timeout)') /// 'true' if the cache path was setup, 'false' if failed to setup the cache path public bool InsertPath(string path, string prefix = "/", string filter = "*.*", TimeSpan timeout = new TimeSpan(), InsertHandler handler = null) { handler ??= (FileCache cache, string key, byte[] value, TimeSpan timespan) => cache.Add(key, value, timespan); // Try to find and remove the previous path RemovePathInternal(path); using (new WriteLock(_lockEx)) { // Add the given path to the cache _pathsByKey.Add(path, new FileCacheEntry(this, prefix, path, filter, handler, timeout)); // Create entries by path map _entriesByPath[path] = new HashSet(); } // Insert the cache path if (!InsertPathInternal(path, path, prefix, timeout, handler)) return false; return true; } /// /// Try to find the cache path /// /// Path to find /// 'true' if the cache path was found, 'false' if the given path was not found public bool FindPath(string path) { using (new ReadLock(_lockEx)) { // Try to find the given key return _pathsByKey.ContainsKey(path); } } /// /// Remove the cache path from the file cache /// /// Path to remove /// 'true' if the cache path was removed, 'false' if the given path was not found public bool RemovePath(string path) { return RemovePathInternal(path); } /// /// Clear the memory cache /// public void Clear() { using (new WriteLock(_lockEx)) { // Stop all file system watchers foreach (var fileCacheEntry in _pathsByKey) fileCacheEntry.Value.StopWatcher(); // Clear all cache entries _entriesByKey.Clear(); _entriesByPath.Clear(); _pathsByKey.Clear(); } } #endregion #region Cache implementation private readonly ReaderWriterLockSlim _lockEx = new ReaderWriterLockSlim(); private Dictionary _entriesByKey = new Dictionary(); private Dictionary> _entriesByPath = new Dictionary>(); private Dictionary _pathsByKey = new Dictionary(); private class MemCacheEntry { private readonly byte[] _value; private readonly TimeSpan _timespan; public byte[] Value => _value; public TimeSpan Timespan => _timespan; public MemCacheEntry(byte[] value, TimeSpan timespan = new TimeSpan()) { _value = value; _timespan = timespan; } public MemCacheEntry(string value, TimeSpan timespan = new TimeSpan()) { _value = Encoding.UTF8.GetBytes(value); _timespan = timespan; } }; private class FileCacheEntry { private readonly string _prefix; private readonly string _path; private readonly InsertHandler _handler; private readonly TimeSpan _timespan; private readonly FileSystemWatcher _watcher; public FileCacheEntry(FileCache cache, string prefix, string path, string filter, InsertHandler handler, TimeSpan timespan) { _prefix = prefix.Replace('\\', '/').RemoveSuffix('/'); _path = path.Replace('\\', '/').RemoveSuffix('/'); _handler = handler; _timespan = timespan; _watcher = new FileSystemWatcher(); // Start the filesystem watcher StartWatcher(cache, path, filter); } private void StartWatcher(FileCache cache, string path, string filter) { FileCacheEntry entry = this; // Initialize a new filesystem watcher _watcher.Created += (sender, e) => OnCreated(sender, e, cache, entry); _watcher.Changed += (sender, e) => OnChanged(sender, e, cache, entry); _watcher.Deleted += (sender, e) => OnDeleted(sender, e, cache, entry); _watcher.Renamed += (sender, e) => OnRenamed(sender, e, cache, entry); _watcher.Path = path; _watcher.IncludeSubdirectories = true; _watcher.Filter = filter; _watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite; _watcher.EnableRaisingEvents = true; } public void StopWatcher() { _watcher.Dispose(); } private static bool IsDirectory(string path) { try { // Skip directory updates if (File.GetAttributes(path).HasFlag(FileAttributes.Directory)) return true; } catch (Exception) {} return false; } private static void OnCreated(object sender, FileSystemEventArgs e, FileCache cache, FileCacheEntry entry) { var key = e.FullPath.Replace('\\', '/').Replace(entry._path, entry._prefix).RemoveSuffix('/'); var file = e.FullPath.Replace('\\', '/').RemoveSuffix('/'); // Skip missing files if (!File.Exists(file)) return; // Skip directory updates if (IsDirectory(file)) return; cache.InsertFileInternal(entry._path, file, key, entry._timespan, entry._handler); } private static void OnChanged(object sender, FileSystemEventArgs e, FileCache cache, FileCacheEntry entry) { if (e.ChangeType != WatcherChangeTypes.Changed) return; var key = e.FullPath.Replace('\\', '/').Replace(entry._path, entry._prefix).RemoveSuffix('/'); var file = e.FullPath.Replace('\\', '/').RemoveSuffix('/'); // Skip missing files if (!File.Exists(file)) return; // Skip directory updates if (IsDirectory(file)) return; cache.InsertFileInternal(entry._path, file, key, entry._timespan, entry._handler); } private static void OnDeleted(object sender, FileSystemEventArgs e, FileCache cache, FileCacheEntry entry) { var key = e.FullPath.Replace('\\', '/').Replace(entry._path, entry._prefix).RemoveSuffix('/'); var file = e.FullPath.Replace('\\', '/').RemoveSuffix('/'); cache.RemoveFileInternal(entry._path, key); } private static void OnRenamed(object sender, RenamedEventArgs e, FileCache cache, FileCacheEntry entry) { var oldKey = e.OldFullPath.Replace('\\', '/').Replace(entry._path, entry._prefix).RemoveSuffix('/'); var oldFile = e.OldFullPath.Replace('\\', '/').RemoveSuffix('/'); var newKey = e.FullPath.Replace('\\', '/').Replace(entry._path, entry._prefix).RemoveSuffix('/'); var newFile = e.FullPath.Replace('\\', '/').RemoveSuffix('/'); // Skip missing files if (!File.Exists(newFile)) return; // Skip directory updates if (IsDirectory(newFile)) return; cache.RemoveFileInternal(entry._path, oldKey); cache.InsertFileInternal(entry._path, newFile, newKey, entry._timespan, entry._handler); } }; private bool InsertFileInternal(string path, string file, string key, TimeSpan timeout, InsertHandler handler) { try { // Load the cache file content var content = File.ReadAllBytes(file); if (!handler(this, key, content, timeout)) return false; using (new WriteLock(_lockEx)) { // Update entries by path map _entriesByPath[path].Add(key); } return true; } catch (Exception) { return false; } } private bool RemoveFileInternal(string path, string key) { try { using (new WriteLock(_lockEx)) { // Update entries by path map _entriesByPath[path].Remove(key); } return Remove(key); } catch (Exception) { return false; } } private bool InsertPathInternal(string root, string path, string prefix, TimeSpan timeout, InsertHandler handler) { try { // Iterate through all directory entries foreach (var item in Directory.GetDirectories(path)) { string key = prefix + "/" + HttpUtility.UrlDecode(Path.GetFileName(item)); // Recursively insert sub-directory if (!InsertPathInternal(root, item, key, timeout, handler)) return false; } foreach (var item in Directory.GetFiles(path)) { string key = prefix + "/" + HttpUtility.UrlDecode(Path.GetFileName(item)); // Insert file into the cache if (!InsertFileInternal(root, item, key, timeout, handler)) return false; } return true; } catch (Exception) { return false; } } private bool RemovePathInternal(string path) { using (new WriteLock(_lockEx)) { // Try to find the given path if (!_pathsByKey.TryGetValue(path, out var cacheValue)) return false; // Stop the file system watcher cacheValue.StopWatcher(); // Remove path entries foreach (var entryKey in _entriesByPath[path]) _entriesByKey.Remove(entryKey); _entriesByPath.Remove(path); // Remove cache path _pathsByKey.Remove(path); return true; } } #endregion #region IDisposable implementation // Disposed flag. private bool _disposed; // Implement IDisposable. public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposingManagedResources) { // The idea here is that Dispose(Boolean) knows whether it is // being called to do explicit cleanup (the Boolean is true) // versus being called due to a garbage collection (the Boolean // is false). This distinction is useful because, when being // disposed explicitly, the Dispose(Boolean) method can safely // execute code using reference type fields that refer to other // objects knowing for sure that these other objects have not been // finalized or disposed of yet. When the Boolean is false, // the Dispose(Boolean) method should not execute code that // refer to reference type fields because those objects may // have already been finalized." if (!_disposed) { if (disposingManagedResources) { // Dispose managed resources here... Clear(); } // Dispose unmanaged resources here... // Set large fields to null here... // Mark as disposed. _disposed = true; } } #endregion } /// /// Disposable lock class performs exit action on dispose operation. /// public class DisposableLock : IDisposable { private readonly Action _exitLock; public DisposableLock(Action exitLock) { _exitLock = exitLock; } public void Dispose() { _exitLock(); } } /// /// Read lock class enters read lock on construction and performs exit read lock on dispose. /// public class ReadLock : DisposableLock { public ReadLock(ReaderWriterLockSlim locker) : base(locker.ExitReadLock) { locker.EnterReadLock(); } } /// /// Write lock class enters write lock on construction and performs exit write lock on dispose. /// public class WriteLock : DisposableLock { public WriteLock(ReaderWriterLockSlim locker) : base(locker.ExitWriteLock) { locker.EnterWriteLock(); } } /// /// String extensions utility class. /// public static class StringExtensions { public static string RemoveSuffix(this string str, char toRemove) => str.EndsWith(toRemove) ? str.Substring(0, str.Length - 1) : str; public static string RemoveSuffix(this string str, string toRemove) => str.EndsWith(toRemove) ? str.Substring(0, str.Length - toRemove.Length) : str; } }