diff --git a/README.md b/README.md index 72d054d..227d51b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # MitMediator.InMemoryCache ## An attribute-driven in-memory caching extension for the [MitMediator](https://github.com/dzmprt/MitMediator) + [![Build and Test](https://github.com/dzmprt/MitMediator.InMemoryCache/actions/workflows/dotnet.yml/badge.svg)](https://github.com/dzmprt/MitMediator.InMemoryCache/actions/workflows/dotnet.yml) ![NuGet](https://img.shields.io/nuget/v/MitMediator.InMemoryCache) ![.NET 9.0](https://img.shields.io/badge/Version-.NET%209.0-informational?style=flat&logo=dotnet) @@ -9,23 +10,24 @@ ## Installation -### 1. Install the package +### 1. Add package ```sh - dotnet add package MitMediator.InMemoryCache -v 9.0.0 + dotnet add package MitMediator.InMemoryCache -v 9.0.0-alfa-2 ``` -### 2. Use extension for `IServiceCollection` +### 2. Register services ```csharp // Register handlers and IMediator builder.Services.AddMitMediator(); -// Register MemoryCache and InMemoryCacheBehavior -// Read information about all IRequest<> +// Register MemoryCache, InMemoryCacheBehavior, +// scan information about all IRequest<> builder.Services.AddRequestsInMemoryCache() ``` -⚠️⚠️⚠️ **Make sure to register `.AddRequestsInMemoryCache()` as the last `IPipelineBehavior`. Cached responses will prevent further pipeline execution** + +⚠️ **Important: Make sure `.AddRequestsInMemoryCache()` is registered as the last `IPipelineBehavior`. Cached responses will short-circuit the pipeline and prevent further execution** To customize `MemoryCache` options and specify assemblies to scan: @@ -35,40 +37,104 @@ builder.Services.AddRequestsInMemoryCache( new []{typeof(GetQuery).Assembly});` ``` -> For `ICollection` types, the cache entry size is `response.Count`; for all other types, it is 1 - ## Usage -Decorate your request classes with caching attributes: +Decorate your request classes with attribute `[CacheResponse]` + +Requests decorated with the `[CacheResponse]` attribute will have their responses cached in memory. You can control expiration, entry size, and define which requests should invalidate the cache + +### CacheResponseAttribute params +| Name | Description | +|------------------------|-------------------------------------------------------------------------------------------------------| +| `expirationSeconds` | Absolute expiration time in seconds, relative to the current moment. Set null for indefinitely | +| `entrySize` | The size of the cache entry item. For collections, size is calculated per element. Default value is 1 | +| `requestsToClearCache` | Types of requests that will trigger cache invalidation | + +Use `IMediator` extension methods to clear cached responses: + +Clear cache for specific request data: ```csharp -using MitMediator.InMemoryCache; +mediator.ClearResponseCacheAsync(request, ct); +``` -[CacheForever] +Clear all cached responses for a request type +```csharp +mediator.ClearAllResponseCacheAsync(ct); +``` + +## Example usage + +Cache indefinitely: +```csharp +[CacheResponse] public struct GetGenresQuery : IRequest; +``` + +> Default `entrySize` is 1 -[CacheForSeconds(10)] +Cache for 10 seconds: +```csharp +[CacheResponse(10)] public struct GetBookQuery : IRequest { public int BookId { get; set; } } +``` -[CacheUntilSent(typeof(DeleteAuthorCommand), typeof(UpdateAuthorCommand))] -public struct GetAuthorQuery : IRequest +Invalidate cache on specific requests: +```csharp +[CacheResponse(typeof(DeleteAuthorCommand), typeof(UpdateAuthorCommand))] +public struct GetAuthorsByFilterQuery : IRequest +{ + public int? Limit { get; init; } + + public int? Offset { get; init; } + + public string? FreeText { get; init; } +} +``` + +Set custom entry size and time (30s) +```csharp +[CacheResponse(30, 4)] +public struct GetBooksByFilterQuery : IRequest { - public int AuthorId { get; init; } + public int? Limit { get; init; } + + public int? Offset { get; init; } + + public string? FreeText { get; init; } } ``` -## Available Attributes -- `[CacheForever]` - caches the response indefinitely -- `[CacheForSeconds(int seconds)]` - caches the response for the specified seconds -- `[CacheUntilSent(params Type[] triggers)]` - caches the response until one of the specified request types is sent +> For `ICollection` types, the cache entry size is `response.Count * entrySize` + +Clear cache after updating data: +```csharp +public async ValueTask HandleAsync(UpdateBookTitleCommand command, CancellationToken cancellationToken) +{ + var book = await _booksRepository.FirstOrDefaultAsync(b => b.BookId == command.BookId, cancellationToken); + + book.SetTitle(command.Title); + + await _booksRepository.UpdateAsync(book, cancellationToken); + + // Clear cached response for the updated book + await _mediator.ClearResponseCacheAsync(new GetBookQuery() { BookId = command.BookId }, cancellationToken); + return book; +} +``` -> Responses are cached per unique request data. -For example, GetAuthorQuery will cache a separate response for each AuthorId +> Responses are cached per unique request data. +> For example, `GetBookQuery` caches a separate response for each `BookId` ## See [samples](./samples) ## License + MIT + + + + diff --git a/samples/BooksWebApi/Books.Application/UseCase/Authors/Commands/DeleteAuthor/DeleteAuthorCommandHandler.cs b/samples/BooksWebApi/Books.Application/UseCase/Authors/Commands/DeleteAuthor/DeleteAuthorCommandHandler.cs index 71b0134..2876ff3 100644 --- a/samples/BooksWebApi/Books.Application/UseCase/Authors/Commands/DeleteAuthor/DeleteAuthorCommandHandler.cs +++ b/samples/BooksWebApi/Books.Application/UseCase/Authors/Commands/DeleteAuthor/DeleteAuthorCommandHandler.cs @@ -1,7 +1,9 @@ using Books.Application.Abstractions.Infrastructure; using Books.Application.Exceptions; +using Books.Application.UseCase.Authors.Queries.GetAuthor; using Books.Domain; using MitMediator; +using MitMediator.InMemoryCache; namespace Books.Application.UseCase.Authors.Commands.DeleteAuthor; @@ -11,16 +13,18 @@ namespace Books.Application.UseCase.Authors.Commands.DeleteAuthor; internal sealed class DeleteAuthorCommandHandler : IRequestHandler { private readonly IBaseRepository _authorRepository; - + private readonly IMediator _mediator; + /// /// Initializes a new instance of the . /// /// Author repository. - public DeleteAuthorCommandHandler(IBaseRepository authorRepository) + public DeleteAuthorCommandHandler(IBaseRepository authorRepository, IMediator mediator) { _authorRepository = authorRepository; + _mediator = mediator; } - + /// public async ValueTask HandleAsync(DeleteAuthorCommand command, CancellationToken cancellationToken) { @@ -29,7 +33,9 @@ public async ValueTask HandleAsync(DeleteAuthorCommand command, Cancellati { throw new NotFoundException(); } + await _authorRepository.RemoveAsync(author, cancellationToken); + await _mediator.ClearResponseCacheAsync(new GetAuthorQuery { AuthorId = command.AuthorId }, cancellationToken); return Unit.Value; } } \ No newline at end of file diff --git a/samples/BooksWebApi/Books.Application/UseCase/Authors/Commands/UpdateAuthor/UpdateAuthorCommandHandler.cs b/samples/BooksWebApi/Books.Application/UseCase/Authors/Commands/UpdateAuthor/UpdateAuthorCommandHandler.cs index 98f6e56..71fd36d 100644 --- a/samples/BooksWebApi/Books.Application/UseCase/Authors/Commands/UpdateAuthor/UpdateAuthorCommandHandler.cs +++ b/samples/BooksWebApi/Books.Application/UseCase/Authors/Commands/UpdateAuthor/UpdateAuthorCommandHandler.cs @@ -1,7 +1,9 @@ using Books.Application.Abstractions.Infrastructure; using Books.Application.Exceptions; +using Books.Application.UseCase.Authors.Queries.GetAuthor; using Books.Domain; using MitMediator; +using MitMediator.InMemoryCache; namespace Books.Application.UseCase.Authors.Commands.UpdateAuthor; @@ -11,28 +13,33 @@ namespace Books.Application.UseCase.Authors.Commands.UpdateAuthor; internal sealed class UpdateAuthorCommandHandler : IRequestHandler { private readonly IBaseRepository _authorRepository; + private readonly IMediator _mediator; /// /// Initializes a new instance of the . /// /// Author repository. - public UpdateAuthorCommandHandler(IBaseRepository authorRepository) + public UpdateAuthorCommandHandler(IBaseRepository authorRepository, IMediator mediator) { _authorRepository = authorRepository; + _mediator = mediator; } - + /// /// The updated author. public async ValueTask HandleAsync(UpdateAuthorCommand command, CancellationToken cancellationToken) { - var author = await _authorRepository.FirstOrDefaultAsync(q => q.AuthorId == command.AuthorId, cancellationToken); + var author = + await _authorRepository.FirstOrDefaultAsync(q => q.AuthorId == command.AuthorId, cancellationToken); if (author is null) { throw new NotFoundException(); } + author.UpdateFirstName(command.FirstName); author.UpdateLastName(command.LastName); await _authorRepository.UpdateAsync(author, cancellationToken); + await _mediator.ClearResponseCacheAsync(new GetAuthorQuery { AuthorId = command.AuthorId }, cancellationToken); return author; } } \ No newline at end of file diff --git a/samples/BooksWebApi/Books.Application/UseCase/Authors/Queries/GetAuthor/GetAuthorQuery.cs b/samples/BooksWebApi/Books.Application/UseCase/Authors/Queries/GetAuthor/GetAuthorQuery.cs index f236884..bbab2ea 100644 --- a/samples/BooksWebApi/Books.Application/UseCase/Authors/Queries/GetAuthor/GetAuthorQuery.cs +++ b/samples/BooksWebApi/Books.Application/UseCase/Authors/Queries/GetAuthor/GetAuthorQuery.cs @@ -1,5 +1,3 @@ -using Books.Application.UseCase.Authors.Commands.DeleteAuthor; -using Books.Application.UseCase.Authors.Commands.UpdateAuthor; using Books.Domain; using MitMediator; using MitMediator.InMemoryCache; @@ -9,7 +7,7 @@ namespace Books.Application.UseCase.Authors.Queries.GetAuthor; /// /// Get author query. /// -[CacheUntilSent(typeof(DeleteAuthorCommand), typeof(UpdateAuthorCommand))] +[CacheResponse] public struct GetAuthorQuery : IRequest { /// diff --git a/samples/BooksWebApi/Books.Application/UseCase/Authors/Queries/GetAuthorsByFilter/GetAuthorsByFilterQuery.cs b/samples/BooksWebApi/Books.Application/UseCase/Authors/Queries/GetAuthorsByFilter/GetAuthorsByFilterQuery.cs index 348ad83..336c4be 100644 --- a/samples/BooksWebApi/Books.Application/UseCase/Authors/Queries/GetAuthorsByFilter/GetAuthorsByFilterQuery.cs +++ b/samples/BooksWebApi/Books.Application/UseCase/Authors/Queries/GetAuthorsByFilter/GetAuthorsByFilterQuery.cs @@ -1,4 +1,3 @@ -using Books.Application.UseCase.Authors.Commands.CreateAuthor; using Books.Application.UseCase.Authors.Commands.DeleteAuthor; using Books.Application.UseCase.Authors.Commands.UpdateAuthor; using Books.Domain; @@ -10,7 +9,7 @@ namespace Books.Application.UseCase.Authors.Queries.GetAuthorsByFilter; /// /// Get authors query. /// -[CacheUntilSent(typeof(CreateAuthorCommand), typeof(DeleteAuthorCommand), typeof(UpdateAuthorCommand))] +[CacheResponse(typeof(DeleteAuthorCommand), typeof(UpdateAuthorCommand))] public struct GetAuthorsByFilterQuery : IRequest { /// diff --git a/samples/BooksWebApi/Books.Application/UseCase/Books/Commands/DeleteBook/DeleteBookCommandHandler.cs b/samples/BooksWebApi/Books.Application/UseCase/Books/Commands/DeleteBook/DeleteBookCommandHandler.cs index 3d4a8c1..c9ca8ae 100644 --- a/samples/BooksWebApi/Books.Application/UseCase/Books/Commands/DeleteBook/DeleteBookCommandHandler.cs +++ b/samples/BooksWebApi/Books.Application/UseCase/Books/Commands/DeleteBook/DeleteBookCommandHandler.cs @@ -1,8 +1,10 @@ using Books.Application.UseCase.Authors.Commands.DeleteAuthor; using Books.Application.Abstractions.Infrastructure; using Books.Application.Exceptions; +using Books.Application.UseCase.Books.Queries.GetBook; using Books.Domain; using MitMediator; +using MitMediator.InMemoryCache; namespace Books.Application.UseCase.Books.Commands.DeleteBook; @@ -12,14 +14,16 @@ namespace Books.Application.UseCase.Books.Commands.DeleteBook; internal sealed class DeleteBookCommandHandler : IRequestHandler { private readonly IBaseRepository _booksRepository; - + private readonly IMediator _mediator; + /// /// Initializes a new instance of the . /// /// Books repository. - public DeleteBookCommandHandler(IBaseRepository booksRepository) + public DeleteBookCommandHandler(IBaseRepository booksRepository, IMediator mediator) { _booksRepository = booksRepository; + _mediator = mediator; } /// @@ -31,6 +35,7 @@ public async ValueTask HandleAsync(DeleteBookCommand command, Cancellation throw new NotFoundException(); } await _booksRepository.RemoveAsync(book, cancellationToken); + await _mediator.ClearResponseCacheAsync(new GetBookQuery() { BookId = command.BookId }, cancellationToken); return Unit.Value; } } \ No newline at end of file diff --git a/samples/BooksWebApi/Books.Application/UseCase/Books/Commands/UpdateBook/UpdateBookCommandHandler.cs b/samples/BooksWebApi/Books.Application/UseCase/Books/Commands/UpdateBook/UpdateBookCommandHandler.cs index d04dec6..b1f6c07 100644 --- a/samples/BooksWebApi/Books.Application/UseCase/Books/Commands/UpdateBook/UpdateBookCommandHandler.cs +++ b/samples/BooksWebApi/Books.Application/UseCase/Books/Commands/UpdateBook/UpdateBookCommandHandler.cs @@ -1,8 +1,10 @@ using Books.Application.Abstractions.Infrastructure; using Books.Application.Exceptions; using Books.Application.UseCase.Books.Commands.CreateBook; +using Books.Application.UseCase.Books.Queries.GetBook; using Books.Domain; using MitMediator; +using MitMediator.InMemoryCache; namespace Books.Application.UseCase.Books.Commands.UpdateBook; @@ -16,6 +18,8 @@ public class UpdateBookCommandHandler : IRequestHandler private readonly IBaseRepository _authorsRepository; private readonly IBaseRepository _genresRepository; + + private readonly IMediator _mediator; /// /// Initializes a new instance of the . @@ -26,38 +30,42 @@ public class UpdateBookCommandHandler : IRequestHandler public UpdateBookCommandHandler( IBaseRepository booksRepository, IBaseRepository authorsRepository, - IBaseRepository genresRepository) + IBaseRepository genresRepository, + IMediator mediator) { _booksRepository = booksRepository; _authorsRepository = authorsRepository; _genresRepository = genresRepository; + _mediator = mediator; } /// - public async ValueTask HandleAsync(UpdateBookCommand request, CancellationToken cancellationToken) + public async ValueTask HandleAsync(UpdateBookCommand command, CancellationToken cancellationToken) { - var book = await _booksRepository.FirstOrDefaultAsync(b => b.BookId == request.BookId, cancellationToken); + var book = await _booksRepository.FirstOrDefaultAsync(b => b.BookId == command.BookId, cancellationToken); if (book is null) { throw new NotFoundException(); } - var author = await _authorsRepository.FirstOrDefaultAsync(a => a.AuthorId == request.AuthorId, cancellationToken); + var author = await _authorsRepository.FirstOrDefaultAsync(a => a.AuthorId == command.AuthorId, cancellationToken); if (author is null) { throw new BadOperationException("Author not found"); } - var genre = await _genresRepository.FirstOrDefaultAsync(g => g.GenreName == request.GenreName.Trim().ToUpperInvariant(), cancellationToken); + var genre = await _genresRepository.FirstOrDefaultAsync(g => g.GenreName == command.GenreName.Trim().ToUpperInvariant(), cancellationToken); if (genre is null) { throw new BadOperationException("Genre not found"); } - book.SetTitle(request.Title); + book.SetTitle(command.Title); book.SetAuthor(author); book.SetGenre(genre); await _booksRepository.UpdateAsync(book, cancellationToken); + await _mediator.ClearResponseCacheAsync(new GetBookQuery() { BookId = command.BookId }, cancellationToken); + await _mediator.ClearAllResponseCacheAsync(cancellationToken); return book; } } \ No newline at end of file diff --git a/samples/BooksWebApi/Books.Application/UseCase/Books/Queries/GetBook/GetBookQuery.cs b/samples/BooksWebApi/Books.Application/UseCase/Books/Queries/GetBook/GetBookQuery.cs index aca1876..0655bd8 100644 --- a/samples/BooksWebApi/Books.Application/UseCase/Books/Queries/GetBook/GetBookQuery.cs +++ b/samples/BooksWebApi/Books.Application/UseCase/Books/Queries/GetBook/GetBookQuery.cs @@ -7,7 +7,7 @@ namespace Books.Application.UseCase.Books.Queries.GetBook; /// /// Get book query. /// -[CacheForSeconds(10)] +[CacheResponse] public struct GetBookQuery : IRequest { /// diff --git a/samples/BooksWebApi/Books.Application/UseCase/Books/Queries/GetBooksByFilter/GetBooksByFilterQuery.cs b/samples/BooksWebApi/Books.Application/UseCase/Books/Queries/GetBooksByFilter/GetBooksByFilterQuery.cs index 7d93b40..e3060f5 100644 --- a/samples/BooksWebApi/Books.Application/UseCase/Books/Queries/GetBooksByFilter/GetBooksByFilterQuery.cs +++ b/samples/BooksWebApi/Books.Application/UseCase/Books/Queries/GetBooksByFilter/GetBooksByFilterQuery.cs @@ -7,7 +7,7 @@ namespace Books.Application.UseCase.Books.Queries.GetBooksByFilter; /// /// Get books query. /// -[CacheForSeconds(10)] +[CacheResponse(30, 2)] public struct GetBooksByFilterQuery : IRequest { /// diff --git a/samples/BooksWebApi/Books.Application/UseCase/Genres/Queries/GetGenres/GetGenresQuery.cs b/samples/BooksWebApi/Books.Application/UseCase/Genres/Queries/GetGenres/GetGenresQuery.cs index 4b6ea09..ededcc3 100644 --- a/samples/BooksWebApi/Books.Application/UseCase/Genres/Queries/GetGenres/GetGenresQuery.cs +++ b/samples/BooksWebApi/Books.Application/UseCase/Genres/Queries/GetGenres/GetGenresQuery.cs @@ -7,5 +7,5 @@ namespace Books.Application.UseCase.Genres.Queries.GetGenres; /// /// Get genres query. /// -[CacheForever] +[CacheResponse] public struct GetGenresQuery : IRequest; \ No newline at end of file diff --git a/samples/SimpleConsoleApp/Program.cs b/samples/SimpleConsoleApp/Program.cs index c534888..0edd12b 100644 --- a/samples/SimpleConsoleApp/Program.cs +++ b/samples/SimpleConsoleApp/Program.cs @@ -19,7 +19,7 @@ result = await mediator.SendAsync(new PingRequest(), CancellationToken.None); Console.WriteLine(result); //Pong result -[CacheForever] +[CacheResponse] public class PingRequest : IRequest; public class PingRequestHandler : IRequestHandler diff --git a/src/MitMediator.InMemoryCache/CacheEntryKeyUtill.cs b/src/MitMediator.InMemoryCache/CacheEntryKeyUtill.cs new file mode 100644 index 0000000..9befb56 --- /dev/null +++ b/src/MitMediator.InMemoryCache/CacheEntryKeyUtill.cs @@ -0,0 +1,12 @@ +using System.Text.Json; + +namespace MitMediator.InMemoryCache; + +internal static class ObjectGetCacheEntryKeyExtensions +{ + public static string GetCacheEntryKey(this object obj) + { + // DON'T USE GetHashCode() + return $"{obj.GetType().Name}_{JsonSerializer.Serialize(obj)}"; + } +} \ No newline at end of file diff --git a/src/MitMediator.InMemoryCache/CacheForeverAttribute.cs b/src/MitMediator.InMemoryCache/CacheForeverAttribute.cs deleted file mode 100644 index 25491f2..0000000 --- a/src/MitMediator.InMemoryCache/CacheForeverAttribute.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace MitMediator.InMemoryCache; - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] -public sealed class CacheForeverAttribute : Attribute, ICacheAttribute; \ No newline at end of file diff --git a/src/MitMediator.InMemoryCache/CacheResponseAttribute.cs b/src/MitMediator.InMemoryCache/CacheResponseAttribute.cs new file mode 100644 index 0000000..c19ff36 --- /dev/null +++ b/src/MitMediator.InMemoryCache/CacheResponseAttribute.cs @@ -0,0 +1,45 @@ +namespace MitMediator.InMemoryCache; + +/// +/// Specifies caching behavior for request handlers, allowing responses to be cached +/// indefinitely, for a fixed duration, or until triggered by other request types. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public class CacheResponseAttribute : Attribute +{ + public int? AbsoluteExpirationRelativeToNowSeconds { get; private set; } + + public int EntrySize { get; private set; } = 1; + + public Type[]? RequestsToClearCache { get; private set; } + + /// + /// Caches the response until one of the specified request types is received. + /// + /// Types of requests that will trigger cache invalidation. + public CacheResponseAttribute(params Type[]? requestsToClearCache) + { + if (requestsToClearCache is not null && requestsToClearCache.Length > 0) + { + RequestsToClearCache = requestsToClearCache; + } + } + + /// + /// Caches the response for a specified duration or indefinitely. + /// + /// Absolute expiration time in seconds, relative to the current moment. Set null for indefinitely + /// The size of the cache entry item. For collections, size is calculated per element. + /// Types of requests that will trigger cache invalidation + public CacheResponseAttribute( + int expirationSeconds = -1, + int entrySize = 1, + params Type[] requestsToClearCache) + { + EntrySize = entrySize; + AbsoluteExpirationRelativeToNowSeconds = expirationSeconds == -1 + ? null + : expirationSeconds; + RequestsToClearCache = requestsToClearCache; + } +} \ No newline at end of file diff --git a/src/MitMediator.InMemoryCache/CacheTimeAttribute.cs b/src/MitMediator.InMemoryCache/CacheTimeAttribute.cs deleted file mode 100644 index 42980f5..0000000 --- a/src/MitMediator.InMemoryCache/CacheTimeAttribute.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MitMediator.InMemoryCache; - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] -public sealed class CacheForSecondsAttribute(int seconds) : Attribute, ICacheAttribute -{ - public TimeSpan CacheTime { get; } = new(0,0, seconds); -} \ No newline at end of file diff --git a/src/MitMediator.InMemoryCache/CacheUntilSentAttribute.cs b/src/MitMediator.InMemoryCache/CacheUntilSentAttribute.cs deleted file mode 100644 index 69ff0b8..0000000 --- a/src/MitMediator.InMemoryCache/CacheUntilSentAttribute.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MitMediator.InMemoryCache; - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] -public sealed class CacheUntilSentAttribute(params Type[] triggersToClearRequests) : Attribute, ICacheAttribute -{ - public Type[] TriggersToClearRequests { get; } = triggersToClearRequests; -} \ No newline at end of file diff --git a/src/MitMediator.InMemoryCache/ClearCacheMatrix.cs b/src/MitMediator.InMemoryCache/ClearCacheMatrix.cs index 38ddd40..2de2cba 100644 --- a/src/MitMediator.InMemoryCache/ClearCacheMatrix.cs +++ b/src/MitMediator.InMemoryCache/ClearCacheMatrix.cs @@ -4,5 +4,6 @@ namespace MitMediator.InMemoryCache; internal static class ClearCacheMatrix { - public static IReadOnlyDictionary? ClearCacheMatrixDictionary { get; set; } + public static IReadOnlyDictionary ClearCacheMatrixDictionary { get; set; } = + new Dictionary(); } \ No newline at end of file diff --git a/src/MitMediator.InMemoryCache/DependencyInjection.cs b/src/MitMediator.InMemoryCache/DependencyInjection.cs index 9fbae2b..0f7731a 100644 --- a/src/MitMediator.InMemoryCache/DependencyInjection.cs +++ b/src/MitMediator.InMemoryCache/DependencyInjection.cs @@ -1,6 +1,7 @@ using System.Reflection; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; +using MitMediator.InMemoryCache.Notifications; namespace MitMediator.InMemoryCache; @@ -24,12 +25,12 @@ public static IServiceCollection AddRequestsInMemoryCache(this IServiceCollectio var types = assemblies.SelectMany(a => a.GetTypes().Where(t => !t.IsAbstract)).ToArray(); foreach (var type in types) { - var cacheUntilSentAttribute = (CacheUntilSentAttribute?)type.GetCustomAttribute(typeof(CacheUntilSentAttribute), inherit: true); - if (cacheUntilSentAttribute == null) + var cacheAttribute = type.GetCustomAttribute(); + if (cacheAttribute == null || cacheAttribute.RequestsToClearCache is null) { continue; } - foreach (var clearCacheTriggerRequest in cacheUntilSentAttribute.TriggersToClearRequests) + foreach (var clearCacheTriggerRequest in cacheAttribute.RequestsToClearCache) { if (!clearCacheMatrix.TryGetValue(clearCacheTriggerRequest, out var toClearList)) { @@ -66,7 +67,9 @@ public static IServiceCollection AddRequestsInMemoryCache(this IServiceCollectio services.AddMemoryCache(); } services.AddSingleton(); - + services + .AddScoped, ClearAllResponsesCacheNotificationHandler>() + .AddScoped, ClearResponseCacheForRequestHandler>(); return services; } } \ No newline at end of file diff --git a/src/MitMediator.InMemoryCache/ICacheAttribute.cs b/src/MitMediator.InMemoryCache/ICacheAttribute.cs deleted file mode 100644 index 0df58f4..0000000 --- a/src/MitMediator.InMemoryCache/ICacheAttribute.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace MitMediator.InMemoryCache; - -internal interface ICacheAttribute; \ No newline at end of file diff --git a/src/MitMediator.InMemoryCache/InMemoryCacheBehavior.cs b/src/MitMediator.InMemoryCache/InMemoryCacheBehavior.cs index 15d2013..394662a 100644 --- a/src/MitMediator.InMemoryCache/InMemoryCacheBehavior.cs +++ b/src/MitMediator.InMemoryCache/InMemoryCacheBehavior.cs @@ -1,5 +1,5 @@ using System.Collections; -using System.Text.Json; +using System.Reflection; using Microsoft.Extensions.Caching.Memory; namespace MitMediator.InMemoryCache; @@ -14,10 +14,7 @@ public async ValueTask HandleAsync(TRequest request, IRequestHandlerN TResponse result; var requestType = typeof(TRequest); - var cacheAttribute = requestType - .GetCustomAttributes(inherit: true) - .OfType() - .SingleOrDefault(); + var cacheAttribute = requestType.GetCustomAttribute(); if (cacheAttribute is null) { @@ -25,10 +22,9 @@ public async ValueTask HandleAsync(TRequest request, IRequestHandlerN ClearCacheByMatrix(requestType); return result; } - - // DON'T USE GetHashCode() - var key = $"{requestType.Name}_{JsonSerializer.Serialize(request)}"; + var key = request.GetCacheEntryKey(); + if (memoryCache.TryGetValue(key, out var value)) { ClearCacheByMatrix(requestType); @@ -38,50 +34,53 @@ public async ValueTask HandleAsync(TRequest request, IRequestHandlerN result = await nextPipe.InvokeAsync(request, cancellationToken); ClearCacheByMatrix(requestType); - if(result is null) + if (result is null) { return result; } - var entrySize = 1; + var entrySize = cacheAttribute.EntrySize; if (result is ICollection collection) { - entrySize = collection.Count; + entrySize = entrySize * collection.Count; } - switch (cacheAttribute) + + var memoryCacheEntryOptions = new MemoryCacheEntryOptions + { + Size = entrySize + }; + + // TODO: check by benchmarks + // if (!cacheAttribute.AbsoluteExpirationRelativeToNowSeconds.HasValue && + // cacheAttribute.RequestsToClearCache is null) + // { + // memoryCacheEntryOptions.Priority = CacheItemPriority.NeverRemove; + // } + + if (cacheAttribute.AbsoluteExpirationRelativeToNowSeconds.HasValue) { - case CacheForeverAttribute: - memoryCache.Set(key, result, new MemoryCacheEntryOptions { Priority = CacheItemPriority.NeverRemove, Size = entrySize }); - break; - case CacheUntilSentAttribute: - memoryCache.Set(key, result, new MemoryCacheEntryOptions { Size = entrySize }); - break; - case CacheForSecondsAttribute cacheForSecondsAttribute: - memoryCache.Set(key, result, new MemoryCacheEntryOptions { Size = entrySize, AbsoluteExpirationRelativeToNow = cacheForSecondsAttribute.CacheTime}); - break; + memoryCacheEntryOptions.AbsoluteExpirationRelativeToNow = + new TimeSpan(0, 0, cacheAttribute.AbsoluteExpirationRelativeToNowSeconds.Value); } + memoryCache.Set(key, result, memoryCacheEntryOptions); + return result; } // TODO: background task? private void ClearCacheByMatrix(Type request) { - if (ClearCacheMatrix.ClearCacheMatrixDictionary is null) - { - return; - } - if (ClearCacheMatrix.ClearCacheMatrixDictionary.TryGetValue(request, out var typesToClearCache)) { - var requestsToClearCacheNames = typesToClearCache.Select(c => c.Name).ToArray(); - foreach (var typesToClearCacheName in requestsToClearCacheNames) + var requestsTypeName = typesToClearCache.Select(c => c.Name).ToArray(); + foreach (var requestTypeName in requestsTypeName) { var keys = memoryCache .Keys - .Where(k => k.ToString()!.StartsWith(typesToClearCacheName)) + .Where(k => k is string && k.ToString()?.StartsWith(requestTypeName) == true) .Select(k => k.ToString()).ToArray(); - + foreach (var key in keys) { memoryCache.Remove(key!); diff --git a/src/MitMediator.InMemoryCache/MediatorExtensions.cs b/src/MitMediator.InMemoryCache/MediatorExtensions.cs new file mode 100644 index 0000000..e498586 --- /dev/null +++ b/src/MitMediator.InMemoryCache/MediatorExtensions.cs @@ -0,0 +1,33 @@ +using MitMediator.InMemoryCache.Notifications; + +namespace MitMediator.InMemoryCache; + +public static class MediatorExtensions +{ + /// + /// Clear response cache for request data. + /// + /// . + /// Request. + /// . + /// Type of request. + /// + public static ValueTask ClearResponseCacheAsync(this IMediator mediator, TRequest request, CancellationToken cancellationToken) + { + var notification = new ClearResponseCacheForRequestNotification(request); + return mediator.PublishAsync(notification, cancellationToken); + } + + /// + /// Clear all response cache for request. + /// + /// . + /// . + /// Type of request. + /// + public static ValueTask ClearAllResponseCacheAsync(this IMediator mediator, CancellationToken cancellationToken) + { + var notification = new ClearAllResponsesCacheNotification(typeof(TRequest)); + return mediator.PublishAsync(notification, cancellationToken); + } +} \ No newline at end of file diff --git a/src/MitMediator.InMemoryCache/MitMediator.InMemoryCache.csproj b/src/MitMediator.InMemoryCache/MitMediator.InMemoryCache.csproj index f2fa1fd..6f3ad6e 100644 --- a/src/MitMediator.InMemoryCache/MitMediator.InMemoryCache.csproj +++ b/src/MitMediator.InMemoryCache/MitMediator.InMemoryCache.csproj @@ -5,13 +5,13 @@ MitMediator.InMemoryCache enable enable - 9.0.0-alfa + 9.0.0-alfa-2 MitMediator.InMemoryCache An attribute-driven in-memory caching extension for the MitMediator https://github.com/dzmprt/MitMediator.InMemoryCache mediator;request;response;queries;commands;mitmediator;cqrs;memorycache;inmemorycache - v9.0.0-alfa -Init + v9.0.0-alfa-2 +Added extensions methods for IMediator. Unified all cache-related attributes into a single [CacheResponse] attribute LICENSE README.md logo.png diff --git a/src/MitMediator.InMemoryCache/Notifications/ClearAllResponsesCacheNotification.cs b/src/MitMediator.InMemoryCache/Notifications/ClearAllResponsesCacheNotification.cs new file mode 100644 index 0000000..b568138 --- /dev/null +++ b/src/MitMediator.InMemoryCache/Notifications/ClearAllResponsesCacheNotification.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace MitMediator.InMemoryCache.Notifications; + +internal class ClearAllResponsesCacheNotification(Type requestType) : INotification +{ + public Type RequestType { get; private set; } = requestType; +} + +internal class ClearAllResponsesCacheNotificationHandler(MemoryCache memoryCache) + : INotificationHandler +{ + public ValueTask HandleAsync(ClearAllResponsesCacheNotification cacheNotification, CancellationToken cancellationToken) + { + var keyStart = cacheNotification.RequestType.Name; + var entriesKeys = memoryCache.Keys + .Where(k => k is string && k.ToString()?.StartsWith(keyStart) == true); + foreach (var entriesKey in entriesKeys) + { + memoryCache.Remove(entriesKey); + } + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/MitMediator.InMemoryCache/Notifications/ClearResponseCacheForRequestNotification.cs b/src/MitMediator.InMemoryCache/Notifications/ClearResponseCacheForRequestNotification.cs new file mode 100644 index 0000000..0ae8d5a --- /dev/null +++ b/src/MitMediator.InMemoryCache/Notifications/ClearResponseCacheForRequestNotification.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace MitMediator.InMemoryCache.Notifications; + +internal class ClearResponseCacheForRequestNotification : INotification +{ + public object Request { get; private set; } + + // public Type RequestType { get; private set; } = requestType; + + public ClearResponseCacheForRequestNotification(object request) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + Request = request; + } +} + +internal class ClearResponseCacheForRequestHandler(MemoryCache memoryCache) : INotificationHandler +{ + public ValueTask HandleAsync(ClearResponseCacheForRequestNotification notification, CancellationToken cancellationToken) + { + var key = notification.Request!.GetCacheEntryKey(); + memoryCache.Remove(key); + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/tests/MitMediator.InMemoryCache.Tests/AttributesAndDI_Tests.cs b/tests/MitMediator.InMemoryCache.Tests/AttributesAndDI_Tests.cs index c4000b5..c7e55c0 100644 --- a/tests/MitMediator.InMemoryCache.Tests/AttributesAndDI_Tests.cs +++ b/tests/MitMediator.InMemoryCache.Tests/AttributesAndDI_Tests.cs @@ -6,24 +6,29 @@ namespace MitMediator.InMemoryCache.Tests; public class AttributeTests { [Fact] - public void CacheForeverAttribute_ShouldImplementICacheAttribute() + public void CacheResponseAttribute_CacheForever_ShouldHaveEntrySize1ByDefaultAndOtherDataNull() { - var attr = new CacheForeverAttribute(); - Assert.IsAssignableFrom(attr); + var attr = new CacheResponseAttribute(); + Assert.Equal(1, attr.EntrySize); + Assert.Null(attr.AbsoluteExpirationRelativeToNowSeconds); + Assert.Null(attr.RequestsToClearCache); + } [Fact] - public void CacheForSecondsAttribute_ShouldSetCacheTime() + public void CacheResponseAttribute_CacheForTime_ShouldSetCacheTimeAndSize() { - var attr = new CacheForSecondsAttribute(42); - Assert.Equal(TimeSpan.FromSeconds(42), attr.CacheTime); + var attr = new CacheResponseAttribute(42, 2); + Assert.Equal(42, attr.AbsoluteExpirationRelativeToNowSeconds); + Assert.Equal(2, attr.EntrySize); + } [Fact] - public void CacheUntilSentAttribute_ShouldSetTriggers() + public void CacheResponseAttribute_CacheUntilTrigger_ShouldSetTriggers() { var types = new[] { typeof(string), typeof(int) }; - var attr = new CacheUntilSentAttribute(types); - Assert.Equal(types, attr.TriggersToClearRequests); + var attr = new CacheResponseAttribute(types); + Assert.Equal(types, attr.RequestsToClearCache); } } \ No newline at end of file diff --git a/tests/MitMediator.InMemoryCache.Tests/ClearCacheMatrixTests.cs b/tests/MitMediator.InMemoryCache.Tests/ClearCacheMatrixTests.cs new file mode 100644 index 0000000..a0a009a --- /dev/null +++ b/tests/MitMediator.InMemoryCache.Tests/ClearCacheMatrixTests.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using MitMediator.InMemoryCache; +using Xunit; + +namespace MitMediator.InMemoryCache.Tests; + +public class ClearCacheMatrixTests +{ + [Fact] + public void ClearCacheMatrixDictionary_SetAndGet_WorksCorrectly() + { + var dict = new Dictionary { { typeof(string), new[] { typeof(int), typeof(double) } } }; + ClearCacheMatrix.ClearCacheMatrixDictionary = dict; + Assert.NotNull(ClearCacheMatrix.ClearCacheMatrixDictionary); + Assert.True(ClearCacheMatrix.ClearCacheMatrixDictionary.ContainsKey(typeof(string))); + Assert.Equal(new[] { typeof(int), typeof(double) }, ClearCacheMatrix.ClearCacheMatrixDictionary[typeof(string)]); + } +} diff --git a/tests/MitMediator.InMemoryCache.Tests/InMemoryCacheBehaviorSizeTests.cs b/tests/MitMediator.InMemoryCache.Tests/InMemoryCacheBehaviorSizeTests.cs index 901c21f..cb0b6c1 100644 --- a/tests/MitMediator.InMemoryCache.Tests/InMemoryCacheBehaviorSizeTests.cs +++ b/tests/MitMediator.InMemoryCache.Tests/InMemoryCacheBehaviorSizeTests.cs @@ -36,10 +36,10 @@ public async Task CacheEntry_Size_Equals_Collection_Count() } - [CacheForever] + [CacheResponse] private class SingleRequest : IRequest { } - [CacheForever] + [CacheResponse] private class CollectionRequest : IRequest> { } private class SingleHandlerNext : IRequestHandlerNext { diff --git a/tests/MitMediator.InMemoryCache.Tests/InMemoryCacheBehaviorTests.cs b/tests/MitMediator.InMemoryCache.Tests/InMemoryCacheBehaviorTests.cs index f16a25a..a0d6ca5 100644 --- a/tests/MitMediator.InMemoryCache.Tests/InMemoryCacheBehaviorTests.cs +++ b/tests/MitMediator.InMemoryCache.Tests/InMemoryCacheBehaviorTests.cs @@ -93,7 +93,7 @@ public async Task HandleAsync_ShouldCacheAndClearCacheByMatrix() Assert.False(_memoryCache.TryGetValue(key, out _)); } - [CacheUntilSent(typeof(ClearCacheRequest))] + [CacheResponse(typeof(ClearCacheRequest))] internal class CachedRequest : IRequest { public string TestData { get; set; } @@ -101,7 +101,7 @@ internal class CachedRequest : IRequest internal class ClearCacheRequest : IRequest; - [CacheForever] + [CacheResponse] internal class TestRequest : IRequest { public string Value { get; set; } = string.Empty; @@ -112,7 +112,7 @@ internal class NoCacheRequest : IRequest public string Value { get; set; } = string.Empty; } - [CacheForSeconds(10)] + [CacheResponse(10)] internal class CacheForSecondsRequest : IRequest { public string Value { get; set; } = string.Empty; diff --git a/tests/MitMediator.InMemoryCache.Tests/MediatorExtensionsTests.cs b/tests/MitMediator.InMemoryCache.Tests/MediatorExtensionsTests.cs new file mode 100644 index 0000000..2185346 --- /dev/null +++ b/tests/MitMediator.InMemoryCache.Tests/MediatorExtensionsTests.cs @@ -0,0 +1,80 @@ +using System.Diagnostics.CodeAnalysis; +using MitMediator.InMemoryCache.Notifications; + +namespace MitMediator.InMemoryCache.Tests; + +public class MediatorExtensionsTests +{ + [ExcludeFromCodeCoverage] + private class DummyMediator : IMediator + { + public object? PublishedNotification { get; private set; } + public CancellationToken? PublishedToken { get; private set; } + + public Task Send(IRequest request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task Send(TRequest request, CancellationToken cancellationToken) where TRequest : IRequest + { + throw new NotImplementedException(); + } + + public Task Send(TRequest request, CancellationToken cancellationToken) where TRequest : IRequest + { + throw new NotImplementedException(); + } + + public ValueTask SendAsync(TRequest request, CancellationToken cancellationToken) where TRequest : IRequest + { + throw new NotImplementedException(); + } + + public ValueTask SendAsync(TRequest request, CancellationToken cancellationToken) where TRequest : IRequest + { + throw new NotImplementedException(); + } + + public ValueTask PublishAsync(TNotification notification, CancellationToken cancellationToken) where TNotification : INotification + { + PublishedNotification = notification; + PublishedToken = cancellationToken; + return ValueTask.CompletedTask; + } + + public Task PublishParallelAsync(TNotification notification, + CancellationToken cancellationToken = new CancellationToken()) where TNotification : INotification + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable CreateStream(TRequest request, CancellationToken cancellationToken) where TRequest : IStreamRequest + { + throw new NotImplementedException(); + } + } + + [Fact] + public async Task ClearResponseCacheAsync_PublishesNotification() + { + var mediator = new DummyMediator(); + var request = "test-request"; + var token = new CancellationTokenSource().Token; + await mediator.ClearResponseCacheAsync(request, token); + Assert.NotNull(mediator.PublishedNotification); + Assert.IsType(mediator.PublishedNotification); + Assert.Equal(token, mediator.PublishedToken); + } + + [Fact] + public async Task ClearAllResponseCacheAsync_PublishesNotification() + { + var mediator = new DummyMediator(); + var token = new CancellationTokenSource().Token; + await mediator.ClearAllResponseCacheAsync(token); + Assert.NotNull(mediator.PublishedNotification); + Assert.IsType(mediator.PublishedNotification); + Assert.Equal(token, mediator.PublishedToken); + } +} diff --git a/tests/MitMediator.InMemoryCache.Tests/NotificationsTests.cs b/tests/MitMediator.InMemoryCache.Tests/NotificationsTests.cs new file mode 100644 index 0000000..3b70097 --- /dev/null +++ b/tests/MitMediator.InMemoryCache.Tests/NotificationsTests.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Caching.Memory; +using MitMediator.InMemoryCache.Notifications; + +namespace MitMediator.InMemoryCache.Tests; + +public class NotificationsTests +{ + [Fact] + public void ClearAllResponsesCacheNotification_SetsRequestType() + { + var type = typeof(string); + var notification = new ClearAllResponsesCacheNotification(type); + Assert.Equal(type, notification.RequestType); + } + + [Fact] + public async Task ClearAllResponsesCacheNotificationHandler_RemovesAllMatchingKeys() + { + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + memoryCache.Set("String_key1", "value1"); + memoryCache.Set("String_key2", "value2"); + memoryCache.Set("Int32_key3", "value3"); + var handler = new ClearAllResponsesCacheNotificationHandler(memoryCache); + var notification = new ClearAllResponsesCacheNotification(typeof(string)); + await handler.HandleAsync(notification, CancellationToken.None); + Assert.False(memoryCache.TryGetValue("String_key1", out _)); + Assert.False(memoryCache.TryGetValue("String_key2", out _)); + Assert.True(memoryCache.TryGetValue("Int32_key3", out _)); + } + + [Fact] + public void ClearResponseCacheForRequestNotification_ThrowsOnNull() + { + Assert.Throws(() => new ClearResponseCacheForRequestNotification(null)); + } + + [Fact] + public async Task ClearResponseCacheForRequestHandler_RemovesKey() + { + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var request = "test-request"; + var key = request.GetCacheEntryKey(); + memoryCache.Set(key, "value"); + var handler = new ClearResponseCacheForRequestHandler(memoryCache); + var notification = new ClearResponseCacheForRequestNotification(request); + await handler.HandleAsync(notification, CancellationToken.None); + Assert.False(memoryCache.TryGetValue(key, out _)); + } +} diff --git a/tests/MitMediator.InMemoryCache.Tests/ObjectGetCacheEntryKeyExtensionsTests.cs b/tests/MitMediator.InMemoryCache.Tests/ObjectGetCacheEntryKeyExtensionsTests.cs new file mode 100644 index 0000000..6879984 --- /dev/null +++ b/tests/MitMediator.InMemoryCache.Tests/ObjectGetCacheEntryKeyExtensionsTests.cs @@ -0,0 +1,29 @@ +using System; +using Xunit; +using MitMediator.InMemoryCache; + +namespace MitMediator.InMemoryCache.Tests; + +public class CacheEntryKeyUtilTests +{ + [Fact] + public void GetCacheEntryKey_ReturnsExpectedFormat() + { + var obj = new TestObj { Id = 1, Name = "Test", InnerObject = new InnerObject() { BoolValue = true } }; + var key = obj.GetCacheEntryKey(); + Assert.Equal("TestObj_{\"Id\":1,\"Name\":\"Test\",\"InnerObject\":{\"BoolValue\":true}}", key); + } + + private class TestObj + { + public int Id { get; set; } + public string Name { get; set; } + + public InnerObject InnerObject { get; set; } + } + + private class InnerObject + { + public bool BoolValue { get; set; } + } +} \ No newline at end of file