At first glance, caching appears simple. The aspect generates the cache key, performs a cache lookup, and returns the result if it exists. If it doesn't exist, the method runs, and the return value gets stored in the cache. The following example compares the source code, without caching logic, to the transformed code, with caching logic.
1public class Calculator
2{
3 public int InvocationCounts { get; private set; }
4
5 public int Add( int a, int b )
6 {
7 Console.WriteLine( "Thinking..." );
8 this.InvocationCounts++;
9
10 return a + b;
11 }
12}
Infrastructure Code
The aspect relies on the ICache
interface, which has only two methods used by the aspect.
1public interface ICache
2{
3 bool TryGetValue( string key, out object? value );
4
5 void TryAdd( string key, object? value );
6}
A typical implementation of this interface would use MemoryCache.
Aspect Code
The aspect itself is straightforward:
1using Metalama.Extensions.DependencyInjection;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code.SyntaxBuilders;
4
5public class CacheAttribute : OverrideMethodAspect
6{
7 [IntroduceDependency]
Warning CS0649: Field 'CacheAttribute._cache' is never assigned to, and will always have its default value null
8 private readonly ICache _cache;
9
10 public override dynamic? OverrideMethod()
11 {
12 // Builds the caching string.
13 var cacheKey = CacheKeyBuilder.GetCachingKey().ToValue();
14
15 // Cache lookup.
16 if ( this._cache.TryGetValue( cacheKey, out object value ) )
17 {
18 // Cache hit.
19 return value;
20 }
21
22 // Cache miss. Go and invoke the method.
23 var result = meta.Proceed();
24
25 // Add to cache.
26 this._cache.TryAdd( cacheKey, result );
27
28 return result;
29 }
30}
As usual, the aspect class inherits the abstract class OverrideMethodAspect, which, in
turn, derives from the System.Attribute class. This makes CacheAttribute
a custom
attribute. The OverrideMethod method acts like a template.
Most of its code gets injected into the target method, the one to which we add
the [ReportAndSwallowExceptionsAttribute]
custom attribute. Inside
the OverrideMethod implementation, the call to meta.Proceed()
has a unique meaning. When the aspect applies to the target, the call to meta.Proceed()
stands in for the original
implementation, with a few syntactic alterations that capture the return value.
The CacheKeyBuilder
class hides the complexity of creating the cache key. It is a compile-time class.
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code.SyntaxBuilders;
3
4[CompileTime]
5internal static class CacheKeyBuilder
6{
7 public static InterpolatedStringBuilder GetCachingKey()
8 {
9 var stringBuilder = new InterpolatedStringBuilder();
10 stringBuilder.AddText( meta.Target.Type.ToString() );
11 stringBuilder.AddText( "." );
12 stringBuilder.AddText( meta.Target.Method.Name );
13 stringBuilder.AddText( "(" );
14
15 foreach ( var p in meta.Target.Parameters )
16 {
17 if ( p.Index > 0 )
18 {
19 stringBuilder.AddText( ", " );
20 }
21
22 // We have to add the parameter type to avoid ambiguities
23 // between different overloads of the same method.
24 stringBuilder.AddText( "(" );
25 stringBuilder.AddText( p.Type.ToString() );
26 stringBuilder.AddText( ")" );
27
28 stringBuilder.AddText( "{" );
29 stringBuilder.AddExpression( p );
30 stringBuilder.AddText( "}" );
31 }
32
33 stringBuilder.AddText( ")" );
34
35 return stringBuilder;
36 }
37}
As its name suggests, the InterpolatedStringBuilder class helps build
interpolated strings. The meta.Target
property exposes the context into which the template
applies. meta.Target.Type
is the current type, meta.Target.Method
is the current method, and so on.
The GetCachingKey
method creates an interpolated string for the current context. Parameters get represented at compile
time using the IParameter interface.
The parameter.Value expression returns a dynamic
object that
represents a run-time expression. In this case, it's the name of the parameter.
Upon receiving the InterpolatedStringBuilder instance from
the CacheKeyBuilder
class, the aspect converts it to a run-time interpolated string by calling
the ToValue method. This method returns
a dynamic
object representing the interpolated string. It can be cast to a string
, and we use it as the caching key.