Caching with Attributes in .Net Core 5

You had a performance problem, and you solved it by implementing caching:

1
var people = PersonRepository.GetByLastName(personId);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class PersonRepository 
{
private const secondsToCacheResult = 300;

public async Task<IEnumerable<Person>> GetByLastName(string lastName)
{
var cacheKey = buildCacheKey(lastName);
if (_cache.ContainsKey(cacheKey))
{
return _cache[cacheKey] as Person;
}

// This part takes a long time, that's why we
// want to cache the results
var people = SomeLongRunningProcess(lastName);

var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = new System.TimeSpan(
hours: 0, minutes: 0, seconds: secondsToCacheResult)
};
_cache.Add(cacheKey, people, options)

return people;
}
}

And it worked! The performance problem is solved. But then you start caching things in several places in your repositories, and you end up with this exact same caching code everywhere. Not only are you violating the DRY principle, but it makes it much harder to understand what’s really going on in the repository method.

This new problem can be solved by using Aspect Oriented Programming in a Dependency Injection framework by using interception with attributes to make your code look like this instead:

1
2
3
4
5
[Cache(Seconds = 30)]
public async Task<IEnumerable<Person>> GetPeopleByLastName(string lastName)
{
return SomeLongRunningProcess(lastName);
}

We could use DI to solve this with Microsoft’s Unity DI, or with AutoFac. Or we could use PostSharp, which ‘weaves’ the IL of your code with the code for caching at compile time. But I wanted to get this working with Microsoft’s DI framework that comes with .Net Core 5.

But there’s a problem - Microsoft’s DI doesn’t natively support interceptors, so we have to bring in some extra help. In this case, we’re going to use the Castle Project’s DynamicProxy library.

In order to be able to add the attribute as seen above and have the caching magically happen, we need to setup 4 things:

  1. CacheAttribute - This defines which method should have its results cached and how the results should be cached.
  2. CacheInterceptor - This is like pipeline middleware, but for calling a method. This gets injected between the method and the code calling the method, and it decides whether to return a value found in the cache, or whether to let the method run and generate the return value (which is then added to the cache).
  3. ServiceExtensions - An extension method to help us register the class in our DI container so that the CacheInterceptor is used.
  4. Startup.cs - We have to add a few lines to wire everything up

CacheAttribute

First, add an attribute class. This attribute is used to identify methods that we want to cache. Customize this to add whatever cache settings might be useful in your scenario, such as if you want to use a sliding window, etc. I’m keeping this simple, so all we have is a timeout with a default value of 30 seconds.

1
2
3
4
5
6
7
8
9
using System;
namespace DotNetCore5.Common.Interception
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class CacheAttribute : Attribute
{
public int Seconds { get; set; } = 30;
}
}

CacheInterceptor

Second, we add the interceptor. This is similar to middleware, because it lets us add logic between a method and a line of code that calls that method. I’m using MemoryCache, which is automatically set up and injected by Microsoft’s DI, but you can use whatever other mechanism you need for caching the results.

This requires the Castle.Core Nuget package.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
using Castle.DynamicProxy;
using Microsoft.Extensions.Caching.Memory;
using System.Linq;
namespace DotNetCore5.Common.Interception
{
public class CacheInterceptor : IInterceptor
{
private IMemoryCache _memoryCache;
public CacheInterceptor(IMemoryCache memoryCache)
{
_memoryCache = memoryCache;
}

// Create a cache key using the name of the method and the values
// of its arguments so that if the same method is called with the
// same arguments in the future, we can find out if the results
// are cached or not
private static string GenerateCacheKey(string name,
object[] arguments)
{
if (arguments == null || arguments.Length == 0)
return name;
return name + "--" +
string.Join("--", arguments.Select(a =>
a == null ? "**NULL**" : a.ToString()).ToArray());
}

public void Intercept(IInvocation invocation)
{
var cacheAttribute = invocation.MethodInvocationTarget
.GetCustomAttributes(typeof(CacheAttribute), false)
.FirstOrDefault() as CacheAttribute;

// If the cache attribute is added ot this method, we
// need to intercept this call
if (cacheAttribute != null)
{
var cacheKey = GenerateCacheKey(invocation.Method.Name,
invocation.Arguments);
if (_memoryCache.TryGetValue(cacheKey, out object value))
{
// The results were already in the cache so return
// them from the cache instead of calling the
// underlying method
invocation.ReturnValue = value;
}
else
{
// Get the result the hard way by calling
// the underlying method
invocation.Proceed();
// Save the result in the cache
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow =
new System.TimeSpan(hours: 0, minutes: 0,
seconds: cacheAttribute.Seconds)
};
_memoryCache.Set(cacheKey, invocation.ReturnValue,
options);
}
}
else
{
// We don't need to cache the results,
// nothing to see here
invocation.Proceed();
}
}
}
}

Service Extensions

Third, we’ll add an extension method to make it easier to use the CacheInterceptor when registering services. You may need to add other similar extension methods depending on how you like to use DI.

Please note that in order for this entire idea to work, the class that has methods to cache must implement an interface, that that interface is what gets injected into the other class that uses it. That is, if you want to cache the results of a method on MyRepository, then MyRepository must implement IMyRepository and IMyRepository must be injected into MyController.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static class ServicesExtensions
{
public static void AddProxiedScoped<TInterface, TImplementation>
(this IServiceCollection services)
where TInterface : class
where TImplementation : class, TInterface
{
// This registers the underlying class
services.AddScoped<TImplementation>();
services.AddScoped(typeof(TInterface), serviceProvider =>
{
// Get an instance of the Castle Proxy Generator
var proxyGenerator = serviceProvider
.GetRequiredService<ProxyGenerator>();
// Have DI build out an instance of the class that has methods
// you want to cache (this is a normal instance of that class
// without caching added)
var actual = serviceProvider
.GetRequiredService<TImplementation>();
// Find all of the interceptors that have been registered,
// including our caching interceptor. (you might later add a
// logging interceptor, etc.)
var interceptors = serviceProvider
.GetServices<IInterceptor>().ToArray();
// Have Castle Proxy build out a proxy object that implements
// your interface, but adds a caching layer on top of the
// actual implementation of the class. This proxy object is
// what will then get injected into the class that has a
// dependency on TInterface
return proxyGenerator.CreateInterfaceProxyWithTarget(
typeof(TInterface), actual, interceptors);
});
}
}

Startup.cs

Fourth, we add a few lines to Startup.cs.

In Startup.cs, ConfigureServices needs to be changed. Add two lines for setting up interception. These two lines register the ProxyGenerator and the CacheInterceptor. For any classes that need caching added, in addition to adding the CacheAttribute on one of its methods, we register it here with services.AddProxiedScoped<TInterface, T>() instead of services.AddScoped<TInterface, T>().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void ConfigureServices(IServiceCollection services)
{
// Setup Interception
services.AddSingleton(new ProxyGenerator());
services.AddScoped<IInterceptor, CacheInterceptor>();

// Register with the AddProxiedScope method. When a class needs an
// IPersonRepository injected, then DynamicProxy will create a proxy
// object by adding a caching layer on top of the real
// PersonRepository. That proxy object will be injected into
// the class that needs an IPersonRepository injected into it.
services.AddProxiedScoped<IPersonRepository, PersonRepository>();

// Other registrations
services.AddScoped<SomeOtherClassThatDoesntNeedCaching>();

// This was already in this method because we're doing MVC
services.AddControllersWithViews();
}

Usage

That’s all of the setup. When you need to add caching to a method, it will require two steps:

  1. Change the DI registration for the class that has a method that needs to be cached. This can be done in Startup.ConfigureServices by changing that class’ registration from services.AddScoped to services.AddProxiedScoped.

  2. Add the Cache attribute to the method, like this:

1
2
3
4
5
[Cache(Seconds = 30)]
public async Task<IEnumerable<Person>> GetPeopleByLastName(string lastName)
{
return SomeLongRunningProcess(...);
}

Thanks to Zanid Haytam for his post on AOP using Proxies. I couldn’t have figured this out this without his blog post.