Pull to refresh

Глобальное кеширование результатов Query в ASP.NET CORE

Reading time10 min
Views8.5K

Парадигма CQRS в том или ином виде предполагает, что вызовы Query не будут менять состояние приложения. То есть многократные вызовы одной и той же query, в рамках одного запроса, будут иметь один и тот же результат.


Пусть все интерфейсы, использующиеся в рамках запроса будут иметь тип IQuery или IAsyncQuery:


public interface IQuery<TIn, TOut>
{
   TOut Query(TIn input);
}

public interface IAsyncQuery<TIn, TOut>: IQuery<TIn, Task<TOut>
{
}

Эти интерфейсы полно описывают получение данных, например, получение отформатированных цен с учетом скидок/бонусов и всего прочего:


public class ProductPriceQuery: IQuery<ProductDto,PricePresentationDto>
{
  public ProductPriceQuery(
     IQuery<ProductDto, PriceWithSalesDto> priceWithSalesQuery,
     IQuery<PriceWithSalesDto, PricePresentationDto> pricePresentationQuery)
  {
     _priceWithSalesQuery = priceWithSalesQuery;
     _pricePresentationQuery = pricePresentationQuery;
  }

  public PricePresentationDto Query(ProductDto dto) 
  {
     var withSales = _priceWithSalesQuery(dto);
     var result = _pricePresentationQuery(withSales);
     return result;
  }
}

Pipeline интерфейсов


Плюс такого подхода — единообразие интерфейсов в приложении, которые можно выстраивать в pipeline:


public class Aggregate2Query<TIn, TOut1, TOut2> : BaseAggregateQuery<TIn, TOut2>
 {
   public Aggregate2Query(
      IQuery<TIn, TOut1> query0,
      IQuery<TOut1, TOut2> query1)
      : base(query0, query1){}
 }

public abstract class BaseAggregateQuery<TIn, TOut> : IQuery<TIn, TOut>
 {
   private object[] queries { get; set; }

   protected BaseAggregateQuery(params object[] queries)
   {
       this.queries = queries;
   }

   public TOut Query(TIn input) =>
       queries.Aggregate<object, dynamic>(input, (current, query) =>
       ((dynamic) query).Query(current));
 }

Регистрировать вот так:


serviceCollection.AddScoped(typeof(Aggregate2Query<,,>));

Получаем:


public ProductPriceQuery(
   BaseAggregateQuery<ProductDto,PriceWithSalesDto,PricePresentationDto>  query)
{
  _aggregateQuery = query;
}

public PricePresentationDto Query(ProductDto dto) => _aggregateQuery.Query(dto);

В идеале программирование должно превратиться в сборку конструктора, но на деле это просто красивая фишечка для удовлетворения самолюбия программиста.


Декораторы и ASP.NET CORE


Библиотека MediatR построена как раз на единообразии интерфейсов и на декораторах.


Декораторы позволяют навесить на стандартный интерфейс IQuery<TIn, TOut> некоторые дополнительные функции, например логгирование:


public class LoggingQuery<TIn,TOut>: IQuery<TIn,TOut>
{
   public LoggingQuery(IQuery<TIn,TOut> priceQuery)
   {
      _priceQuery = priceQuery
   }

   public TOut Query(TIn input)
   {
     Console.WriteLine($"Query {_priceQuery.GetType()} Start");
     var result= _priceQuery.Query(input);
     Console.WriteLine($"Query {_priceQuery.GetType()} End");
     return result; 
   }
}

Я опущу то, что декораторы позволяют написать сквозную(cross-cutting) функциональность в одном месте, а не размазывать по всей программе, это не входит в рамки данной статьи.


Стандартный IoC контейнер предоставляемый .Net Core не умеет регистрировать декораторы.Сложность в том что у нас есть две реализации одного интерфейса: исходная query и декоратор, и в конструктор декоратора приходит тот же интерфейс, который декоратор реализует. Контейнер не может разрешить такой граф и кидает ошибку "circular dependency".


Есть несколько путей решения этой проблемы.Специально для .Net Core контейнера написана библиотека Scrutor, она умеет регистрировать декораторы:


 services.Decorate(typeof(IQuery<,>), typeof(LoggingQuery<,>));

Если вы не хотите добавлять в проект лишние зависимости, то можно написать этот функционал самому, что я и сделал. Перед демонстрацией кода давайте обсудим кеширования результатов Query в рамках запроса. Если нужно добавить кеширование и ключом является класс, необходимо переопределить GetHashCode и Equals, так мы отвяжемся от сравнения по ссылке.


Способы кеширования


Я представлю пример простого кеша:


//Cache
 ConcurrentDictionary<Key,Value> _cache { get; }
//Key
 public class Key
 {
   //ReSharper-generated code
   protected bool Equals(Key other)
   {
      return Field1 == other.Field1 && Field2 == other.Field2;
   }
   //ReSharper-generated code
   public override bool Equals(object obj)
   {
       if (ReferenceEquals(null, obj)) return false;
       if (ReferenceEquals(this, obj)) return true;
       if (obj.GetType() != this.GetType()) return false;
       return Equals((Key) obj);
   }
   //ReSharper-generated code
   public override int GetHashCode()
   {
       unchecked
       {
           return (Field1 * 397) ^ Field2;
       }
   }

   public int Field1 { get; set; }
   public int Field2 { get; set; }
}
//Value irrelevant

При поиске значения сначала вызывается метод GetHashCode для поиска нужной корзины, а потом, если в корзине более одного элемента, вызывается Equals для сравнения.Посмотрите, если не совсем понимаете как это работает


ReSharper сам генерирует эти методы, но мы реализуем кеширование глобально, об этом не обязан знать программист реализовавший IQuery<TIn, TOut> интерфейс и вообще интерфейс IQuery<TIn,TOut>, не будем забывать об SRP. Поэтому генерация методов решарпером нам не подходит.


Когда мы имеем дело со сквозной функциональностью на помощь приходят АОП фреймворки. EqualsFody, плагин для Fody, переписывает IL, переопределяя Equals и GetHashCode в классах помеченных атрибутом EqualsAttribute.


Чтобы не помечать каждый Dto этим атрибутом мы можем немного переписать интерфейс IQuery


public IQuery<TIn,TOut> where TIn : CachedDto{

}

[Equals]
public class CachedDto{

}

Теперь все Dto точно будут переопределять нужные методы, и нам не нужно добавлять атрибут на каждый входной DTO (он будет подхвачен из базового класса). Если перезаписывать IL вам не подходит, реализуйте CachedDto так (используем контекст в котором вызываются методы базового класса):


public class CachedDto{
  public override bool Equals(object x) => DeepEquals.Equals(this,x);
  public override int GetHashCode() => DeepHash.GetHashCode(this);
}

DeepEquals.Equals и DeepHash.GetHashCode используют рефлексию, будет медленнее чем Fody, для корпоративных приложений не фатально.


Но вспоминаем про SRP, IQuery не должен знать про то, что его кешируют.


Наиболее верным решением будет реализация IEqualityComparer. Dictionary принимает его в конструкторе и использует при вставке/удалении/поиске.


 public class EqualityComparerUsingReflection<TKey> : IEqualityComparer<TKey>
 {
   public bool Equals(TKey x, TKey y) => DeepEqualsCommonType(x, y);

   public int GetHashCode(TKey obj) => Hash.GetHashCode(obj);
 }

Теперь можно выкинуть constraint на TIn, мы добились чего хотели. Напишем кеширующий декоратор:


 public class BaseCacheQuery<TIn, TOut> : IQuery<TIn, TOut>
 {
    private readonly ConcurrentDictionary<TIn, TOut> _cache;

    private readonly IQuery<TIn, TOut> _query;

    protected BaseCacheQuery(
        IQuery<TIn, TOut> query,
        IConcurrentDictionaryFactory<TIn, TOut> factory)
    {
        _cache = factory.Create();
        _query = query;
    }

    public TOut Query(TIn input) => _cache
        .GetOrAdd(input, x => _query.Query(input));
  }

Обратите внимание на IConcurrentDictionaryFactory, цель этой фабрики предоставить экземпляр словаря, но почему бы просто не создать его в конструкторе?


Во-первых, DI и SRP, вполне возможно, что нужно будет добавить еще одну реализацию компарера(например более легкую для определенных типов DTO) или вообще поменять реализацию, во-вторых, возможна ситуация, когда кеш начнет тормозить из-за рефлексии и абстрация протечет. Я пойду на компромисс и если в Dto переопределены Equals и GetHashCode не буду использовать "тяжелый" EqualityComparer.


Цель фабрики — проверить переопределены ли методы, если да, вернуть стандартный словарь, использующий переопределенные в DTO методы, нет — словарь с компарером.


Регистрация


Вернемся к тому, как все это регистрировать.


Аргумент services метода ConfigureServices это коллекция ServiceDescriptor'ов, каждый дескриптор содержит информацию о регистрируемой зависимости


public class ServiceDescriptor{
    // other methods

    /// <inheritdoc />
    public ServiceLifetime Lifetime { get; }

    /// <inheritdoc />
    public Type ServiceType { get; }

    /// <inheritdoc />
    public Type ImplementationType { get; }

    /// <inheritdoc />
    public object ImplementationInstance { get; }

    /// <inheritdoc />
    public Func<IServiceProvider, object> ImplementationFactory { get; }

  // other methods
}

Таким образом в коллекцию services добавляется новый ServiceDescriptor с LifeTime = Scoped,
ServiceType = typeof(IService), ImplementType = typeof(Service) :


services.AddScoped<IService,Service>().

Свойство ImplementationFactory позволяет конкретно указать, как нужно создавать зависимость, мы будет использовать его. Я напишу extension к IServiceCollection, который найдет в сборках все IQuery и IAsyncQuery, навесит декораторы и зарегистрирует.


 public static void AddCachedQueries(this IServiceCollection serviceCollection)
 {
   // Func<Type,bool>  выбирает типы реализующие IAsyncQuery
     var asyncQueryScanPredicate = AggregatePredicates(
     IsClass, ContainsAsyncQueryInterface);

    // Func<Type,bool> который выбирает типы реализующие IQuery
     var queryScanAssemblesPredicate =AggregatePredicates(
      IsClass, x => !asyncQueryScanPredicate(x), ContainsQueryInterface);

     // находит все реализации IAsyncQuery в сканируемых сборках
     var asyncQueries = GetAssemblesTypes(
     asyncQueryScanPredicate, DestAsyncQuerySourceType);
     // находит все реализации IQuery в сканируемых сборках
     var queries = GetAssemblesTypes(
     queryScanAssemblesPredicate, DestQuerySourceType);
     //регистрация фабрики создающую ConcurrentDictionary
     serviceCollection.AddScoped(
     typeof(IConcurrentDictionaryFactory<,>), typeof(ConcDictionaryFactory<,>));
     // добавляет в services ServiceDescriptor'ы для регистрации IAsyncQuery
     serviceCollection.QueryDecorate(asyncQueries, typeof(AsyncQueryCache<,>));
     // добавляет в services ServiceDescriptor'ы для регистрации IQuery
     serviceCollection.QueryDecorate(queries, typeof(QueryCache<,>));
   }

   private static void QueryDecorate(this IServiceCollection serviceCollection,
       IEnumerable<(Type source, Type dest)> parameters, Type cacheType,
       ServiceLifetime lifeTime = ServiceLifetime.Scoped)
   {
       foreach (var (source, dest) in parameters)
           serviceCollection.AddDecorator(
              cacheType.MakeGenericType(source.GenericTypeArguments),
              source,
              dest,
              lifeTime);
   }

    private static void AddDecorator(
       this IServiceCollection serviceCollection,
       Type cacheType, Type querySourceType,
       Type queryDestType,
       ServiceLifetime lifetime = ServiceLifetime.Scoped)
   {
    //ReSharper disable once ConvertToLocalFunction  
    Func<IServiceProvider, object> factory = provider 
       => ActivatorUtilities.CreateInstance(provider, cacheType,
       ActivatorUtilities.GetServiceOrCreateInstance(provider, queryDestType));

    serviceCollection.Add(
       new ServiceDescriptor(querySourceType, factory, lifetime));
   }
}

Особого внимания заслуживает метод AddDecorator, здесь используются статические методы класса ActivatorUtilities. ActivatorUtilities.CreateInstance принимает IServiceProvider, тип создаваемого объекта и экземпляры зависимостей которые этот объект принимает в конструкторе(можно указать только те, которые не зарегистрированы, остальные будут разрешены провайдером)


ActivatorUtilities.GetServiceOrCreateInstance — делает тоже самое, но не позволяет передавать в конструктор создаваемого объекта недостающие зависимости. Если объект зарегистрирован в контейнере то он просто создаст его(либо вернет уже созданный), если же нет, создаст объект, при условии, что сможет решить все его зависимости


Тем самым можно создать функцию возвращающую объект кеша и добавить в services дескриптор описывающий эту регистрацию.


Напишем тест:


public class DtoQuery : IQuery<Dto, Something>
{
   private readonly IRepository _repository;

   public DtoQuery(IRepository repository)
   {
      _repository = repository;
   }

   public Something Query(Dto input) => _repository.GetSomething();
}
// инициализация из контейнера
private IQuery<Dto, Something> query { get; set; }

public void TwoCallQueryTest()
{
   var dto = new Dto {One = 1};
   var dto1 = new Dto {One = 1};
   //query - зарегистрирован нашим расширением и получены из контейнера
   query.Query(dto);
   query.Query(dto1);
   //за кулисами: services.AddScoped<IRepository>(x => MockRepository.Object)
   RepositoryMock.Verify(x => x.GetSomething(), Times.Once);
}

ReposityMock — Mock от библиотеки Moq, забавно, но для тестирования того, сколько раз был вызван метод GetSomething() репозитория она тоже использует декораторы, правда генерирует их автоматически используя Castle.Interceptor. Мы тестируем декораторы используя декораторы.

Вот так можно добавить кеширование всех результатов IQuery<TIn,TOut>, очень неудобно писать столько кода чтобы реализовать небольшую функциональность.


Другие решения


MediatR


Центральный интерфейс библиотеки:


public interface IRequestHandler<in TRequest, TResponse> 
  where TRequest : IRequest<TResponse>
{
  Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}

Основная функциональность MediatR — добавление оберток над IRequestHandler'ом, например, реализация pipeline с помощью интерфейса IPipelineBehavior, вот так можно зарегистрировать CachePipelineBehaviour, он будет применяться ко всем зарегистрированным интерфейсам IRequestHandler:


sc.AddScoped(typeof(IPipelineBehavior<,>), typeof(CachePipelineBehaviour<,>));

Реализуем кеширующий PipelineBehaviour:


public class CachePipelineBehaviour<TDto, TResult> 
                : IPipelineBehavior<TDto, TResult>
{
  private readonly ConcurrentDictionary<TDto, Task<TResult>> _cache;

  public CachePipelineBehaviour(
      IConcurrentDictionaryFactory<TDto, Task<TResult>> cacheFactory)
  {
      _cache = cacheFactory.Create();
  }

   public async Task<TResult> Handle(TDto request, 
      CancellationToken cancellationToken,
      RequestHandlerDelegate<TResult> next) =>
      await _cache.GetOrAdd(request, x => next());
}

В метод Handle приходит Dto запроса, токен отмены и RequestHandlerDelegate. Последний это просто обертка над следующими вызовами других декораторов и handler'a. MediatR сканирует сборки и сам регистрирует все реализации интерфейса. Чтобы воспользоваться вам нужно инжектировать IMediator, и вызвать у него метод Send передав Dto:


public async Task<IActionResult>([FromBody] Dto dto){
    return Ok(mediator.Send(dto));
}

MediatR сам найдет найдет подходящую реализацию IRequestHabdler'a и применит все декораторы (Кроме PipelineBehaviour есть еще IPreRequestHandler и IPostRequestHandler)


Castle Windsor


Фишка контейнера — генерация динамических оберток, это динамическое АОП.


Entity Framework использует его для Lazy Loading, в геттере свойства вызывается метод Load интерфейса ILazyLoader, который инжектируется в классы всех оберток над сущностями через внедрение конструктора.


Чтобы сконфигурировать контейнер с генерацией оберток нужно создать Interceptor и зарегистрировать его


  public  class CacheInterceptor<TIn, TOut> : IInterceptor
 {
   private readonly ConcurrentDictionary<TIn, TOut> _cache;

   public CacheInterceptor(
         IConcurrentDictionaryFactory<TIn, TOut> cacheFactory)
   {
       _cache = cacheFactory.Create();
   }

   public void Intercept(IInvocation invocation)
   {
       var input = (TIn) invocation.Arguments.Single();
       if (_cache.TryGetValue(input, out var value))
           invocation.ReturnValue = value;
       else
       {
           invocation.Proceed();
           _cache.TryAdd(input, (TOut) invocation.ReturnValue);
       }
     }
 }

Интерфейс IInvocation предоставляет информацию о члене декорируемого объекта, к которому было обращение, единственный публичный член интерфейса это метод Query, поэтому не будем ставить проверки, что обращение было именного к нему, других вариантов нет.


Если в кеше есть объект с таким ключом, заполним возвращаемое значение метода (не вызывая его), если же нет, вызовем метод Proceed, который, в свою очередь, вызовет декорируемый метод и заполнит ReturnValue.


Регистрацию перехватчика и полный код можно посмотреть на Githab

Tags:
Hubs:
+13
Comments11

Articles

Change theme settings