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
.
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}
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}
1public class Entity
2{
3 [CacheKeyMember]
4 public EntityKey Key { get; }
5
6 public Entity( EntityKey key )
7 {
8 this.Key = key;
9 }
10}
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}
1public class InvoiceVersion : Invoice
2{
3 [CacheKeyMember]
4 public int Version { get; }
5
6 public InvoiceVersion( long id, int version ) : base( id ) { }
7}
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}
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}
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) )]