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.
1using Metalama.Patterns.Caching.Aspects;
2using System;
3using System.Collections.Generic;
4
5namespace Doc.CacheKeyAspect;
6
7public 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
24public class EntityService
25{
26 [Cache]
27 public IEnumerable<Entity> GetRelatedEntities( Entity entity )
28 => throw new NotImplementedException();
29}
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
12public 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(GetType().FullName);
31 if (formatterRepository.Role is CacheKeyFormatting)
32 {
33 stringBuilder.Append(" ");
34 formatterRepository.Get<int>().Format(stringBuilder, Id);
35 stringBuilder.Append(" ");
36 formatterRepository.Get<string>().Format(stringBuilder, Kind);
37 }
38 }
39
40 protected virtual void FormatCacheKey(UnsafeStringBuilder stringBuilder, IFormatterRepository formatterRepository)
41 {
42 stringBuilder.Append(GetType().FullName);
43 if (formatterRepository.Role is CacheKeyFormatting)
44 {
45 stringBuilder.Append(" ");
46 formatterRepository.Get<int>().Format(stringBuilder, Id);
47 stringBuilder.Append(" ");
48 formatterRepository.Get<string>().Format(stringBuilder, Kind);
49 }
50 }
51}
52
53public class EntityService
54{
55 [Cache]
56 public IEnumerable<Entity> GetRelatedEntities(Entity entity)
57
58 {
59 static object? Invoke(object? instance, object?[] args)
60 {
61 return ((EntityService)instance).GetRelatedEntities_Source((Entity)args[0]);
62 }
63
64 return _cachingService.GetFromCacheOrExecute<IEnumerable<Entity>>(_cacheRegistration_GetRelatedEntities, this, new object[] { entity }, Invoke);
65 }
66
67 private IEnumerable<Entity> GetRelatedEntities_Source(Entity entity) => throw new NotImplementedException();
68
69 private static readonly CachedMethodMetadata _cacheRegistration_GetRelatedEntities;
70 private ICachingService _cachingService;
71
72 static EntityService()
73 {
74 _cacheRegistration_GetRelatedEntities = CachedMethodMetadata.Register(typeof(EntityService).GetMethod("GetRelatedEntities", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(Entity) }, null).ThrowIfMissing("EntityService.GetRelatedEntities(Entity)"), new CachedMethodConfiguration() { AbsoluteExpiration = null, AutoReload = null, IgnoreThisParameter = null, Priority = null, ProfileName = (string?)null, SlidingExpiration = null }, true);
75 }
76
77 public EntityService(ICachingService? cachingService = null)
78 {
79 this._cachingService = cachingService ?? throw new System.ArgumentNullException(nameof(cachingService));
80 }
81}
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.AddMetalamaCaching or CachingService.Create, and supply a delegate that calls ConfigureFormatters like in the following snippet:
14 // Add the caching service.
15 builder.Services.AddMetalamaCaching(
16 caching => caching.ConfigureFormatters(
17 formatters
18 => formatters.AddFormatter(
19 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.Patterns.Caching.Aspects;
2using System;
3using System.IO;
4
5namespace Doc.Formatter;
6
7public 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}
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
10public 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 _cacheRegistration_ReadAll = CachedMethodMetadata.Register(typeof(FileSystem).GetMethod("ReadAll", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(FileInfo) }, null).ThrowIfMissing("FileSystem.ReadAll(FileInfo)"), new CachedMethodConfiguration() { AbsoluteExpiration = null, AutoReload = null, IgnoreThisParameter = null, Priority = null, ProfileName = (string?)null, SlidingExpiration = null }, true);
40 }
41
42 public FileSystem(ICachingService? cachingService = null)
43 {
44 this._cachingService = cachingService ?? throw new System.ArgumentNullException(nameof(cachingService));
45 }
46}
1using Metalama.Documentation.Helpers.ConsoleApp;
2using System;
3using System.IO;
4
5namespace Doc.Formatter;
6
7public 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 var fileInfo = new FileInfo( Environment.ProcessPath! );
19
20 for ( var i = 0; i < 3; i++ )
21 {
22 var value = this._fileSystem.ReadAll( fileInfo );
23 Console.WriteLine( $"FileSystem returned {value.Length} bytes." );
24 }
25
26 Console.WriteLine(
27 $"In total, FileSystem performed {this._fileSystem.OperationCount} operation(s)." );
28 }
29}
1using Flashtrace.Formatters;
2using System.IO;
3
4namespace Doc.Formatter;
5
6internal class FileInfoFormatter : Formatter<FileInfo>
7{
8 public FileInfoFormatter( IFormatterRepository repository ) : base( repository ) { }
9
10 public override void Format( UnsafeStringBuilder stringBuilder, FileInfo? value )
11 => stringBuilder.Append( value?.FullName ?? "<null>" );
12}
Reading the whole file. FileSystem returned 101 bytes. FileSystem returned 101 bytes. FileSystem returned 101 bytes. In total, FileSystem performed 1 operation(s).
1using Metalama.Documentation.Helpers.ConsoleApp;
2using Metalama.Patterns.Caching.Building;
3using Microsoft.Extensions.DependencyInjection;
4
5namespace Doc.Formatter;
6
7internal static class Program
8{
9 public static void Main()
10 {
11 var builder = ConsoleApp.CreateBuilder();
12
13 //
14 // Add the caching service.
15 builder.Services.AddMetalamaCaching(
16 caching => caching.ConfigureFormatters(
17 formatters
18 => formatters.AddFormatter(
19 r => new FileInfoFormatter( r ) ) ) );
20 //
21
22 // Add other components as usual.
23 builder.Services.AddConsoleMain<ConsoleMain>();
24
25 builder.Services.AddSingleton<FileSystem>();
26
27 // Run the main service.
28 using var app = builder.Build();
29
30 app.Run();
31 }
32}
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.AddMetalamaCaching 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:
Create a new class that implements the ICacheKeyBuilder interface, or derive from CacheKeyBuilder if you want to reuse its logic.
Register your implementation while calling serviceCollection.AddMetalamaCaching or CachingService.Create as shown in the following snippet:
1using Metalama.Documentation.Helpers.ConsoleApp;
2using Metalama.Patterns.Caching.Building;
3using Microsoft.Extensions.DependencyInjection;
4
5namespace Doc.HashingKeyBuilder;
6
7internal static class Program
8{
9 public static void Main()
10 {
11 var builder = ConsoleApp.CreateBuilder();
12
13 //
14 // Add the caching service.
15 builder.Services.AddMetalamaCaching(
16 caching => caching.WithKeyBuilder(
17 ( formatters, _ ) => new HashingKeyBuilder( formatters ) ) );
18
19 //
20
21 // Add other components as usual.
22 builder.Services.AddConsoleMain<ConsoleMain>();
23 builder.Services.AddSingleton<FileSystem>();
24
25 // Run the main service.
26 using var app = builder.Build();
27 app.Run();
28 }
29}
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 Metalama.Patterns.Caching.Aspects;
2using System;
3
4namespace Doc.HashingKeyBuilder;
5
6public 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}
1using Metalama.Patterns.Caching;
2using Metalama.Patterns.Caching.Aspects;
3using Metalama.Patterns.Caching.Aspects.Helpers;
4using System;
5using System.Reflection;
6
7namespace Doc.HashingKeyBuilder;
8
9public 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 _cacheRegistration_ReadAll = CachedMethodMetadata.Register(typeof(FileSystem).GetMethod("ReadAll", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(string) }, null).ThrowIfMissing("FileSystem.ReadAll(string)"), new CachedMethodConfiguration() { AbsoluteExpiration = null, AutoReload = null, IgnoreThisParameter = null, Priority = null, ProfileName = (string?)null, SlidingExpiration = null }, true);
39 }
40
41 public FileSystem(ICachingService? cachingService = null)
42 {
43 this._cachingService = cachingService ?? throw new System.ArgumentNullException(nameof(cachingService));
44 }
45}
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.HashingKeyBuilder;
9
10internal sealed class HashingKeyBuilder : ICacheKeyBuilder, IDisposable
11{
12 private readonly CacheKeyBuilder _underlyingBuilder;
13
14 public HashingKeyBuilder( IFormatterRepository formatters )
15 {
16 this._underlyingBuilder = new CacheKeyBuilder(
17 formatters,
18 new CacheKeyBuilderOptions() { MaxKeySize = 8000 } );
19 }
20
21 public string BuildMethodKey(
22 CachedMethodMetadata metadata,
23 object? instance,
24 IList<object?> arguments )
25 {
26 var fullKey = this._underlyingBuilder.BuildMethodKey( metadata, instance, arguments );
27
28 return Hash( fullKey );
29 }
30
31 public string BuildDependencyKey( object o )
32 {
33 var fullKey = this._underlyingBuilder.BuildDependencyKey( o );
34
35 return Hash( fullKey );
36 }
37
38 private static string Hash( string s )
39 {
40 unsafe
41 {
42 fixed ( byte* hashBytes = stackalloc byte[128] )
43 fixed ( char* input = s )
44 {
45 var span = new ReadOnlySpan<byte>( input, s.Length * 2 );
46 var hashSpan = new Span<byte>( hashBytes, 128 );
47 XxHash128.Hash( span, hashSpan );
48
49 return Convert.ToBase64String( hashSpan );
50 }
51 }
52 }
53
54 public void Dispose()
55 {
56 this._underlyingBuilder.Dispose();
57 }
58}
1using Metalama.Documentation.Helpers.ConsoleApp;
2using System;
3
4namespace Doc.HashingKeyBuilder;
5
6public sealed class ConsoleMain : IConsoleMain
7{
8 private readonly FileSystem _fileSystem;
9
10 public ConsoleMain( FileSystem fileSystem )
11 {
12 this._fileSystem = fileSystem;
13 }
14
15 public void Execute()
16 {
17 for ( var i = 0; i < 3; i++ )
18 {
19 var value = this._fileSystem.ReadAll( Environment.ProcessPath! );
20 Console.WriteLine( $"FileSystem returned {value.Length} bytes." );
21 }
22
23 Console.WriteLine(
24 $"In total, FileSystem performed {this._fileSystem.OperationCount} operation(s)." );
25 }
26}
Reading the whole file. FileSystem returned 101 bytes. FileSystem returned 101 bytes. FileSystem returned 101 bytes. In total, FileSystem performed 1 operation(s).
1using Metalama.Documentation.Helpers.ConsoleApp;
2using Metalama.Patterns.Caching.Building;
3using Microsoft.Extensions.DependencyInjection;
4
5namespace Doc.HashingKeyBuilder;
6
7internal static class Program
8{
9 public static void Main()
10 {
11 var builder = ConsoleApp.CreateBuilder();
12
13 //
14 // Add the caching service.
15 builder.Services.AddMetalamaCaching(
16 caching => caching.WithKeyBuilder(
17 ( formatters, _ ) => new HashingKeyBuilder( formatters ) ) );
18
19 //
20
21 // Add other components as usual.
22 builder.Services.AddConsoleMain<ConsoleMain>();
23 builder.Services.AddSingleton<FileSystem>();
24
25 // Run the main service.
26 using var app = builder.Build();
27 app.Run();
28 }
29}