Almacenamiento en caché de resultados de consultas globales en ASP.NET CORE

El paradigma CQRS de una forma u otra supone que las llamadas de consulta no cambiarán el estado de la aplicación. Es decir, varias llamadas a la misma consulta dentro de la misma consulta tendrán el mismo resultado.


Deje que todas las interfaces utilizadas como parte de la consulta sean del tipo IQuery o IAsyncQuery:


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

Estas interfaces describen completamente la recepción de datos, por ejemplo, la recepción de precios formateados teniendo en cuenta descuentos / bonificaciones y todo lo demás:


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

Interfaces de tubería


La ventaja de este enfoque es la uniformidad de las interfaces en la aplicación, que se puede construir en la tubería:


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

Regístrate así:


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

Obtenemos:


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

Idealmente, la programación debería convertirse en un ensamblaje del constructor, pero en realidad es solo un hermoso truco para satisfacer el orgullo del programador.


Decoradores y ASP.NET CORE


La biblioteca MediatR está construida precisamente sobre la uniformidad de las interfaces y los decoradores.


Los decoradores le permiten colgar algunas funciones adicionales en la interfaz estándar IQuery <TIn, TOut>, por ejemplo, el registro:


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

Omitiré el hecho de que los decoradores le permiten escribir funcionalidades transversales en un solo lugar, en lugar de difundirlo por todo el programa, esto no es parte del alcance de este artículo.


El contenedor IoC estándar proporcionado por .Net Core no puede registrar decoradores. La dificultad es que tenemos dos implementaciones de la misma interfaz: la consulta original y el decorador, y la misma interfaz que implementa el decorador llega al constructor del decorador. El contenedor no puede resolver dicho gráfico y arroja un error de "dependencia circular".


Hay varias formas de resolver este problema. Especialmente para el contenedor .Net Core, la biblioteca Scrutor está escrita, puede registrar decoradores:


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

Si no desea agregar dependencias adicionales al proyecto, puede escribir esta funcionalidad usted mismo, lo cual hice. Antes de demostrar el código, analicemos el almacenamiento en caché de los resultados de la consulta como parte de una consulta. Si necesita agregar almacenamiento en caché y la clave es la clase, debe anular GetHashCode e Equals, por lo que eliminaremos la comparación por referencia.


Métodos de almacenamiento en caché


Presentaré un ejemplo de un caché simple:


 //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 

Al buscar un valor, primero se llama al método GetHashCode para encontrar la cesta deseada y luego, si la cesta tiene más de un elemento, se llama a Equals para comparar. Vea si no comprende bien cómo funciona.


ReSharper genera estos métodos, pero implementamos el almacenamiento en caché a nivel mundial, el programador que implementó la interfaz IQuery <TIn, TOut> y, en general, la interfaz IQuery <TIn, TOut>, no se olvide de SRP. Por lo tanto, la generación de métodos por parte del resharper no nos conviene.


Cuando nos ocupamos de la funcionalidad de extremo a extremo, los marcos de AOP vienen al rescate. EqualsFody, un complemento para Fody, reescribe IL, anulando Equals y GetHashCode en clases marcadas con el atributo EqualsAttribute.


Para no marcar cada Dto con este atributo, podemos reescribir un poco la interfaz IQuery


 public IQuery<TIn,TOut> where TIn : CachedDto{ } [Equals] public class CachedDto{ } 

Ahora, todos los Dto definitivamente redefinirán los métodos necesarios, y no necesitamos agregar un atributo a cada DTO de entrada (se seleccionará de la clase base). Si reescribir IL no es adecuado para usted, implemente CachedDto de esta manera (use el contexto en el que se llaman los métodos de la clase base):


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

DeepEquals.Equals y DeepHash.GetHashCode usan la reflexión, será más lenta que Fody, no es fatal para aplicaciones corporativas.


Pero recuerde acerca de SRP, IQuery no debe saber que está en caché.


La mejor solución sería implementar IEqualityComparer. El diccionario lo toma en el constructor y lo usa al insertar / eliminar / buscar.


  public class EqualityComparerUsingReflection<TKey> : IEqualityComparer<TKey> { public bool Equals(TKey x, TKey y) => DeepEqualsCommonType(x, y); public int GetHashCode(TKey obj) => Hash.GetHashCode(obj); } 

Ahora puede lanzar restricciones en TIn, logramos lo que queríamos. Escribamos un decorador de caché:


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

Preste atención a IConcurrentDictionaryFactory, el objetivo de esta fábrica es proporcionar una instancia del diccionario, pero ¿por qué no simplemente crearlo en el constructor?


En primer lugar, DI y SRP, es bastante posible que necesite agregar otra implementación de comparación (por ejemplo, más fácil para ciertos tipos de DTO) o cambiar la implementación por completo, y en segundo lugar, una situación es posible cuando el caché comienza a disminuir debido a la reflexión y la abstracción fugas Me comprometeré y si Equals se anula en Dto y GetHashCode no usará el EqualityComparer "pesado".


El propósito de la fábrica es verificar si se anulan los métodos, de ser así, devolver un diccionario estándar utilizando métodos redefinidos en el DTO, no, un diccionario con comparador.


Registro


Volvamos a cómo registrar todo esto.


El argumento de servicios del método ConfigureServices es una colección de descriptores de servicio, cada descriptor contiene información sobre la dependencia registrada


 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 } 

Por lo tanto, se agrega un nuevo ServiceDescriptor con LifeTime = Scoped a la colección de servicios.
ServiceType = typeof (IService), ImplementType = typeof (Service):


 services.AddScoped<IService,Service>(). 

La propiedad ImplementationFactory le permite especificar cómo crear la dependencia; la usaremos. Escribiré una extensión para IServiceCollection, que encontrará todos IQuery e IAsyncQuery en ensamblajes, decoradores colgantes y registros.


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

El método AddDecorator merece especial atención, aquí usamos los métodos estáticos de la clase ActivatorUtilities. ActivatorUtilities.CreateInstance acepta IServiceProvider, el tipo de objeto a crear y las instancias de dependencia que este objeto acepta en el constructor (puede especificar solo aquellos que no están registrados, el resto lo permitirá el proveedor)


ActivatorUtilities.GetServiceOrCreateInstance: hace lo mismo, pero no permite que se pasen las dependencias faltantes al constructor del objeto creado. Si el objeto está registrado en el contenedor, simplemente lo creará (o devolverá el ya creado), de lo contrario, creará el objeto, siempre que pueda resolver todas sus dependencias


Por lo tanto, puede crear una función que devuelva un objeto de caché y agregar un descriptor que describa este registro a los servicios.


Escribamos una prueba:


 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: el simulacro de la biblioteca Moq es divertido, pero para probar cuántas veces se ha llamado al método GetSomething () del repositorio, también usa decoradores, aunque los genera automáticamente usando Castle.Interceptor. Probamos decoradores usando decoradores.

Así es como puede agregar el almacenamiento en caché de todos los resultados IQuery <TIn, TOut>, es muy inconveniente escribir tanto código para implementar una pequeña funcionalidad.


Otras soluciones


Mediatr


Interfaz de la biblioteca central:


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

La funcionalidad principal de MediatR es agregar envoltorios sobre un IRequestHandler, por ejemplo, implementar una tubería utilizando la interfaz IPipelineBehavior, así es como puede registrar CachePipelineBehaviour, se aplicará a todas las interfaces IRequestHandler registradas:


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

Implementamos el almacenamiento en caché 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()); } 

El Dto de la solicitud, el token de cancelación y RequestHandlerDelegate vienen al método Handle. Este último es solo una envoltura sobre las próximas llamadas de otros decoradores y handler'a. MediatR escanea ensamblajes y registra todas las implementaciones de la interfaz en sí. Para usarlo, debe inyectar IMediator y llamar al método Enviar pasando Dto:


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

MediatR lo encontrará, encontrará la implementación adecuada de IRequestHabdler y aplicará todos los decoradores (además de PipelineBehaviour, también hay IPreRequestHandler e IPostRequestHandler)


Castillo de windsor


La característica del contenedor es la generación de envoltorios dinámicos, esto es AOP dinámico.


Entity Framework lo usa para Lazy Loading, en el captador de propiedades, se llama al método Load de la interfaz ILazyLoader, que se inyecta en las clases de todos los contenedores sobre entidades a través de la implementación del constructor .


Para configurar el contenedor con la generación de envoltorios, debe crear un Interceptor y registrarlo


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

La interfaz IInvocation proporciona información sobre el miembro del objeto decorado al que se accedió; el único miembro público de la interfaz es el método de consulta, por lo que no verificaremos que el acceso esté registrado, no hay otras opciones.


Si hay un objeto con dicha clave en el caché, complete el valor de retorno del método (sin llamarlo), si no, llame al método Proceed, que, a su vez, llamará al método decorado y completará el Valor de retorno.


El registro del interceptor y el código completo se pueden ver en Githab

Source: https://habr.com/ru/post/449744/


All Articles