MetalamaConceptual documentationUsing Metalama PatternsCachingCustomizing cache keys
Open sandboxFocusImprove this doc

Customizing cache keys

By default, the cache key of a parameter is built using the ToString method. However, the default implementation of the ToString method does not return a unique string for custom classes and structs. The default implementation of ToString for records is more likely to be correct. Therefore, it is essential to provide a cache key implementation for all parameter types of a cached method. This article explains several approaches.

Using the [CacheKey] aspect

The most straightforward approach to customize the cache key for a class or struct is to add the [CacheKey] aspect to the fields or properties that must be a part of the cache key.

This aspect automatically implements the IFormattable<T> interface for the CacheKeyFormatting role.

Example: [CacheKey] aspect

The following example demonstrates a service class EntityService and an entity class Entity. The method EntityService.GetRelatedEntities retrieves all entities related to a given Entity and is cached using the [Cache] aspect. Therefore, the Entity class is a part of the cache key. Any Entity is uniquely distinguished by its Id and Kind properties. We use the [CacheKey] aspect on these properties to add these properties to the cache key. However, the Description property is not a part of the entity identity and does not require the aspect.

You can observe how the [CacheKey] aspect implements the IFormattable<T> interface.

Source Code
1using Metalama.Patterns.Caching.Aspects;
2using System;


3using System.Collections.Generic;


4
5namespace Doc.CacheKeyAspect

6{
7    public abstract class Entity
8    {
9        protected Entity( string kind, int id )
10        {
11            this.Kind = kind;
12            this.Id = id;
13        }
14
15        [CacheKey]
16        public string Kind { get; }
17
18        [CacheKey]
19        public int Id { get; }
20
21        public string? Description { get; set; }
22    }
23






















24    public class EntityService


25    {
26        [Cache]
27        public IEnumerable<Entity> GetRelatedEntities( Entity entity ) => throw new NotImplementedException();
28    }
29}
Transformed Code
1using Flashtrace.Formatters;
2using Metalama.Patterns.Caching;
3using Metalama.Patterns.Caching.Aspects;
4using Metalama.Patterns.Caching.Aspects.Helpers;
5using Metalama.Patterns.Caching.Formatters;
6using System;
7using System.Collections.Generic;
8using System.Reflection;
9
10namespace Doc.CacheKeyAspect
11{
12    public abstract class Entity : IFormattable<CacheKeyFormatting>
13    {
14        protected Entity(string kind, int id)
15        {
16            this.Kind = kind;
17            this.Id = id;
18        }
19
20        [CacheKey]
21        public string Kind { get; }
22
23        [CacheKey]
24        public int Id { get; }
25
26        public string? Description { get; set; }
27
28        void IFormattable<CacheKeyFormatting>.Format(UnsafeStringBuilder stringBuilder, IFormatterRepository formatterRepository)
29        {
30            stringBuilder.Append(this.GetType().FullName);
31            if (formatterRepository.Role is CacheKeyFormatting)
32            {
33                stringBuilder.Append(" ");
34                formatterRepository.Get<int>().Format(stringBuilder, this.Id);
35                stringBuilder.Append(" ");
36                formatterRepository.Get<string>().Format(stringBuilder, this.Kind);
37            }
38        }
39
40        protected virtual void FormatCacheKey(UnsafeStringBuilder stringBuilder, IFormatterRepository formatterRepository)
41        {
42            stringBuilder.Append(this.GetType().FullName);
43            if (formatterRepository.Role is CacheKeyFormatting)
44            {
45                stringBuilder.Append(" ");
46                formatterRepository.Get<int>().Format(stringBuilder, this.Id);
47                stringBuilder.Append(" ");
48                formatterRepository.Get<string>().Format(stringBuilder, this.Kind);
49            }
50        }
51    }
52
53    public class EntityService
54    {
55        [Cache]
56        public IEnumerable<Entity> GetRelatedEntities(Entity entity)
57        {
58            static object? Invoke(object? instance, object?[] args)
59            {
60                return ((EntityService)instance).GetRelatedEntities_Source((Entity)args[0]);
61            }
62
63            return _cachingService!.GetFromCacheOrExecute<IEnumerable<Entity>>(_cacheRegistration_GetRelatedEntities!, this, new object[] { entity }, Invoke);
64        }
65
66        private IEnumerable<Entity> GetRelatedEntities_Source(Entity entity) => throw new NotImplementedException();
67
68        private static readonly CachedMethodMetadata _cacheRegistration_GetRelatedEntities;
69        private ICachingService _cachingService;
70
71        static EntityService
72        ()
73        {
74            EntityService._cacheRegistration_GetRelatedEntities = CachedMethodMetadata.Register(RunTimeHelpers.ThrowIfMissing(typeof(EntityService).GetMethod("GetRelatedEntities", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(Entity) }, null)!, "EntityService.GetRelatedEntities(Entity)"), new CachedMethodConfiguration() { AbsoluteExpiration = null, AutoReload = null, IgnoreThisParameter = null, Priority = null, ProfileName = (string?)null, SlidingExpiration = null }, true);
75        }
76
77        public EntityService
78        (ICachingService? cachingService = default)
79        {
80            this._cachingService = cachingService ?? throw new System.ArgumentNullException(nameof(cachingService));
81        }
82    }
83}

Overriding the ToString method or the ISpanFormattable interface

For simple types, consider implementing the ToString method to return a distinct value for each distinct instance of the type.

Since ToString always allocates a short-lived string, which presents a minor performance overhead, an alternative is to implement the ISpanFormattable interface. However, the optimization level of Metalama Caching is not so high that using ISpanFormattable instead of ToString would make a significant difference at the moment.

The inconvenience of either of these approaches is that ToString and ISpanFormattable are typically used to create human-readable strings, which may conflict with the goal of creating cache keys. Whenever these goals are conflicting, it is better to take a different approach.

This approach is mentioned because this is the fallback mechanism: if Metalama Caching finds no other way to generate a cache key from an object, it will first see if ISpanFormattable is implemented, and, if not, it will use ToString.

Implementing the IFormattable interface

If none of the above approaches are suitable, you can manually implement the IFormattable<T> interface, where T is the CacheKeyFormatting class.

For inspiration, see the aspect-generated code of the [CacheKey] example above.

Warning

It is a best practice to include the full type name in all generated strings. Suppose for instance you have a class family representing database entities. The cache key of each entity is the Id property. If you don't include the type name in the cache key, you won't be able to differentiate a Customer from an Invoice that have the same Id, which may cause a problem in situations where the objects are passed as parameters of the same method.

Implementing a formatter for a third-party type

If you do not own the source code of a type, none of the approaches mentioned above can work. In this situation, follow these steps:

Step 1. Implement the Formatter class

Create a class derived from the Formatter<T> abstract class where T is the type for which you want to generate cache keys.

Note

Your formatter class can have generic parameters; in this case, they have to match the generic parameters of the formatted type. One of these generic parameters can represent the formatted type itself. This parameter must have the [BindToExtendedType] custom attribute.

Then, implement the Format abstract method.

Step 2. Register your new formatter

Return to the code that initialized Metalama Caching by calling serviceCollection.AddCaching or CachingService.Create, and supply a delegate that calls ConfigureFormatters like in the following snippet:

15                caching => caching.ConfigureFormatters(
16                    formatters => formatters.AddFormatter( r => new FileInfoFormatter( r ) ) ) );

Example: custom formatter for FileInfo

In this example, we demonstrate how to build a custom cache key formatter for the System.IO.FileInfo class, whose ToString implementation returns the file name instead of the full path and is therefore unsuitable for use in a cache key. The formatter is implemented by the FileInfoFormatter class, which is registered during the app initialization. Thanks to this, the FileSystem service can safely use System.IO.FileInfo in cached methods.

1using Metalama.Documentation.Helpers.ConsoleApp;
2using Microsoft.Extensions.Hosting;
3using System;
4using System.IO;
5
6namespace Doc.Formatter
7{
8    public sealed class ConsoleMain : IConsoleMain
9    {
10        private readonly FileSystem _fileSystem;
11
12        public ConsoleMain( FileSystem fileSystem )
13        {
14            this._fileSystem = fileSystem;
15        }
16
17        public void Execute()
18        {
19            var fileInfo = new FileInfo( Environment.ProcessPath! );
20
21            for ( var i = 0; i < 3; i++ )
22            {
23                var value = this._fileSystem.ReadAll( fileInfo );
24                Console.WriteLine( $"FileSystem returned {value.Length} bytes." );
25            }
26
27            Console.WriteLine( $"In total, FileSystem performed {this._fileSystem.OperationCount} operation(s)." );
28        }
29    }
30}
1using Flashtrace.Formatters;
2using System.IO;
3
4namespace Doc.Formatter
5{
6    internal class FileInfoFormatter : Formatter<FileInfo>
7    {
8        public FileInfoFormatter( IFormatterRepository repository ) : base( repository ) { }
9
10        public override void Format( UnsafeStringBuilder stringBuilder, FileInfo? value ) => stringBuilder.Append( value?.FullName ?? "<null>" );
11    }
12}
1using Metalama.Documentation.Helpers.ConsoleApp;
2using Metalama.Patterns.Caching.Building;
3using Microsoft.Extensions.DependencyInjection;
4
5namespace Doc.Formatter
6{
7    internal static class Program
8    {
9        public static void Main()
10        {
11            var builder = ConsoleApp.CreateBuilder();
12
13            // Add the caching service.
14            builder.Services.AddCaching(
15                caching => caching.ConfigureFormatters(
16                    formatters => formatters.AddFormatter( r => new FileInfoFormatter( r ) ) ) );
17
18            // Add other components as usual.
19            builder.Services.AddConsoleMain<ConsoleMain>();
20
21            builder.Services.AddSingleton<FileSystem>();
22
23            // Run the main service.
24            using var app = builder.Build();
25
26            app.Run();
27        }
28    }
29}
Source Code
1using Metalama.Patterns.Caching.Aspects;
2using System;

3using System.IO;

4
5namespace Doc.Formatter

6{
7    public sealed class FileSystem
8    {
9        public int OperationCount { get; private set; }
10
11        [Cache]
12        public byte[] ReadAll( FileInfo file )
13        {
14            this.OperationCount++;
15










16            Console.WriteLine( "Reading the whole file." );
17
18            return new byte[100 + this.OperationCount];
19        }
20    }















21}
Transformed Code
1using Metalama.Patterns.Caching;
2using Metalama.Patterns.Caching.Aspects;
3using Metalama.Patterns.Caching.Aspects.Helpers;
4using System;
5using System.IO;
6using System.Reflection;
7
8namespace Doc.Formatter
9{
10    public sealed class FileSystem
11    {
12        public int OperationCount { get; private set; }
13
14        [Cache]
15        public byte[] ReadAll(FileInfo file)
16        {
17            static object? Invoke(object? instance, object?[] args)
18            {
19                return ((FileSystem)instance).ReadAll_Source((FileInfo)args[0]);
20            }
21
22            return _cachingService!.GetFromCacheOrExecute<byte[]>(_cacheRegistration_ReadAll!, this, new object[] { file }, Invoke);
23        }
24
25        private byte[] ReadAll_Source(FileInfo file)
26        {
27            this.OperationCount++;
28
29            Console.WriteLine("Reading the whole file.");
30
31            return new byte[100 + this.OperationCount];
32        }
33
34        private static readonly CachedMethodMetadata _cacheRegistration_ReadAll;
35        private ICachingService _cachingService;
36
37        static FileSystem
38        ()
39        {
40            FileSystem._cacheRegistration_ReadAll = CachedMethodMetadata.Register(RunTimeHelpers.ThrowIfMissing(typeof(FileSystem).GetMethod("ReadAll", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(FileInfo) }, null)!, "FileSystem.ReadAll(FileInfo)"), new CachedMethodConfiguration() { AbsoluteExpiration = null, AutoReload = null, IgnoreThisParameter = null, Priority = null, ProfileName = (string?)null, SlidingExpiration = null }, true);
41        }
42
43        public FileSystem
44        (ICachingService? cachingService = default)
45        {
46            this._cachingService = cachingService ?? throw new System.ArgumentNullException(nameof(cachingService));
47        }
48    }
49}
Reading the whole file.
FileSystem returned 101 bytes.
FileSystem returned 101 bytes.
FileSystem returned 101 bytes.
In total, FileSystem performed 1 operation(s).

Changing the maximal length of a cache key

The maximum length of a cache key is 1024 characters by default.

To change the maximum length of a cache key, the procedure is similar to registering a custom formatter.

Go to the code that initialized Metalama Caching by calling serviceCollection.AddCaching or CachingService.Create. This time, call WithKeyBuilderOptions and pass a new instance of the CacheKeyBuilderOptions with the MaxKeySize property set to a different value.

Warning

If you need large cache keys, we suggest you also hash the cache key before submitting it to the caching backend. To hash the cache key, implement a custom cache key builder. We will show how to achieve this in the next section.

Overriding the cache key builder

The ultimate and hopefully least necessary solution to customize the cache key is to provide your own implementation of the ICacheKeyBuilder interface.

The default implementation is the CacheKeyBuilder class. It has many virtual methods that you can override. It generates the cache key by appending the following items:

  • in case that the backend supports it, a global prefix that allows using the same caching server with several applications (see e.g. KeyPrefix).

  • the full name of the declaring type (including generic parameters, if any),

  • the method name,

  • the method generic parameters, if any,

  • the this object (unless the method is static),

  • a comma-separated list of all method arguments including the full type of the parameter and the formatted parameter value,

To override the default ICacheKeyBuilder implementation:

  1. Create a new class that implements the ICacheKeyBuilder interface, or derive from CacheKeyBuilder if you want to reuse its logic.

  2. Register your implementation while calling serviceCollection.AddCaching or CachingService.Create as shown in the following snippet:

[!metalama-test ~/code/Metalama.Documentation.SampleCode.Caching/HashingKeyBuilder/HashingKeyBuilder.Program.cs marker="Registration"]

Example: implementing a hashing cache key builder

In this example, we show how to build and register a custom key builder. We chose the XxHash128 algorithm because it has good performance and very low collision.

Note that we are reusing the string-based CacheKeyBuilder implementation so that we can reuse the infrastructure described in this article. It is theoretically possible to implement a hashing string builder that does not rely on any string, but it would require us to design and implement a new solution, one that would not rely on the string-based IFormattable<T>.

1using Flashtrace.Formatters;
2using Metalama.Patterns.Caching;
3using Metalama.Patterns.Caching.Formatters;
4using System;
5using System.Collections.Generic;
6using System.IO.Hashing;
7
8namespace Doc.HashKeyBuilder
9{
10    internal sealed class HashingKeyBuilder : ICacheKeyBuilder, IDisposable
11    {
12        private readonly CacheKeyBuilder _underlyingBuilder;
13
14        public HashingKeyBuilder( IFormatterRepository formatters )
15        {
16            this._underlyingBuilder = new CacheKeyBuilder( formatters, new CacheKeyBuilderOptions() { MaxKeySize = 8000 } );
17        }
18
19        public string BuildMethodKey( CachedMethodMetadata metadata, object? instance, IList<object?> arguments )
20        {
21            var fullKey = this._underlyingBuilder.BuildMethodKey( metadata, instance, arguments );
22
23            return Hash( fullKey );
24        }
25
26        public string BuildDependencyKey( object o )
27        {
28            var fullKey = this._underlyingBuilder.BuildDependencyKey( o );
29
30            return Hash( fullKey );
31        }
32
33        private static string Hash( string s )
34        {
35            unsafe
36            {
37                fixed ( byte* hashBytes = stackalloc byte[128] )
38                fixed ( char* input = s )
39                {
40                    var span = new ReadOnlySpan<byte>( input, s.Length * 2 );
41                    var hashSpan = new Span<byte>( hashBytes, 128 );
42                    XxHash128.Hash( span, hashSpan );
43
44                    return Convert.ToBase64String( hashSpan );
45                }
46            }
47        }
48
49        public void Dispose()
50        {
51            this._underlyingBuilder.Dispose();
52        }
53    }
54}
1using Metalama.Documentation.Helpers.ConsoleApp;
2using Microsoft.Extensions.Hosting;
3using System;
4
5namespace Doc.HashKeyBuilder
6{
7    public sealed class ConsoleMain : IConsoleMain
8    {
9        private readonly FileSystem _fileSystem;
10
11        public ConsoleMain( FileSystem fileSystem )
12        {
13            this._fileSystem = fileSystem;
14        }
15
16        public void Execute()
17        {
18            for ( var i = 0; i < 3; i++ )
19            {
20                var value = this._fileSystem.ReadAll( Environment.ProcessPath! );
21                Console.WriteLine( $"FileSystem returned {value.Length} bytes." );
22            }
23
24            Console.WriteLine( $"In total, FileSystem performed {this._fileSystem.OperationCount} operation(s)." );
25        }
26    }
27}
1using Metalama.Documentation.Helpers.ConsoleApp;
2using Metalama.Patterns.Caching.Building;
3using Microsoft.Extensions.DependencyInjection;
4
5namespace Doc.HashKeyBuilder
6{
7    internal static class Program
8    {
9        public static void Main()
10        {
11            var builder = ConsoleApp.CreateBuilder();
12
13            // Add the caching service.
14            builder.Services.AddCaching(
15                caching => caching.WithKeyBuilder( ( formatters, _ ) => new HashingKeyBuilder( formatters ) ) );
16
17            // Add other components as usual.
18            builder.Services.AddConsoleMain<ConsoleMain>();
19            builder.Services.AddSingleton<FileSystem>();
20
21            // Run the main service.
22            using var app = builder.Build();
23            app.Run();
24        }
25    }
26}
Source Code
1using Metalama.Patterns.Caching.Aspects;
2using System;

3

4namespace Doc.HashKeyBuilder

5{
6    public sealed class FileSystem
7    {
8        public int OperationCount { get; private set; }
9
10        [Cache]
11        public byte[] ReadAll( string path )
12        {
13            this.OperationCount++;
14










15            Console.WriteLine( "Reading the whole file." );
16
17            return new byte[100 + this.OperationCount];
18        }
19    }















20}
Transformed Code
1using Metalama.Patterns.Caching;
2using Metalama.Patterns.Caching.Aspects;
3using Metalama.Patterns.Caching.Aspects.Helpers;
4using System;
5using System.Reflection;
6
7namespace Doc.HashKeyBuilder
8{
9    public sealed class FileSystem
10    {
11        public int OperationCount { get; private set; }
12
13        [Cache]
14        public byte[] ReadAll(string path)
15        {
16            static object? Invoke(object? instance, object?[] args)
17            {
18                return ((FileSystem)instance).ReadAll_Source((string)args[0]);
19            }
20
21            return _cachingService!.GetFromCacheOrExecute<byte[]>(_cacheRegistration_ReadAll!, this, new object[] { path }, Invoke);
22        }
23
24        private byte[] ReadAll_Source(string path)
25        {
26            this.OperationCount++;
27
28            Console.WriteLine("Reading the whole file.");
29
30            return new byte[100 + this.OperationCount];
31        }
32
33        private static readonly CachedMethodMetadata _cacheRegistration_ReadAll;
34        private ICachingService _cachingService;
35
36        static FileSystem
37        ()
38        {
39            FileSystem._cacheRegistration_ReadAll = CachedMethodMetadata.Register(RunTimeHelpers.ThrowIfMissing(typeof(FileSystem).GetMethod("ReadAll", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(string) }, null)!, "FileSystem.ReadAll(string)"), new CachedMethodConfiguration() { AbsoluteExpiration = null, AutoReload = null, IgnoreThisParameter = null, Priority = null, ProfileName = (string?)null, SlidingExpiration = null }, true);
40        }
41
42        public FileSystem
43        (ICachingService? cachingService = default)
44        {
45            this._cachingService = cachingService ?? throw new System.ArgumentNullException(nameof(cachingService));
46        }
47    }
48}
Reading the whole file.
FileSystem returned 101 bytes.
FileSystem returned 101 bytes.
FileSystem returned 101 bytes.
In total, FileSystem performed 1 operation(s).