Using a Source Generator, MediatR and EasyCaching to Cache Query Results
This article will be going over a caching pattern I implement to help decrease the amount of boilerplate I have to write to cache my response to queries. It uses a combination of MediatR, a .NET Source Generator and EasyCaching with a set of rules and code patterns that can be activated with an Attribute and a services.AddScope<...>()
.
Why?
The main reason to for this was learning, plain and simple I wanted a relatively complicated scenario to see how I could use Source Generators in my project. Coupling it with EasyCaching gave me a feature I could use in my projects. And I use MediatR IPipelineBehavior
to capture exceptions as an Error Boundary, and caching query results did not seem like a far off throw to implement.
Details
The pattern is built on the IPipelineBehavior
of MediatR to create an interceptor for our Query, this behavior will be generated by a custom Source Generator during our build. The source generator will be introspect our source code and when it finds our Attribute of [GenerateCache]
will trigger the generation of a IPipelineBehavior
specific to our query/response.
The generated behavior will inject the IEasyCachingProvider
using this abstraction around caching allows us to configure the caching and our generated source does not have to be aware how it setup, just that it has access to a cache using this abstraction. I found EasyCaching very straightforward to setup and use, well after I ran into a bug that seems to only be in the recently released version for the InMemory extension. That aside, we use it here since it provides a simple interface to get/set our cache items, and the caching details are in a separate configuration elsewhere.
Source Code
Below I point out a few areas of interest that make most of this possible. I have include comments that point out different features, like how you can Debug a source generator or what the Abstraction Syntax Tree Linq statements do.
You can view the source code here: canhorn/EventHorizon.Blazor.Query.Caching
The EventHorizon.Blazor.Query.Caching Demo WebSite is deployed as an Azure Static Web App so you can view a working example without having to cloning the source code to your machine.
[GenerateCache]
attribute to this IRequest
to trigger the generation of our IPipelineBehavior
cache.
[GenerateCache]
public struct QueryForForecastData
: IRequest<IEnumerable<WeatherForecast>>,
CacheKey
{
// A prefix that we can use to purge a whole section of the cache.
public string CacheKeyPrefix => "fetchdata";
// A "dynamic" cache key, we can use this to create custom keys if we want.
public string CacheKey => $"{CacheKeyPrefix}:{nameof(QueryForForecastData)}";
}
[GenerateCache]
attribute to this IRequest
to trigger the generation of our IPipelineBehavior
cache.
[Generator]
public class QueryWithEasyCachingResponseGenerator
: ISourceGenerator
{
// We hardcode the full name of our GenerateCache attribute
// This tell the Execute logic that it should create a Cache Mediator Behavior
private const string GenerateCacheAttributeName = "EventHorizon.Cache.GenerateCacheAttribute";
public void Execute(GeneratorExecutionContext context)
{
var attributeSymbol = context.Compilation.GetTypeByMetadataName(
GenerateCacheAttributeName
);
// You can uncomment these lines and run a build to trigger debugging
//#if DEBUG
// if (!Debugger.IsAttached)
// {
// Debugger.Launch();
// }
//#endif
var classWithAttributes = context.Compilation
.SyntaxTrees
.Where(
st => st.GetRoot()
.DescendantNodes()
.OfType<ClassDeclarationSyntax>()
.Any(
p => p.DescendantNodes()
.OfType<AttributeSyntax>()
.Any()
)
);
Generate<ClassDeclarationSyntax>(context, attributeSymbol, classWithAttributes);
var structWithAttributes = context.Compilation
.SyntaxTrees
.Where(
st => st.GetRoot()
.DescendantNodes()
.OfType<StructDeclarationSyntax>()
.Any(
p => p.DescendantNodes()
.OfType<AttributeSyntax>()
.Any()
)
);
Generate<StructDeclarationSyntax>(context, attributeSymbol, structWithAttributes);
}
private static void Generate<T>(
GeneratorExecutionContext context,
INamedTypeSymbol? attributeSymbol,
IEnumerable<SyntaxTree> elementsWithAttribute
) where T : TypeDeclarationSyntax
{
foreach (SyntaxTree tree in elementsWithAttribute)
{
var semanticModel = context.Compilation.GetSemanticModel(tree);
foreach (var declaredClass in tree
.GetRoot()
.DescendantNodes()
.OfType<T>()
.Where(cd => cd.DescendantNodes().OfType<AttributeSyntax>().Any()))
{
// Check for existing GenerateCacheAttributeName Attribute on <T>
var nodes = declaredClass
.DescendantNodes()
.OfType<AttributeSyntax>()
.FirstOrDefault(
a => a.DescendantTokens()
.Any(
dt => dt.IsKind(SyntaxKind.IdentifierToken)
&& dt.Parent != null
&& attributeSymbol != null
&& semanticModel.GetTypeInfo(dt.Parent).Type?.Name == attributeSymbol.Name
)
)?.DescendantTokens()
?.Where(
dt => dt.IsKind(SyntaxKind.IdentifierToken)
)?.ToList();
if (nodes == null)
{
continue;
}
if (declaredClass.Parent is not NamespaceDeclarationSyntax classNamespace)
{
// Ignored, dont care about non-namespaced
continue;
}
var responseNamespace = classNamespace.Name.ToString();
var responseUsingNamespace = classNamespace.Usings
.ToString()
// We dont want these duplicated
// The GenerateCache/MediatR are necessary for these to be even used.
.Replace("using EventHorizon.Cache;", string.Empty)
.Replace("using MediatR;", string.Empty);
var requestType = declaredClass.Identifier.ToString();
var responseType = declaredClass.BaseList?.Types
.Select(
a => a.ToString()
).FirstOrDefault(
// Here we just do a lazy starts with check
// This could be expanded on to check the IRequest identifier
a => a.StartsWith("IRequest<")
);
if (responseType == null || string.IsNullOrEmpty(
responseType
))
{
// Ignored, dont care about non IRequest< typed queries
continue;
}
responseType = responseType.Replace(
"IRequest<", string.Empty
);
responseType = responseType.Substring(
#pragma warning disable IDE0057 // Use range operator
0,
responseType.Length - 1
#pragma warning restore IDE0057 // Use range operator
);
var generatedClass = @$"
// namespace EventHorizon.Cache.Generated
namespace {responseNamespace}
{{
using System;
using System.Threading;
using System.Threading.Tasks;
using EasyCaching.Core;
using MediatR;
using Microsoft.Extensions.Logging;
#region Generated Usings
// using {responseNamespace};
{responseUsingNamespace}
#endregion
public class {requestType}GeneratedCachedBehavior
: IPipelineBehavior<{requestType}, {responseType}>
{{
private readonly ILogger _logger;
private readonly IEasyCachingProvider _provider;
public {requestType}GeneratedCachedBehavior(
ILogger<{requestType}GeneratedCachedBehavior> logger,
IEasyCachingProvider provider
)
{{
_logger = logger;
_provider = provider;
}}
public async Task<{responseType}> Handle(
{requestType} request,
CancellationToken cancellationToken,
RequestHandlerDelegate<{responseType}> next
)
{{
var key = request.CacheKey;
var response = await _provider.GetAsync(
key,
async () =>
{{
return await next();
}},
TimeSpan.FromSeconds(30)
);
// Remove Error Results
if (!response.HasValue)
{{
await _provider.RemoveAsync(
key
);
}}
_logger.LogInformation(
""Key='{{CacheKey}}', Value='{{CacheResponse}}', Time='{{CacheCheckTime}}'"",
key,
response,
DateTime.Now.ToString(""yyyy-MM-dd HH:mm:ss"")
);
return response.Value;
}}
}}
}}";
context.AddSource(
$"{requestType}GeneratedCachedBehavior.cs",
SourceText.From(
generatedClass.ToString(),
Encoding.UTF8
)
);
}
}
}
public void Initialize(GeneratorInitializationContext context)
{
}
}