• .NET
  • MediatR
  • EasyCaching
  • C#
  • CQRS

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.

QueryForForecastData is our Query request that returns a list of Weather Forecasts. We attach the [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)}";
}

QueryWithEasyCachingResponseGenerator is our Source Generator that will introspect our code and generate us the IPipelineBehavior that will do the heavy lifting . We attach the [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)
    {
    }
}

Cody's logo image, it is an abstract of a black hole with a white Event Horizon.

Cody Merritt Anhorn

A Engineer with a passion for Platform Architecture and Tool Development.