Хабрахабр

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

То есть многократные вызовы одной и той же query, в рамках одного запроса, будут иметь один и тот же результат. Парадигма CQRS в том или ином виде предполагает, что вызовы 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 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) функциональность в одном месте, а не размазывать по всей программе, это не входит в рамки данной статьи.

Net Core не умеет регистрировать декораторы.Сложность в том что у нас есть две реализации одного интерфейса: исходная query и декоратор, и в конструктор декоратора приходит тот же интерфейс, который декоратор реализует. Стандартный IoC контейнер предоставляемый . Контейнер не может разрешить такой граф и кидает ошибку "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{ }

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

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

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

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

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

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, цель этой фабрики предоставить экземпляр словаря, но почему бы просто не создать его в конструкторе?

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

Цель фабрики — проверить переопределены ли методы, если да, вернуть стандартный словарь, использующий переопределенные в 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>().

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

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)); }
}

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

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

Тем самым можно создать функцию возвращающую объект кеша и добавить в 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);
}

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

Вот так можно добавить кеширование всех результатов 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());
}

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

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

Показать больше

Похожие публикации

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»