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] public string Type { get; }
4
5    [CacheKeyMember] public long Id { get; }
6
7    public EntityKey(string type, long id)
8    {
9        this.Type = type;
10        this.Id = id;












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



1public class Entity
2{

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










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


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






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


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








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

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








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





20
21        return "some details";


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

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        // Require the declaring type to have GenerateCacheKeyAspect.
8        builder.Outbound.Select(f => f.DeclaringType).RequireAspect<GenerateCacheKeyAspect>();
9}

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(builder.Target, typeof(ICacheKey),
15            OverrideStrategy.Ignore);
16
17
18    // Implementation of ICacheKey.ToCacheKey.
19    [InterfaceMember]
20    public string ToCacheKey()
21    {
22        var stringBuilder = new StringBuilder();
23        this.BuildCacheKey(stringBuilder);
24
25
26        return stringBuilder.ToString();
27    }
28
29
30    [Introduce(WhenExists = OverrideStrategy.Override)]
31    protected virtual void BuildCacheKey(StringBuilder stringBuilder)
32    {
33        // Call the base method, if any.
34        if (meta.Target.Method.IsOverride)
35        {
36            meta.Proceed();
37            stringBuilder.Append(", ");
38        }
39
40        // Select all cache key members.
41        var members =
42            meta.Target.Type.FieldsAndProperties
43                .Where(f => f.Enhancements().HasAspect<CacheKeyMemberAttribute>())
44                .OrderBy(f => f.Name)
45                .ToList();
46
47
48        // This is how we define a compile-time variable of value 0.
49
50        var i = meta.CompileTime(0);
51        foreach (var member in members)
52        {
53            if (i > 0)
54            {
55                stringBuilder.Append(", ");
56            }
57
58            i++;
59
60            // Check if the parameter type implements ICacheKey or has an aspect of type GenerateCacheKeyAspect.
61            if (member.Type.Is(typeof(ICacheKey)) ||
62                (member.Type is INamedType { BelongsToCurrentProject: true } namedType &&
63                 namedType.Enhancements().HasAspect<GenerateCacheKeyAspect>()))
64            {
65                // If the parameter is ICacheKey, use it.
66                if (member.Type.IsNullable == false)
67                {
68                    stringBuilder.Append(member.Value!.ToCacheKey());
69                }
70                else
71                {
72                    stringBuilder.Append(member.Value?.ToCacheKey() ?? "null");
73                }
74            }
75            else
76            {
77                if (member.Type.IsNullable == false)
78                {
79                    stringBuilder.Append(member.Value);
80                }
81                else
82                {
83                    stringBuilder.Append(member.Value?.ToString() ?? "null");
84                }
85            }
86        }
87    }
88}

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.
    Warning CS0649: Field 'CacheAttribute._cache' is never assigned to, and will always have its default value null

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

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(AspectOrderDirection.RunTime, typeof(CacheAttribute),
10        typeof(GenerateCacheKeyAspect),
11        typeof(CacheKeyMemberAttribute))]