In the preceding article, we introduced the concept of generating cache keys for custom types by implementing
the ICacheKey
interface. We created an aspect that implements this interface automatically for all the fields or
properties of a custom class annotated with the [CacheKeyMember]
attribute.
However, two issues remain with this approach. Firstly, how do we handle types for which we don't have the source code? Secondly, what if the user of this aspect tries to include an item whose type is not supported? We are now adding two requirements to our aspect:
- Add a mechanism to generate a cache key for externally-defined types, and
- Report an error when the aspect's user attempts to include an unsupported type in the cache key.
ICacheKeyBuilder
To address these challenges, we have introduced the concept of cache key builders — objects capable of building
a
cache key for another object. We define the ICacheKeyBuilder
interface as follows:
1public interface ICacheKeyBuilder<T>
2{
3 public string? GetCacheKey( in T value, ICacheKeyBuilderProvider provider );
4}
The generic type parameter in the interface represents the relevant object type. The benefit of using a generic
parameter is performance: we can generate the cache key without boxing value-typed values into an object
.
For instance, here is an implementation for byte[]
:
1internal class ByteArrayCacheKeyBuilder : ICacheKeyBuilder<byte[]?>
2{
3 public string? GetCacheKey( in byte[]? value, ICacheKeyBuilderProvider provider )
4 {
5 if ( value == null )
6 {
7 return null;
8 }
9
10 return string.Join( ' ', value );
11 }
12}
Compile-time API
To enable compile-time reporting of errors when attempting to include an unsupported type in the cache key, we need a
compile-time configuration API for the caching aspects. We accomplish this via a concept named hierarchical options,
which is explained in more detail in Making aspects configurable. We define a new compile-time class, CachingOptions
,
to map types to their respective builders. It implements the IHierarchicalOptions<T>
for all levels where the options can be defined, i.e. the whole compilation, namespace, or type. The class is designed
as immutable and represents an incremental change in configuration compared to its base level, but without knowing its
base configuration. Because of this unusual requirements, designing aspect options can be complex.
Here is the top-level option class:
1using Metalama.Framework.Code;
2using Metalama.Framework.Options;
3
4public record CachingOptions :
5 IHierarchicalOptions<IMethod>,
6 IHierarchicalOptions<INamedType>,
7 IHierarchicalOptions<INamespace>,
8 IHierarchicalOptions<ICompilation>
9{
10 private readonly IncrementalKeyedCollection<string, CacheBuilderRegistration>
11 _cacheBuilderRegistrations;
12
13 public static CachingOptions Default { get; } = new();
14
15 public CachingOptions() : this(
16 IncrementalKeyedCollection<string, CacheBuilderRegistration>
17 .Empty ) { }
18
19 private CachingOptions( IncrementalKeyedCollection<string, CacheBuilderRegistration> cacheBuilderRegistrations )
20 {
21 this._cacheBuilderRegistrations = cacheBuilderRegistrations;
22 }
23
24 internal IEnumerable<CacheBuilderRegistration> Registrations => this._cacheBuilderRegistrations;
25
26 public CachingOptions UseToString( Type type )
27 => new(
28 this._cacheBuilderRegistrations.AddOrApplyChanges(
29 new CacheBuilderRegistration( TypeFactory.GetType( type ), null ) ) );
30
31 public CachingOptions UseCacheKeyBuilder( Type type, Type builderType )
32 => new(
33 this._cacheBuilderRegistrations.AddOrApplyChanges(
34 new CacheBuilderRegistration(
35 TypeFactory.GetType( type ),
36 TypeFactory.GetType( builderType ) ) ) );
37
38 public object ApplyChanges( object changes, in ApplyChangesContext context )
39 => new CachingOptions(
40 this._cacheBuilderRegistrations.AddOrApplyChanges(
41 ((CachingOptions) changes)._cacheBuilderRegistrations ) );
42}
The class relies on IncrementalKeyedCollection to represent a change in the
collection. Items in these collections are represented by the CacheBuilderRegistration
class.
1using Metalama.Framework.Code;
2using Metalama.Framework.Options;
3
4internal class CacheBuilderRegistration : IIncrementalKeyedCollectionItem<string>
5{
6 public CacheBuilderRegistration( IType keyType, IType? builderType )
7 {
8 // We are using ToDisplayString for the key and not SerializableTypeId because
9 // SerializableTypeId is too strict -- it includes too much nullability information.
10 this.KeyType = keyType.ToDisplayString( CodeDisplayFormat.FullyQualified );
11 this.BuilderType = builderType?.ToSerializableId();
12 }
13
14 public object ApplyChanges( object changes, in ApplyChangesContext context ) => changes;
15
16 public string KeyType { get; }
17
18 public SerializableTypeId? BuilderType { get; }
19
20 public bool UseToString => this.BuilderType == null;
21
22 string IIncrementalKeyedCollectionItem<string>.Key => this.KeyType;
23}
Configuring the caching API using a fabric is straightforward:
1using Metalama.Framework.Fabrics;
2
3public class Fabric : ProjectFabric
4{
5 public override void AmendProject( IProjectAmender amender )
6 {
7 var cachingOptions = CachingOptions.Default
8 .UseToString( typeof(int) )
9 .UseToString( typeof(long) )
10 .UseToString( typeof(string) )
11 .UseCacheKeyBuilder( typeof(byte[]), typeof(ByteArrayCacheKeyBuilder) );
12
13 amender.SetOptions( cachingOptions );
14 }
15}
For those unfamiliar with the term, fabrics are compile-time types whose AmendProject
method executes before any
aspect. The AmendProject
method acts as a compile-time entry point, triggered solely by its existence, much
like Program.Main
, but at compile time. Refer to Fabrics for additional information.
ICacheKeyBuilderProvider
At run time, it is convenient to abstract the process of obtaining ICacheKeyBuilder
instances with a provider pattern.
We can achieve this by defining the ICacheKeyBuilderProvider
interface.
1public interface ICacheKeyBuilderProvider
2{
3 ICacheKeyBuilder<TValue> GetCacheKeyBuilder<TValue, TBuilder>( in TValue value )
4 where TBuilder : ICacheKeyBuilder<TValue>, new();
5}
Note that the new()
constraint on the generic parameter allows for a trivial implementation of the class.
The implementation of ICacheKeyBuilderProvider
should be pulled from the dependency injection container. To allow
cache key objects to be instantiated independently from the dependency container, we update the ICacheKey
interface to
receive the provider from the caller:
1public interface ICacheKey
2{
3 string ToCacheKey( ICacheKeyBuilderProvider provider );
4}
Generating the cache key item expression
The logic to generate the expression that gets the cache key of an object has now grown in complexity. It includes support for three cases plus null-handling.
- Implicit call to
ToString
. - Call to
ICacheKeyBuilderProvider.GetCacheKeyBuilder
. - Call to
ICacheKey.ToCacheKey
.
It is now easier to build the expression with ExpressionBuilder rather
than with a template. We have moved this logic to CachingOptions
.
89internal static bool TryGetCacheKeyExpression(
90 this CachingOptions cachingOptions,
91 IExpression expression,
92 IExpression cacheKeyBuilderProvider,
93 [NotNullWhen( true )] out IExpression? cacheKeyExpression )
94{
95 var expressionBuilder = new ExpressionBuilder();
96
97 var typeId = expression.Type.ToNonNullable()
98 .ToDisplayString( CodeDisplayFormat.FullyQualified );
99
100 if ( GetRegistrations( cachingOptions )
101 .TryGetValue( typeId, out var registration ) )
102 {
103 if ( registration.UseToString )
104 {
105 expressionBuilder.AppendExpression( expression );
106
107 if ( expression.Type.IsNullable == true )
108 {
109 expressionBuilder.AppendVerbatim( "?.ToString() ?? \"null\"" );
110 }
111 }
112 else
113 {
114 expressionBuilder.AppendExpression( cacheKeyBuilderProvider );
115 expressionBuilder.AppendVerbatim( ".GetCacheKeyBuilder<" );
116 expressionBuilder.AppendTypeName( expression.Type );
117 expressionBuilder.AppendVerbatim( ", " );
118
119 expressionBuilder.AppendTypeName(
120 registration.BuilderType!.Value.Resolve( expression.Type.Compilation ) );
121
122 expressionBuilder.AppendVerbatim( ">(" );
123 expressionBuilder.AppendExpression( expression );
124 expressionBuilder.AppendVerbatim( ")" );
125
126 if ( expression.Type.IsNullable == true )
127 {
128 expressionBuilder.AppendVerbatim( "?? \"null\"" );
129 }
130 }
131 }
132 else if ( expression.Type.IsConvertibleTo( typeof(ICacheKey) ) ||
133 (expression.Type is INamedType namedType &&
134 namedType.Enhancements().HasAspect<GenerateCacheKeyAspect>()) )
135 {
136 expressionBuilder.AppendExpression( expression );
137 expressionBuilder.AppendVerbatim( ".ToCacheKey(" );
138 expressionBuilder.AppendExpression( cacheKeyBuilderProvider );
139 expressionBuilder.AppendVerbatim( ")" );
140
141 if ( expression.Type.IsNullable == true )
142 {
143 expressionBuilder.AppendVerbatim( "?? \"null\"" );
144 }
145 }
146 else
147 {
148 cacheKeyExpression = null;
149
150 return false;
151 }
152
153 cacheKeyExpression = expressionBuilder.ToExpression();
154
155 return true;
156}
The ExpressionBuilder class essentially acts as a StringBuilder
wrapper.
We can add any text to an ExpressionBuilder
, as long as it can be parsed back into a valid C# expression.
Reporting errors for unsupported types
We report an error whenever an unsupported type is used as a parameter of a cached method, or when it is used as a type
for a field or property annotated with [CacheKeyMember]
.
To achieve this, we add the following code to CachingOptions
:
40internal static bool VerifyCacheKeyMember<T>(
41 this CachingOptions cachingOptions,
42 T expression,
43 ScopedDiagnosticSink diagnosticSink )
44 where T : IExpression, IDeclaration
45{
46 // Check supported intrinsics.
47 switch ( expression.Type.SpecialType )
48 {
49 case SpecialType.Boolean:
50 case SpecialType.Byte:
51 case SpecialType.Decimal:
52 case SpecialType.Double:
53 case SpecialType.SByte:
54 case SpecialType.Int16:
55 case SpecialType.UInt16:
56 case SpecialType.Int32:
57 case SpecialType.UInt32:
58 case SpecialType.Int64:
59 case SpecialType.UInt64:
60 case SpecialType.String:
61 case SpecialType.Single:
62 return true;
63 }
64
65 // Check registered types.
66 var registrations = GetRegistrations( cachingOptions );
67
68 var typeId = expression.Type.ToNonNullable()
69 .ToDisplayString( CodeDisplayFormat.FullyQualified );
70
71 if ( registrations.ContainsKey( typeId ) )
72 {
73 return true;
74 }
75
76 // Check ICacheKey.
77 if ( expression.Type.IsConvertibleTo( typeof(ICacheKey) ) ||
78 (expression.Type is INamedType { BelongsToCurrentProject: true } namedType &&
79 namedType.Enhancements().HasAspect<GenerateCacheKeyAspect>()) )
80 {
81 return true;
82 }
83
84 diagnosticSink.Report( _error.WithArguments( expression.Type ), expression );
85
86 return false;
87}
88
The first line defines an error kind. Metalama requires the DiagnosticDefinition
to be defined in a static field or property. Then, if the type of the expression
is invalid, this error is reported
for that property or parameter. To learn more about reporting errors, see Reporting and suppressing diagnostics.
This method needs to be reported from the BuildAspect
method of the CacheAttribute
and GenerateCacheKeyAspect
aspect classes. We cannot report errors from template methods because templates are typically not executed at design
time unless we are using the preview feature.
However, a limitation prevents us from detecting unsupported types at design time. When Metalama runs inside the editor, at design time, it doesn't execute all aspects for all files at every keystroke, but only does so for the files that have been edited, plus all files containing the ancestor types. Therefore, at design time, your aspect receives a partial compilation. It can still see all the types in the project, but it doesn't see the aspects that have been applied to these types.
So, when CachingOptions.VerifyCacheKeyMember
evaluates Enhancements().HasAspect<GenerateCacheKeyAspect>()
at design
time, the expression does not yield an accurate result. Therefore, we can only run this method when we have a complete
compilation, i.e., at compile time.
To verify parameters, we need to include this code in the CacheAttribute
aspect class:
20public override void BuildAspect( IAspectBuilder<IMethod> builder )
21{
22 base.BuildAspect( builder );
23
24 if ( !builder.Target.Compilation.IsPartial )
25 {
26 var cachingOptions = builder.Target.Enhancements().GetOptions<CachingOptions>();
27
28 foreach ( var parameter in builder.Target.Parameters )
29 {
30 cachingOptions.VerifyCacheKeyMember( parameter, builder.Diagnostics );
31 }
32 }
33}
34
Aspects in action
The aspects can be applied to some business code as follows:
1public class BlobId
2{
3 public BlobId( string container, byte[] hash )
4 {
5 this.Container = container;
6 this.Hash = hash;
7 }
8
9 [CacheKeyMember]
10 public string Container { get; }
11
12 [CacheKeyMember]
13 public byte[] Hash { get; }
14}
1using System;
2using System.Text;
3
4public class BlobId
5: ICacheKey
6{
7 public BlobId( string container, byte[] hash )
8 {
9 this.Container = container;
10 this.Hash = hash;
11 }
12
13 [CacheKeyMember]
14 public string Container { get; }
15
16 [CacheKeyMember]
17 public byte[] Hash { get; }
18protected virtual void BuildCacheKey(StringBuilder stringBuilder, ICacheKeyBuilderProvider provider)
19 {
20 stringBuilder.Append(Container);
21 stringBuilder.Append(", ");
22 stringBuilder.Append(provider.GetCacheKeyBuilder<byte[], global::ByteArrayCacheKeyBuilder>(this.Hash));
23 }
24 public string ToCacheKey(ICacheKeyBuilderProvider provider)
25 {
26 var stringBuilder = new StringBuilder();
27 BuildCacheKey(stringBuilder, provider);
28 return stringBuilder.ToString();
29 }
30}
1public class DatabaseFrontend
2{
3 public int DatabaseCalls { get; private set; }
4
5 [Cache]
6 public byte[] GetBlob( string container, byte[] hash )
7 {
8 Console.WriteLine( "Executing GetBlob..." );
9 this.DatabaseCalls++;
10
11 return new byte[] { 0, 1, 2 };
12 }
13
14 [Cache]
15 public byte[] GetBlob( BlobId blobId )
16 {
17 Console.WriteLine( "Executing GetBlob..." );
18 this.DatabaseCalls++;
19
20 return new byte[] { 0, 1, 2 };
21 }
22}
1using System;
2
3public class DatabaseFrontend
4{
5 public int DatabaseCalls { get; private set; }
6
7 [Cache]
8 public byte[] GetBlob( string container, byte[] hash )
9 {
10var cacheKey = $"DatabaseFrontend.GetBlob((string) {{{container}}}, (byte[]) {{{(_cacheBuilderProvider.GetCacheKeyBuilder<byte[], global::ByteArrayCacheKeyBuilder>(hash))}}})";
11 if (_cache.TryGetValue(cacheKey, out var value))
12 {
13 return (byte[])value;
14 }
15
16 byte[] result;
17 Console.WriteLine("Executing GetBlob...");
18 this.DatabaseCalls++;
19 result = new byte[] { 0, 1, 2 }; _cache.TryAdd(cacheKey, result);
20 return result;
21 }
22
23 [Cache]
24 public byte[] GetBlob( BlobId blobId )
25 {
26var cacheKey = $"DatabaseFrontend.GetBlob((BlobId) {{{blobId.ToCacheKey(_cacheBuilderProvider)}}})";
27 if (_cache.TryGetValue(cacheKey, out var value))
28 {
29 return (byte[])value;
30 }
31
32 byte[] result;
33 Console.WriteLine("Executing GetBlob...");
34 this.DatabaseCalls++;
35 result = new byte[] { 0, 1, 2 }; _cache.TryAdd(cacheKey, result);
36 return result;
37 }
38private ICache _cache;
39 private ICacheKeyBuilderProvider _cacheBuilderProvider;
40
41 public DatabaseFrontend(ICache? cache = null, ICacheKeyBuilderProvider? cacheBuilderProvider = null)
42 {
43 this._cache = cache ?? throw new System.ArgumentNullException(nameof(cache)); this._cacheBuilderProvider = cacheBuilderProvider ?? throw new System.ArgumentNullException(nameof(cacheBuilderProvider));
44 }
45}