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:
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{
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) {
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(); }
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