При разработке любого высоконагруженного приложения рано или поздно встаёт вопрос кэширования данных. Ведь практически всегда есть часть информации которая изменяется достаточно редко и используется в качестве справочной. Постоянно запрашивать такую информацию из БД достаточно накладно, да и по большому счёту бессмысленно.
Наступил момент, когда и я при разработке такого высоконагруженного приложения столкнулся с этой проблемой. И решил её достаточно просто и безболезненно с помощью Unity Application Block.
Изначально Unity в проекте использовался по своему прямому назначению т.е. для внедрения зависимостей. Но когда встала задача кэширования, я вспомнил, что Unity позволяет осуществлять перехват вызова методов, а значит есть возможность реализовать парадигму аспекто-ориентированного программирования, что для данной задачи подходит как нельзя лучше.
Создание атрибута
Создадим специальный атрибут, которым будем помечать методы, результаты выполнения которых будут храниться в кэше приложения. Необходимо помнить, что разные данные устаревают с разной скоростью и для этого в атрибут добавлено время хранения данных в кэше, по истечении которого эти данные считаются устаревшими и удаляются из кэша автоматически.
////// Атрибут для пометки кэшируемого метода /// [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)] public class CachedAttribute : HandlerAttribute { ////// Максимальное время хранения результатов выполнения метода в кэше (сек.) /// public int CacheTimeSeconds { get; set; } ////// Максимальное время хранения результатов выполнения метода в кэше (мин.) /// public int CacheTimeMinutes { get { return CacheTimeSeconds / 60; } set { CacheTimeSeconds = value * 60; } } ////// Максимальное время хранения результатов выполнения метода в кэше (часов) /// public int CacheTimeHours { get { return CacheTimeSeconds / 3600; } set { CacheTimeSeconds = value * 3600; } } ////// Конструктор по умолчанию, время хранения по умолчанию = 10 минут /// public CachedAttribute() { CacheTimeMinutes = 10; } ////// Конструктор с указанием времени хранения результатов выполнения метода в кэше в секундах /// /// public CachedAttribute(int cacheTimeSeconds) { CacheTimeSeconds = cacheTimeSeconds; } public override ICallHandler CreateHandler(IUnityContainer container) { return new CacheAttributeHandler(CacheTimeSeconds); } }
Код атрибута в общем-то стандартный за исключением наследования специального класса HandlerAttribute.
Далее нам нужно создать собственно перехватчик события вызова метода. Вот его код.
private class CacheAttributeHandler : ICallHandler { private readonly int _maxCacheTime; ////// Конструктор /// /// public CacheAttributeHandler(int maxCacheTime) { _maxCacheTime = maxCacheTime; } #region ICallHandler Members public IMethodReturn Invoke(IMethodInvocation input, GetNextHandlerDelegate getNext) { object cachedResult = CacheContainer.CheckCache(input); if (cachedResult != null) return input.CreateMethodReturn(cachedResult); IMethodReturn mr = getNext()(input, getNext); CacheContainer.AddToCache(mr, input, _maxCacheTime); return mr; } public int Order { get; set;} #endregion }
Как видим, этот атрибут наследует интерфейс ICallHandler и реализует метод Invoke этого интерфейса. Invoke вызывается непосредственно перед вызовом самого метода.
Разберём метод Invoke подробнее. Сначала идёт проверка кэша на наличие в нём результата выполнения метода с данным именем и данными аргументами и их значениями. Если значение найдено в кэше — оно возвращается из кэша. Иначе запускается выполнение метода и результат перед выходом из процедуры сохраняется в кэш, чтобы быть использованным при повторном вызове.
Как вы уже догадались, чтобы воспользоваться кэшем достаточно пометить нужный метод атрибутом [Cached] и указать время, в течение которого данные будут храниться к кэше.
Собственно кэш
Далее приведена всего лишь одна из возможных реализаций кэша. Поясню, что в приведённом ниже примере в качестве главного контейнера испозуется Hashtable, в котором ключом служит имя метода, а значением — ещё одна Hashtable, которая в свою очередь содержит в качестве ключа список аргументов и их значений, а в качестве значения — результат выполнения этих методов.
Сделал я это из соображений производительности, а точнее скорости поиска по контейнеру.
static class CacheContainer { private static IDictionary _methodsCache; ////// Кэш для кэшей методов /// internal static IDictionary MethodsCache { get { return _methodsCache ?? (_methodsCache = CreateCache()); } } static IDictionary CreateCache() { return Hashtable.Synchronized(new Hashtable()); } static CacheContainer() { CacheCleanup.Init(); } private static string GetCacheKey(IMethodInvocation methodInvocation) { string argumentString = "arguments#"; for (int i = 0; i < methodInvocation.Arguments.Count; i++) { string argumentName = methodInvocation.Arguments.ParameterName(i); object value = methodInvocation.Arguments[i]; string argumentValue = value == null ? string.Empty : value.ToString(); argumentString += string.Format("{0}:{1};", argumentName, argumentValue); } return argumentString; } private static string GetMethodCacheKey(IMethodInvocation methodInvocation) { string key = methodInvocation.MethodBase.Name; return key; } ////// Проверить существует ли закэшированное значение. Если да - вернуть его /// /// ///internal static object CheckCache(IMethodInvocation methodInvocation) { // Ключ - имя метода string methodKey = GetMethodCacheKey(methodInvocation); // Находим нужный кэш результатов по имени метода object methodsCacheItem = MethodsCache[methodKey]; if (methodsCacheItem != null) { var cache = (IDictionary)methodsCacheItem; // ключ - имена аргументов и их значения string cacheKey = GetCacheKey(methodInvocation); // Находим кэшированный результат выполнения метода object cachedItem = cache[cacheKey]; if (cachedItem != null) { var item = cachedItem as CachedItem; if (item != null && !item.IsExpired) return item.MethodResult; } } return null; } /// /// Добавить результат выполнения метода в кэш /// /// /// /// internal static void AddToCache(IMethodReturn mr, IMethodInvocation methodInvocation, int maxCacheTime) { // Если метод вернул исключение - в кэш ничего не помещаем if(mr.Exception != null) return; string methodKey = GetMethodCacheKey(methodInvocation); lock (MethodsCache.SyncRoot) { // если в кэше методов не было кэша для результатов конкретного метода - создаём object cache = MethodsCache[methodKey] ?? CreateCache(); string cachekey = GetCacheKey(methodInvocation); lock (((IDictionary)cache).SyncRoot) { if (((IDictionary)cache).Contains(cachekey) == false) { var cachedItem = new CachedItem { MaxCacheTime = DateTime.Now.AddSeconds(maxCacheTime), MethodResult = mr.ReturnValue }; ((IDictionary) cache).Add(cachekey, cachedItem); MethodsCache[methodKey] = cache; } } } } }
Ниже класс, который производит очистку кэша, а также используется для сбора некоторой статистики его использования. При его создании запускается таймер, раз в минуту удаляющий устаревшие объекты из кэша. При очистке сначала идёт попытка получить доступ к контейнеру, и если это не удаётся — очередная очистка просто пропускается.
public static class CacheCleanup { ////// Время последней очистки кэша /// public static DateTime LastCleanupTime { get; private set; } ////// Количество очисток кэша /// public static int WorkTimes { get; private set; } ////// Время, затраченное на последнюю очистку кэша /// public static TimeSpan WorkTime { get; private set; } ////// Количество устаревших объектов в кэше /// public static int ObjectsExpired { get; private set; } ////// Количество объектов в кэше, оставшихся после очистки /// public static int ObjectsInCache { get; private set; } static Timer _timer; static readonly object SyncTimer = new object(); internal static void Init() { // когда выгружается домен приложения - останавливаем таймер AppDomain.CurrentDomain.DomainUnload += CurrentDomainDomainUnload; Start(); } static void CurrentDomainDomainUnload(object sender, EventArgs e) { Stop(); } private static void Start() { if (_timer == null) lock (SyncTimer) if (_timer == null) { TimeSpan interval = TimeSpan.FromSeconds(60); _timer = new Timer(Cleanup, null, interval, interval); } } private static void Stop() { if (_timer != null) lock (SyncTimer) if (_timer != null) { _timer.Dispose(); _timer = null; } } // Очистка кэша private static void Cleanup(object state) { if (!Monitor.TryEnter(CacheContainer.MethodsCache.SyncRoot, 10)) { // The Cache is busy, skip this turn. return; } DateTime start = DateTime.Now; LastCleanupTime = start; try { WorkTimes++; var list = new List(); int objectsInCache = 0, objectsExpired = 0; foreach (DictionaryEntry de in CacheContainer.MethodsCache) { var cache = (IDictionary) de.Value; lock (cache.SyncRoot) { foreach (DictionaryEntry entry in cache) { if (((CachedItem) entry.Value).IsExpired) list.Add(entry); } foreach (DictionaryEntry toRemove in list) { cache.Remove(toRemove.Key); objectsExpired++; } } list.Clear(); objectsInCache += cache.Count; } ObjectsExpired = objectsExpired; ObjectsInCache = objectsInCache; } catch(Exception ex) { Trace.WriteLine(ex); } finally { WorkTime += DateTime.Now - start; Monitor.Exit(CacheContainer.MethodsCache.SyncRoot); } } }
Вот собственно и всё решение. Статья получилось достаточно длинной. В ней я показал, как можно использовать возможности Unity чтобы быстро и безболезненно внедрить кэширование в вашем приложении, даже если вы изначально об этом и не задумывались. Результаты внедрения кстати превзошли самые смелые ожидания. Время выполнения некоторых методов, которые к тому же вызывались достаточно часто и создавали видимую нагрузку на БД, сократилось в сотни раз.