Andrey on .NET | “Незаметное” добавление кэширования

“Незаметное” добавление кэширования

C# logoДостаточно распространена ситуация, когда приложение очень часто запрашивает определенные данные. Например, профиль текущего пользователя. Это может приводить к заметному падению производительности из-за частых обращений к базе данных. В качестве решения можно использовать кэш, который может быть как локальным, так и использовать более оптимальные для данного случая системы хранения данных. Рассмотрим как добавить его незаметно для бизнес-логики приложения при использовании 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 контейнеры позволяют регистрировать несколько реализаций одного и того же интерфейса, давая им разные имена. Воспользуемся тем, что слой бизнес-логики запрашивает реализацию без имени. Тогда необходимо сделать следующее:

  1. Зарегистрировать все реализации с кэш без имени.
  2. Зарегистрировать реализации прямого доступа к данным по правилу:
    • с общим для всех именем, если есть соответствующая реализация с кэшем.
    • в противном случае - без имени

Это позволит из реализаций с кэшем запрашивать именованные экземпляры доступа к данным. В тоже время, бизнес-логика будет получать правильный вариант (он всегда будет без имени).

Пример реализации

Рассмотрим все выше сказанное на примере с использованием библиотеки 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 использованием интерфейса-маркера).

Комментарии (8) -

Олег Аксенов 25.03.2016 12:32:45

Ещё можно зайти немного дальше и добавить кэширующий AOP (можно и средствами Unity, наверное), не зависящий от конкретного метода.

Максим 25.03.2016 12:44:54

А как избежать ситуацию, если кэшируемых данных очень много и со временем забивается оперативка. Было бы интересно почитать про реализацию механизма очистки кэшей по истечении определенного срока.

Олег Я так понимаю ты хочешь кэшировать уже результат метода. Это не всегда применимо. Например если возвращаются различные срезы данных (а это часто), то есть смысл кэшировать всю сущность.
А вообще подход интересный, но думаю уместен если приложение уже использует такие способы для решения своих задач.

Максим Самое простое - удалять данные по мере заполнения кэша. Например при лимите в 10000 записей при добавлении еще одной автоматически будет удалена самая старая. Вариант посложнее - удалять самые неиспользуемые.
Что касается срока, то достаточно проверки в самой реализации кэша при извлечении. Запросили уже устаревшее - вернуть null и удалить объект.
Тут кстати многое зависит от того, как и на базе чего реализован кэш, какие данные хранит.

Костя 25.03.2016 14:20:52

А не подскажите, нельзя ли было SomeCachedRepository унаследовать от SomeEFRepository? Что бы не реализовать те методы, которые не кэшируются?

Костя: Можно, в плане того, что такой код скомпилируется и будет работать (ведь Декоратор является альтернативой наследованию). Но есть моменты в плане проектирования, на которые стоит обратить внимание (и которые мне не нравятся)
1)  Кэш будет знать о конкретной реализации и будет связан с ней. Я предпочитаю, чтобы он был независимой настройкой.
2)  Вам придется отметить все методы исходной реализации как virtual (т.е. изменить существующий код, а этого я старался избегать при реализации)
Кроме того, при наследовании проще упустить сброс элементов кэша в методах, которые сохраняют данные. А создание новой реализации интерфейса заставит вас явно пробежаться по всем его методам (сделав переадресацию вызова или поддержку кэш в них).

В работе использовали похожий подход.
Основные сложности были, как ни странно, с инвалидацией кеша. Например, данные в кеше могут быть не актуальны на момент запроса (устареть) или в кеше могут быть зависимые данные, при изменении одной записи набор других может устареть и т.д.

Не совсем понятно зачем расширение UnityExtensions ?

Alex Оно реализует правило регистрации реализаций, которое описано в тексте над заголовком "Пример реализации".

Т.е. как и сказано там - задача состоит в том, чтобы все передаваемые в бизнес-логику реализации были без имени. А те реализации, которые были замещены вариантами с кэшем, получили имя. В свою очередь, наличие этого имени позволит их передать как параметр конструктора при создании варианта с кэшем.

Добавить комментарий