Open sandboxFocusImprove this doc

Caching example, step 4: cache key for external types

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:

  1. Add a mechanism to generate a cache key for externally-defined types, and
  2. 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:

Source Code



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}
Transformed Code
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}
Source Code


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}
Transformed Code
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}