Достаточно распространена ситуация, когда приложение очень часто запрашивает определенные данные. Например, профиль текущего пользователя. Это может приводить к заметному падению производительности из-за частых обращений к базе данных. В качестве решения можно использовать кэш, который может быть как локальным, так и использовать более оптимальные для данного случая системы хранения данных. Рассмотрим как добавить его незаметно для бизнес-логики приложения при использовании Dependency Injection. |
Описание решения
Изначально будем считать, что слой бизнес-логики использует интерфейсы для работы с данными. Во время выполнения он получает необходимые реализации из IoC контейнера.
Тогда возможно применить следующее решение:
- написать реализацию необходимого интерфейса для работы с данными, использующую кэш;
- эта реализация будет отвечать только за кэширование и использовать исходную реализацию для загрузки и сохранения данных из БД или другого источника;
- обеспечить передачу экземпляра класса реализации с кэшем в слой бизнес-логики, а исходной – в класс с кэшем.
Таком образом, класс, добавляющий кэш, является реализацией шаблона "Декоратор". Это позволит, при необходимости, кэшировать только определенные запросы. Например, можно добавить кэш только для части методов, а вызовы остальных просто переадресовывать к исходной реализации.
Так же, важно не забывать при изменении данных сбрасывать или обновлять их в кэше. Кроме того, кэш может содержать логику, которая автоматически удаляет данные по истечении определенного времени с момента их добавления.
Необходимо отметить, что вся настройка и работа с кэш должна содержаться в специализированных классах и не попадать в другие слои. Так, например, класс бизнес-логики не должен определять какие данные кэшируются и на какой срок.
Предположим, что для доступа к данным определен ISomeRepository. Тогда код может выглядеть так:
// Интерфейс для работы с данными
public interface ISomeRepository
{
SomeData Get(int id);
IEnumerable<SomeData> Find(string query);
void Update(SomeData data);
}
// Реализация для работы с БД, например, с помощью EntityFramework
public class SomeEFRepository : ISomeRepository
{
public SomeData Get(int id) { ... }
public IEnumerable<SomeData> Find(string query) { ... }
public void Update(SomeData data) { ... }
}
// Реализация, добавляющая кэширование
public class SomeCachedRepository : ISomeRepository
{
private readonly ISomeRepository _repository;
private readonly ICacheAccess _cache;
public SomeCachedRepository(ISomeRepository repository, ICacheAccess cache)
{
this._repository = repository;
this._cache = cache;
}
public SomeData Get(int id)
{
var data = this._cache.Get(id);
if (data == null) {
data = this._repository.Get(id);
this._cache.Add(id, data);
}
return data;
}
public IEnumerable<SomeData> Find(string query)
{
return this._repository.Find(query);
}
public void Update(SomeData data)
{
this._repository.Update(data);
this._cache.Reset(data.Id);
// или this._cache.Update(data);
}
}
В данном примере, метод Get() использует кэш, а Find() всегда обращается к данным напрямую.
Поскольку оба класса (SomeEFRepository и SomeCachedRepository) реализуют ISomeRepository, то кэширование будет прозрачно для бизнес-логики. Существующий класс доступа к данным (SomeEFRepository) так же не был затронут.
Может возникнуть вопрос: как получить нужную реализацию ISomeRepository если контейнере теперь их две? Многие IoC контейнеры позволяют регистрировать несколько реализаций одного и того же интерфейса, давая им разные имена. Воспользуемся тем, что слой бизнес-логики запрашивает реализацию без имени. Тогда необходимо сделать следующее:
- Зарегистрировать все реализации с кэш без имени.
- Зарегистрировать реализации прямого доступа к данным по правилу:
- с общим для всех именем, если есть соответствующая реализация с кэшем.
- в противном случае - без имени
Это позволит из реализаций с кэшем запрашивать именованные экземпляры доступа к данным. В тоже время, бизнес-логика будет получать правильный вариант (он всегда будет без имени).
Пример реализации
Рассмотрим все выше сказанное на примере с использованием библиотеки Unity.
Пусть изначально в приложении уже есть интерфейс IUserProfileRepository, его реализация прямого доступа к данным EFUserProfileRepository и некий класс в слое бизнес-логики, использующий IUserProfileRepository:
// Интерфейс доступа к данным
public interface IUserProfileRepository
{
UserProfile Get(int id);
void Update(UserProfile profile);
}
// Слой доступа к данным
public class EFUserProfileRepository : IUserProfileRepository
{
public UserProfile Get(int id)
{
Console.WriteLine(string.Format("Loading user {0} from DB", id));
return new UserProfile() { Id = id };
}
public void Update(UserProfile profile)
{
Console.WriteLine(string.Format("Updating user {0} profile", profile.Id));
}
}
// Слой бизнес-логики
public class SomeUserProfileProcessing
{
private readonly IUserProfileRepository _repository;
public SomeUserProfileProcessing(IUserProfileRepository repository)
{
this._repository = repository;
}
public void ProcessProfile()
{
// Имитируем работу с доступом к данным
this._repository.Get(1);
this._repository.Get(1);
this._repository.Update(new UserProfile() { Id = 1 });
this._repository.Get(1);
this._repository.Get(2);
this._repository.Get(1);
this._repository.Get(2);
}
}
Само приложение имеет вид:
class Program
{
static void Main(string[] args)
{
IUnityContainer container = BuildContainer();
var profileProcessor = container.Resolve<SomeUserProfileProcessing>();
Console.WriteLine("=========== First call");
profileProcessor.ProcessProfile();
Console.WriteLine("=========== Second call");
profileProcessor.ProcessProfile();
Console.WriteLine("Press any key ...");
Console.ReadKey(intercept: true);
}
private static IUnityContainer BuildContainer()
{
IUnityContainer container = new UnityContainer();
// Регистрируем классы слоя доступа к данным
container.RegisterType<IUserProfileRepository, EFUserProfileRepository>();
// Регистрируем классы бизнес-логики
container.RegisterType<SomeUserProfileProcessing>();
return container;
}
}
Теперь добавим кэш и посмотрим как сильно это изменит существующий код.
Создадим версию IUserProfileRepository с кэшем, которая будет запрашивать исходную реализацию доступа к данным по имени "DirectDataAccess". Для простоты в качестве кэша используем статическое поле:
public class CachedUserProfileRepository : IUserProfileRepository
{
private static readonly IDictionary<int, UserProfile> _cache = new Dictionary<int, UserProfile>();
private readonly IUserProfileRepository _repository;
public CachedUserProfileRepository([Dependency("DirectDataAccess")] IUserProfileRepository repository)
{
this._repository = repository;
}
public UserProfile Get(int id)
{
Console.WriteLine(string.Format("Getting user profile {0} from cache", id));
UserProfile profile = null;
if (!_cache.TryGetValue(id, out profile))
{
Console.WriteLine(string.Format("User profile {0} was not found in the cache.", id));
profile = this._repository.Get(id);
_cache.Add(profile.Id, profile);
}
return profile;
}
public void Update(UserProfile profile)
{
this._repository.Update(profile);
_cache.Remove(profile.Id);
}
}
Напишем расширение для контейнера Unity, которое будет регистрировать тип с именем или без, в зависимости от того есть ли реализация заданного интерфейса в контейнере:
public static class UnityExtensions
{
public static void RegisterWithOptionalName<TInterface, TImpl>(this IUnityContainer container, string name)
where TImpl : TInterface
{
if (container.IsRegistered<TInterface>())
container.RegisterType<TInterface, TImpl>(name);
else
container.RegisterType<TInterface, TImpl>();
}
}
Остается только чуть-чуть изменить процесс регистрации типов, чтобы всегда первую очередь добавлять в контейнер реализации с кэшем:
private static IUnityContainer BuildContainer()
{
IUnityContainer container = new UnityContainer();
// Регистрируем классы слоя доступа к данным
// Комментируя следующую строку можно управлять кэшированием
container.RegisterType<IUserProfileRepository, CachedUserProfileRepository>();
container.RegisterWithOptionalName<IUserProfileRepository, EFUserProfileRepository>("DirectDataAccess");
// Регистрируем классы бизнес-логики
container.RegisterType<SomeUserProfileProcessing>();
return container;
}
Все готово. Скачать код примера можно здесь: CachedRepository.zip
В результате:
- Изменения в существующий код минимальны (только настройка контейнера).
- Существующие классы доступа к данным и бизнес логики остались неизменными.
- Убирая или восстанавливая регистрацию типа CachedUserProfileRepository можно управлять наличием кэширования.
И в завершении необходимо отметить один недостаток в рассмотренном примере. Это неявное соглашение о регистрации реализаций с кэшем до реализаций прямого доступа к данным. Чтобы избавиться от него и избежать ошибок в порядке регистрации, можно автоматизировать процесс следующим образом:
- Всем реализациям с кэшем добавляем интерфейс-маркер, например, ICache.
- В начале метода регистрации собираем из всех сборок проекта классы, отмеченные маркером и регистрируем их в контейнере.
- Дальнейшая регистрация происходит без изменений по логике, описанной выше (реализации прямого доступа к данным так же могут быть найдены и зарегистрированы c использованием интерфейса-маркера).