MetalamaCommented examplesCachingStep 4.​ Supporting external types
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 a project extension, 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. We will also store a list of types for which we want to use ToString.

1using Metalama.Framework.Code;
2using Metalama.Framework.Project;
3
4public partial class CachingOptions : ProjectExtension
5{
6    private readonly HashSet<IType> _toStringTypes = new();
7    private readonly Dictionary<IType, IType> _externalCacheBuilderTypes = new();
8
9
10    public void UseToString( Type type ) => this._toStringTypes.Add( TypeFactory.GetType( type ) );
11
12    public void UseCacheKeyBuilder( Type type, Type builderType ) =>
13        this._externalCacheBuilderTypes[TypeFactory.GetType( type )] = TypeFactory.GetType( builderType );
14}

We define an extension method that simplifies access to caching options:

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Project;
3
4[CompileTime]
5public static class CachingProjectExtensions
6{
7    public static CachingOptions CachingOptions( this IProject project ) => project.Extension<CachingOptions>();
8}

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        amender.Project.CachingOptions().UseToString( typeof(int) );
8        amender.Project.CachingOptions().UseToString( typeof(long) );
9        amender.Project.CachingOptions().UseToString( typeof(string) );
10        amender.Project.CachingOptions().UseCacheKeyBuilder( typeof(byte[]), typeof(ByteArrayCacheKeyBuilder) );
11    }
12}

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.

36    internal bool TryGetCacheKeyExpression( IExpression expression,
37        IExpression cacheKeyBuilderProvider,
38        [NotNullWhen( true )] out IExpression? cacheKeyExpression )
39    {
40        var expressionBuilder = new ExpressionBuilder();
41
42        if ( this._toStringTypes.Contains( expression.Type ) )
43        {
44            expressionBuilder.AppendExpression( expression );
45
46            if ( expression.Type.IsNullable == true )
47            {
48                expressionBuilder.AppendVerbatim( "?.ToString() ?? \"null\"" );
49            }
50        }
51        else if ( this._externalCacheBuilderTypes.TryGetValue( expression.Type, out var externalCacheBuilderType ) )
52        {
53            expressionBuilder.AppendExpression( cacheKeyBuilderProvider );
54            expressionBuilder.AppendVerbatim( ".GetCacheKeyBuilder<" );
55            expressionBuilder.AppendTypeName( expression.Type );
56            expressionBuilder.AppendVerbatim( ", " );
57            expressionBuilder.AppendTypeName( externalCacheBuilderType );
58            expressionBuilder.AppendVerbatim( ">(" );
59            expressionBuilder.AppendExpression( expression );
60            expressionBuilder.AppendVerbatim( ")" );
61
62            if ( expression.Type.IsNullable == true )
63            {
64                expressionBuilder.AppendVerbatim( "?? \"null\"" );
65            }
66        }
67        else if ( expression.Type.Is( typeof(ICacheKey) ) ||
68                  (expression.Type is INamedType namedType &&
69                   namedType.Enhancements().HasAspect<GenerateCacheKeyAspect>()) )
70        {
71            expressionBuilder.AppendExpression( expression );
72            expressionBuilder.AppendVerbatim( ".ToCacheKey(" );
73            expressionBuilder.AppendExpression( cacheKeyBuilderProvider );
74            expressionBuilder.AppendVerbatim( ")" );
75
76            if ( expression.Type.IsNullable == true )
77            {
78                expressionBuilder.AppendVerbatim( "?? \"null\"" );
79            }
80        }
81        else
82        {
83            cacheKeyExpression = null;
84            return false;
85        }
86
87
88        cacheKeyExpression = expressionBuilder.ToExpression();
89        return true;
90    }

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:

12    internal bool VerifyCacheKeyMember<T>( T expression, ScopedDiagnosticSink diagnosticSink )
13        where T : IExpression, IDeclaration
14    {
15        if ( this._toStringTypes.Contains( expression.Type ) )
16        {
17            return true;
18        }
19        else if ( this._externalCacheBuilderTypes.ContainsKey( expression.Type ) )
20        {
21            return true;
22        }
23        else if ( expression.Type.Is( typeof(ICacheKey) ) ||
24                  (expression.Type is INamedType namedType &&
25                   namedType.Enhancements().HasAspect<GenerateCacheKeyAspect>()) )
26        {
27            return true;
28        }
29        else
30        {
31            diagnosticSink.Report( _error.WithArguments( expression.Type ), expression );
32            return false;
33        }
34    }

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:

20    public override void BuildAspect( IAspectBuilder<IMethod> builder )
21    {
22        base.BuildAspect( builder );
23
24        if ( !builder.Target.Compilation.IsPartial )
25        {
26            var cachingOptions = builder.Project.CachingOptions();
27
28            foreach ( var parameter in builder.Target.Parameters )
29            {
30                cachingOptions.VerifyCacheKeyMember( parameter, builder.Diagnostics );
31            }
32        }
33    }

Aspects in action

The aspects can be applied to some business code as follows:

Source Code



1public class BlobId
2{

3    [CacheKeyMember]
4    public string Container { get; }
5
6    [CacheKeyMember]
7    public byte[] Hash { get; }
8
9    public BlobId( string container, byte[] hash )
10    {
11        this.Container = container;
12        this.Hash = hash;












13    }
14}
Transformed Code
1using System;
2using System.Text;
3
4public class BlobId
5: ICacheKey
6{
7    [CacheKeyMember]
8    public string Container { get; }
9
10    [CacheKeyMember]
11    public byte[] Hash { get; }
12
13    public BlobId( string container, byte[] hash )
14    {
15        this.Container = container;
16        this.Hash = hash;
17    }
18protected virtual void BuildCacheKey(StringBuilder stringBuilder, ICacheKeyBuilderProvider provider)
19    {
20        stringBuilder.Append(this.Container);
21        stringBuilder.Append(", ");
22        stringBuilder.Append(provider.GetCacheKeyBuilder<global::System.Byte[], global::ByteArrayCacheKeyBuilder>(this.Hash));
23    }
24    public string ToCacheKey(ICacheKeyBuilderProvider provider)
25    {
26        var stringBuilder = new StringBuilder();
27        this.BuildCacheKey(stringBuilder, provider);
28        return stringBuilder.ToString();
29    }
30}
Source Code


1public class DatabaseFrontend
2{
3    public int DatabaseCalls { get; private set; }
4
5
6    [Cache]
7    public byte[] GetBlob( string container, byte[] hash )
8    {








9        Console.WriteLine( "Executing GetBlob..." );
10        this.DatabaseCalls++;
11
12        return new byte[] { 0, 1, 2 };


13    }
14
15    [Cache]
16    public byte[] GetBlob( BlobId blobId )
17    {








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







20
21        return new byte[] { 0, 1, 2 };



22    }
23}
Transformed Code
1using System;
2
3public class DatabaseFrontend
4{
5    public int DatabaseCalls { get; private set; }
6
7
8    [Cache]
9    public byte[] GetBlob( string container, byte[] hash )
10    {
11var cacheKey = $"DatabaseFrontend.GetBlob((string) {{{container}}}, (byte[]) {{{(_cacheBuilderProvider.GetCacheKeyBuilder<byte[], global::ByteArrayCacheKeyBuilder>(hash))}}})";
12        if (this._cache.TryGetValue(cacheKey, out var value))
13        {
14            return (byte[])value;
15        }
16        else
17        {
18            byte[] result;
19            Console.WriteLine( "Executing GetBlob..." );
20        this.DatabaseCalls++;
21result = new byte[] { 0, 1, 2 };
22            this._cache.TryAdd(cacheKey, result);
23            return result;
24        }
25    }
26
27    [Cache]
28    public byte[] GetBlob( BlobId blobId )
29    {
30var cacheKey = $"DatabaseFrontend.GetBlob((BlobId) {{{blobId.ToCacheKey(_cacheBuilderProvider)}}})";
31        if (this._cache.TryGetValue(cacheKey, out var value))
32        {
33            return (byte[])value;
34        }
35        else
36        {
37            byte[] result;
38            Console.WriteLine( "Executing GetBlob..." );
39        this.DatabaseCalls++;
40result = new byte[] { 0, 1, 2 };
41            this._cache.TryAdd(cacheKey, result);
42            return result;
43        }
44    }
45private ICache _cache;
46    private ICacheKeyBuilderProvider _cacheBuilderProvider;
47
48    public DatabaseFrontend
49(ICache? cache = default(global::ICache?), ICacheKeyBuilderProvider? cacheBuilderProvider = default(global::ICacheKeyBuilderProvider?))
50    {
51        this._cache = cache ?? throw new System.ArgumentNullException(nameof(cache)); this._cacheBuilderProvider = cacheBuilderProvider ?? throw new System.ArgumentNullException(nameof(cacheBuilderProvider));
52    }
53}