Open sandboxFocusImprove this doc

Caching example, step 3: building the cache key

In the previous implementation of the aspect, the cache key came from an interpolated string that implicitly called the ToString method for all parameters of the cached method. This approach is simplistic because it assumes that all parameters have a suitable implementation of the ToString method: one that returns a distinct string for each unique instance.

To alleviate this limitation, our objective is to make it sufficient for users of our framework to mark with a [CacheKeyMember] custom attribute all fields or properties that should be part of the cache key. This is not a trivial goal so let's first think about the design.

Pattern design

First, we define an interface ICacheKey. When a type or struct implements this interface, we will call ICacheKey.ToCacheKey instead of the ToString method:

1public interface ICacheKey
2{
3    string ToCacheKey();
4}

We now need to think about an implementation pattern for this interface, i.e., something that we can repeat for all classes. The pattern needs to be inheritable, i.e., it should support the case when a class derives from a base class that already implements ICacheKey, but the derived class adds a member to the cache key. The simplest pattern is to always implement the following method:

protected virtual void BuildCacheKey( StringBuilder stringBuilder )

Each implementation of BuildCacheKey would first call the base implementation if any and then contribute its members to the StringBuilder.

Example code

To see the pattern in action, let's consider four classes EntityKey, Entity, Invoice, and InvoiceVersion that can be part of a cache key, and a cacheable API DatabaseFrontend.

Source Code



1public class EntityKey
2{

3    [CacheKeyMember]
4    public string Type { get; }
5
6    [CacheKeyMember]
7    public long Id { get; }
8
9    public EntityKey( string type, long id )
10    {
11        this.Type = type;
12        this.Id = id;












13    }
14}
Transformed Code
1using System;
2using System.Text;
3
4public class EntityKey
5: ICacheKey
6{
7    [CacheKeyMember]
8    public string Type { get; }
9
10    [CacheKeyMember]
11    public long Id { get; }
12
13    public EntityKey( string type, long id )
14    {
15        this.Type = type;
16        this.Id = id;
17    }
18protected virtual void BuildCacheKey(StringBuilder stringBuilder)
19    {
20        stringBuilder.Append(this.Id);
21        stringBuilder.Append(", ");
22        stringBuilder.Append(this.Type);
23    }
24    public string ToCacheKey()
25    {
26        var stringBuilder = new StringBuilder();
27        BuildCacheKey(stringBuilder);
28        return stringBuilder.ToString();
29    }
30}
Source Code



1public class Entity
2{

3    [CacheKeyMember]
4    public EntityKey Key { get; }
5
6    public Entity( EntityKey key )
7    {
8        this.Key = key;










9    }
10}
Transformed Code
1using System;
2using System.Text;
3
4public class Entity
5: ICacheKey
6{
7    [CacheKeyMember]
8    public EntityKey Key { get; }
9
10    public Entity( EntityKey key )
11    {
12        this.Key = key;
13    }
14protected virtual void BuildCacheKey(StringBuilder stringBuilder)
15    {
16        stringBuilder.Append(this.Key.ToCacheKey());
17    }
18    public string ToCacheKey()
19    {
20        var stringBuilder = new StringBuilder();
21        BuildCacheKey(stringBuilder);
22        return stringBuilder.ToString();
23    }
24}
1public class Invoice : Entity
2{
3    public Invoice( long id ) : base( new EntityKey( "Invoice", id ) ) { }
4}
Source Code


1public class InvoiceVersion : Invoice
2{
3    [CacheKeyMember]
4    public int Version { get; }
5
6    public InvoiceVersion( long id, int version ) : base( id ) { }






7}
Transformed Code
1using System.Text;
2
3public class InvoiceVersion : Invoice
4{
5    [CacheKeyMember]
6    public int Version { get; }
7
8    public InvoiceVersion( long id, int version ) : base( id ) { }
9protected override void BuildCacheKey(StringBuilder stringBuilder)
10    {
11        base.BuildCacheKey(stringBuilder);
12        stringBuilder.Append(", ");
13        stringBuilder.Append(this.Version);
14    }
15}
Source Code


1public class DatabaseFrontend
2{
3    public int DatabaseCalls { get; private set; }
4
5    [Cache]
6    public Entity GetEntity( EntityKey entityKey )
7    {








8        Console.WriteLine( "Executing GetEntity..." );
9        this.DatabaseCalls++;
10
11        return new Entity( entityKey );

12    }
13
14    [Cache]
15    public string GetInvoiceVersionDetails( InvoiceVersion invoiceVersion )
16    {








17        Console.WriteLine( "Executing GetInvoiceVersionDetails..." );
18        this.DatabaseCalls++;





19
20        return "some details";


21    }
22}
Transformed Code
1using System;
2
3public class DatabaseFrontend
4{
5    public int DatabaseCalls { get; private set; }
6
7    [Cache]
8    public Entity GetEntity( EntityKey entityKey )
9    {
10var cacheKey = $"DatabaseFrontend.GetEntity((EntityKey) {{{entityKey.ToCacheKey()}}})";
11        if (_cache.TryGetValue(cacheKey, out var value))
12        {
13            return (Entity)value;
14        }
15        else
16        {
17            Entity result;
18            Console.WriteLine( "Executing GetEntity..." );
19        this.DatabaseCalls++;
20result = new Entity(entityKey); _cache.TryAdd(cacheKey, result);
21            return (Entity)result;
22        }
23    }
24
25    [Cache]
26    public string GetInvoiceVersionDetails( InvoiceVersion invoiceVersion )
27    {
28var cacheKey = $"DatabaseFrontend.GetInvoiceVersionDetails((InvoiceVersion) {{{invoiceVersion.ToCacheKey()}}})";
29        if (_cache.TryGetValue(cacheKey, out var value))
30        {
31            return (string)value;
32        }
33        else
34        {
35            string result;
36            Console.WriteLine( "Executing GetInvoiceVersionDetails..." );
37        this.DatabaseCalls++;
38result = "some details"; _cache.TryAdd(cacheKey, result);
39            return (string)result;
40        }
41    }
42private ICache _cache;
43
44    public DatabaseFrontend(ICache? cache = null)
45    {
46        this._cache = cache ?? throw new System.ArgumentNullException(nameof(cache));
47    }
48}

Pattern implementation

As we decided during the design phase, the public API of our cache key feature is the [CacheKeyMember] custom attribute, which can be applied to fields or properties. The effect of this attribute needs to be the implementation of the ICacheKey interface and the BuildCacheKey method. Because CacheKeyMemberAttribute is a field-or-property-level attribute, and we want to perform a type-level transformation, we will use an internal helper aspect called GenerateCacheKeyAspect.

The only action of the CacheKeyMemberAttribute aspect is then to provide the GenerateCacheKeyAspect aspect:

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3
4public class CacheKeyMemberAttribute : FieldOrPropertyAspect
5{
6    public override void BuildAspect( IAspectBuilder<IFieldOrProperty> builder )
7        =>
8
9            // Require the declaring type to have GenerateCacheKeyAspect.
10            builder.Outbound.Select( f => f.DeclaringType ).RequireAspect<GenerateCacheKeyAspect>();
11}

The BuildAspect method of CacheKeyMemberAttribute calls the RequireAspect method for the declaring type. This method adds an instance of the GenerateCacheKeyAspect if none has been added yet, so that if a class has several properties marked with [CacheKeyMember], a single instance of the GenerateCacheKeyAspect aspect will be added.

Let's now look at the implementation of GenerateCacheKeyAspect:

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using System.Text;
4
5/// <summary>
6/// Implements the <see cref="ICacheKey"/> interface based on <see cref="CacheKeyMemberAttribute"/> 
7/// aspects on fields and properties. This aspect is implicitly added by <see cref="CacheKeyMemberAttribute"/> aspects.
8/// It should never be added explicitly.
9/// </summary>
10[EditorExperience( SuggestAsAddAttribute = false )]
11internal class GenerateCacheKeyAspect : TypeAspect
12{
13    public override void BuildAspect( IAspectBuilder<INamedType> builder )
14        => builder.Advice.ImplementInterface(
15            builder.Target,
16            typeof(ICacheKey),
17            OverrideStrategy.Ignore );
18
19    // Implementation of ICacheKey.ToCacheKey.
20    [InterfaceMember]
21    public string ToCacheKey()
22    {
23        var stringBuilder = new StringBuilder();
24        this.BuildCacheKey( stringBuilder );
25
26        return stringBuilder.ToString();
27    }
28
29    [Introduce( WhenExists = OverrideStrategy.Override )]
30    protected virtual void BuildCacheKey( StringBuilder stringBuilder )
31    {
32        // Call the base method, if any.
33        if ( meta.Target.Method.IsOverride )
34        {
35            meta.Proceed();
36            stringBuilder.Append( ", " );
37        }
38
39        // Select all cache key members.
40        var members =
41            meta.Target.Type.FieldsAndProperties
42                .Where( f => f.Enhancements().HasAspect<CacheKeyMemberAttribute>() )
43                .OrderBy( f => f.Name )
44                .ToList();
45
46        // This is how we define a compile-time variable of value 0.
47
48        var i = meta.CompileTime( 0 );
49
50        foreach ( var member in members )
51        {
52            if ( i > 0 )
53            {
54                stringBuilder.Append( ", " );
55            }
56
57            i++;
58
59            // Check if the parameter type implements ICacheKey or has an aspect of type GenerateCacheKeyAspect.
60            if ( member.Type.IsConvertibleTo( typeof(ICacheKey) ) ||
61                 (member.Type is INamedType { BelongsToCurrentProject: true } namedType &&
62                  namedType.Enhancements().HasAspect<GenerateCacheKeyAspect>()) )
63            {
64                // If the parameter is ICacheKey, use it.
65                if ( member.Type.IsNullable == false )
66                {
67                    stringBuilder.Append( member.Value!.ToCacheKey() );
68                }
69                else
70                {
71                    stringBuilder.Append( member.Value?.ToCacheKey() ?? "null" );
72                }
73            }
74            else
75            {
76                if ( member.Type.IsNullable == false )
77                {
78                    stringBuilder.Append( member.Value );
79                }
80                else
81                {
82                    stringBuilder.Append( member.Value?.ToString() ?? "null" );
83                }
84            }
85        }
86    }
87}

The BuildAspect method of GenerateCacheKeyAspect calls ImplementInterface to add the ICacheKey interface to the target type. The whenExists parameter is set to Ignore, which means that this call will just be ignored if the target type or a base type already implements the interface. The ImplementInterface method requires the interface members to be implemented by the aspect class and to be annotated with the [InterfaceMember] custom attribute. Here, our only member is ToCacheKey, which instantiates a StringBuilder and calls the BuildCacheKey method.

The BuildCacheKey aspect method is marked with the [Introduce] custom attribute, which means that Metalama will add the method to the target type. The WhenExists property specifies what should happen when the type or a base type already defines the member: we choose to override the existing implementation.

The first thing BuildCacheKey does is to execute the existing implementation if any, thanks to a call to meta.Proceed().

Secondly, the method finds all members that have the CacheKeyMemberAttribute aspect. Note that we are using property.Enhancements().HasAspect<CacheKeyMemberAttribute>() and not f.Attributes.OfAttributeType(typeof(CacheKeyMemberAttribute)).Any(). The first expression looks for aspects, while the second one looks for custom attributes. What is the difference, if CacheKeyMemberAttribute is an aspect, anyway? If the CacheKeyMemberAttribute aspect is programmatically added, using fabrics, for instance, then Enhancements().HasAspect will see these new instances, while the Attributes collections will not.

Then, BuildCacheKey iterates through the members and emits a call to stringBuilder.Append for each member. When the type of the member already implements ICacheKey or has an aspect of type GenerateCacheKeyAspect (i.e., will implement ICacheKey after code transformation), we call ICacheKey.ToCacheKey. Otherwise, we call ToString. If the member is null, we append just the "null" string.

Finally, the CacheAttribute aspect needs to be updated to take the ICacheKey interface into account. We must consider the same four cases.

1using Metalama.Extensions.DependencyInjection;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Code.SyntaxBuilders;
5using Metalama.Framework.Eligibility;
6
7#pragma warning disable CS8618
8
9public class CacheAttribute : OverrideMethodAspect
10{
11    // The ICache service is pulled from the dependency injection container. 
12    // If needed, the aspect will add the field to the target class and pull it from
13    // the constructor.
14    [IntroduceDependency]
    Warning CS0649: Field 'CacheAttribute._cache' is never assigned to, and will always have its default value null

15    private readonly ICache _cache;
16
17    public override dynamic? OverrideMethod()
18    {
19        #region Build the caching key
20
21        var stringBuilder = new InterpolatedStringBuilder();
22        stringBuilder.AddText( meta.Target.Type.ToString() );
23        stringBuilder.AddText( "." );
24        stringBuilder.AddText( meta.Target.Method.Name );
25        stringBuilder.AddText( "(" );
26
27        foreach ( var p in meta.Target.Parameters )
28        {
29            if ( p.Index > 0 )
30            {
31                stringBuilder.AddText( ", " );
32            }
33
34            // We have to add the parameter type to avoid ambiguities
35            // between different overloads of the same method.
36            stringBuilder.AddText( "(" );
37            stringBuilder.AddText( p.Type.ToString() );
38            stringBuilder.AddText( ") " );
39
40            stringBuilder.AddText( "{" );
41
42            // Check if the parameter type implements ICacheKey or has an aspect of type GenerateCacheKeyAspect.
43            if ( p.Type.IsConvertibleTo( typeof(ICacheKey) ) || (p.Type is INamedType
44                                                                 {
45                                                                     BelongsToCurrentProject: true
46                                                                 } namedType &&
47                                                                 namedType.Enhancements()
48                                                                     .HasAspect<GenerateCacheKeyAspect>()) )
49            {
50                // If the parameter is ICacheKey, use it.
51                if ( p.Type.IsNullable == false )
52                {
53                    stringBuilder.AddExpression( p.Value!.ToCacheKey() );
54                }
55                else
56                {
57                    stringBuilder.AddExpression( p.Value?.ToCacheKey() ?? "null" );
58                }
59            }
60            else
61            {
62                // Otherwise, fallback to ToString.
63                if ( p.Type.IsNullable == false )
64                {
65                    stringBuilder.AddExpression( p.Value );
66                }
67                else
68                {
69                    stringBuilder.AddExpression( p.Value?.ToString() ?? "null" );
70                }
71            }
72
73            stringBuilder.AddText( "}" );
74        }
75
76        stringBuilder.AddText( ")" );
77
78        var cacheKey = (string) stringBuilder.ToValue();
79
80        #endregion
81
82        // Cache lookup.
83        if ( this._cache.TryGetValue( cacheKey, out var value ) )
84        {
85            // Cache hit.
86            return value;
87        }
88        else
89        {
90            // Cache miss. Go and invoke the method.
91            var result = meta.Proceed();
92
93            // Add to cache.
94            this._cache.TryAdd( cacheKey, result );
95
96            return result;
97        }
98    }
99
100    public override void BuildEligibility( IEligibilityBuilder<IMethod> builder )
101    {
102        // Do not allow or offer the aspect to be used on void methods or methods with out/ref parameters.
103
104        builder.MustSatisfy(
105            m => !m.ReturnType.Equals( SpecialType.Void ),
106            m => $"{m} cannot be void" );
107
108        builder.MustSatisfy(
109            m => !m.Parameters.Any( p => p.RefKind is RefKind.Out or RefKind.Ref ),
110            m => $"{m} cannot have out or ref parameter" );
111    }
112}

Ordering of Aspects

We now have three aspects in our solution. Because they are interdependent, their execution needs to be properly ordered using a global AspectOrderAttribute:

1using Metalama.Framework.Aspects;
2
3// Aspects are executed at compile time in the inverse order than the one given here.
4// It is important that aspects are executed in the given order because they rely on each other:
5//  - CacheKeyMemberAttribute provides GenerateCacheKeyAspect, so CacheKeyMemberAttribute should run before GenerateCacheKeyAspect.
6//  - CacheAttribute relies on CacheKeyMemberAttribute, so CacheAttribute should run after.
7
8[assembly:
9    AspectOrder(
10        AspectOrderDirection.RunTime,
11        typeof(CacheAttribute),
12        typeof(GenerateCacheKeyAspect),
13        typeof(CacheKeyMemberAttribute) )]