using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; using System.Web; namespace NetCoreServer { /// <summary> /// File cache is used to cache files in memory with optional timeouts. /// FileSystemWatcher is used to monitor file system changes in cached /// directories. /// </summary> /// <remarks>Thread-safe.</remarks> public class FileCache : IDisposable { public delegate bool InsertHandler(FileCache cache, string key, byte[] value, TimeSpan timeout); #region Cache items access /// <summary> /// Is the file cache empty? /// </summary> public bool Empty => _entriesByKey.Count == 0; /// <summary> /// Get the file cache size /// </summary> public int Size => _entriesByKey.Count; /// <summary> /// Add a new cache value with the given timeout into the file cache /// </summary> /// <param name="key">Key to add</param> /// <param name="value">Value to add</param> /// <param name="timeout">Cache timeout (default is 0 - no timeout)</param> /// <returns>'true' if the cache value was added, 'false' if the given key was not added</returns> 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; } } /// <summary> /// Try to find the cache value by the given key /// </summary> /// <param name="key">Key to find</param> /// <returns>'true' and cache value if the cache value was found, 'false' if the given key was not found</returns> 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); } } /// <summary> /// Remove the cache value with the given key from the file cache /// </summary> /// <param name="key">Key to remove</param> /// <returns>'true' if the cache value was removed, 'false' if the given key was not found</returns> public bool Remove(string key) { using (new WriteLock(_lockEx)) { return _entriesByKey.Remove(key); } } #endregion #region Cache management methods /// <summary> /// Insert a new cache path with the given timeout into the file cache /// </summary> /// <param name="path">Path to insert</param> /// <param name="prefix">Cache prefix (default is "/")</param> /// <param name="filter">Cache filter (default is "*.*")</param> /// <param name="timeout">Cache timeout (default is 0 - no timeout)</param> /// <param name="handler">Cache insert handler (default is 'return cache.Add(key, value, timeout)')</param> /// <returns>'true' if the cache path was setup, 'false' if failed to setup the cache path</returns> 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<string>(); } // Insert the cache path if (!InsertPathInternal(path, path, prefix, timeout, handler)) return false; return true; } /// <summary> /// Try to find the cache path /// </summary> /// <param name="path">Path to find</param> /// <returns>'true' if the cache path was found, 'false' if the given path was not found</returns> public bool FindPath(string path) { using (new ReadLock(_lockEx)) { // Try to find the given key return _pathsByKey.ContainsKey(path); } } /// <summary> /// Remove the cache path from the file cache /// </summary> /// <param name="path">Path to remove</param> /// <returns>'true' if the cache path was removed, 'false' if the given path was not found</returns> public bool RemovePath(string path) { return RemovePathInternal(path); } /// <summary> /// Clear the memory cache /// </summary> 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<string, MemCacheEntry> _entriesByKey = new Dictionary<string, MemCacheEntry>(); private Dictionary<string, HashSet<string>> _entriesByPath = new Dictionary<string, HashSet<string>>(); private Dictionary<string, FileCacheEntry> _pathsByKey = new Dictionary<string, FileCacheEntry>(); 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 } /// <summary> /// Disposable lock class performs exit action on dispose operation. /// </summary> public class DisposableLock : IDisposable { private readonly Action _exitLock; public DisposableLock(Action exitLock) { _exitLock = exitLock; } public void Dispose() { _exitLock(); } } /// <summary> /// Read lock class enters read lock on construction and performs exit read lock on dispose. /// </summary> public class ReadLock : DisposableLock { public ReadLock(ReaderWriterLockSlim locker) : base(locker.ExitReadLock) { locker.EnterReadLock(); } } /// <summary> /// Write lock class enters write lock on construction and performs exit write lock on dispose. /// </summary> public class WriteLock : DisposableLock { public WriteLock(ReaderWriterLockSlim locker) : base(locker.ExitWriteLock) { locker.EnterWriteLock(); } } /// <summary> /// String extensions utility class. /// </summary> 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; } }