This code incorporates the new .NET 7 Output Caching. I would like to get some refactoring tips on the RedisOutputCacheStore
class.
How to use
// Redis Output Cache
builder.Services.AddStackExchangeRedisCache(options => options.ConfigurationOptions = new ConfigurationOptions
{
EndPoints = { "localhost:6379" },
Password = "my_master_password",
Ssl = false // false for development testing purposes
});
builder.Services.AddRedisOutputCache();
app.UseOutputCache();
app.MapGet("/cache", () =>
{
Log.Information("Cache 1 called");
return Task.FromResult(Results.Ok("This is the endpoint that gets cached for 30 seconds. It also gets categorised under the 'cache' tag"));
})
.CacheOutput(x => x
.Expire(TimeSpan.FromSeconds(30))
.Tag("cache"));
app.MapGet("/cache2", (ILoggerFactory loggerFactory) =>
{
Log.Information("Cache 2 called");
return Task.FromResult(Results.Ok("This is the secondary endpoint that gets cached for 20 seconds. It also gets categorised under the 'cache' tag"));
})
.CacheOutput(x => x
.Expire(TimeSpan.FromSeconds(20))
.Tag("cache"));
app.MapGet("/uncache",
async (IOutputCacheStore outputCacheStore, CancellationToken token) =>
{
await outputCacheStore.EvictByTagAsync("cache", token);
return Results.Ok("This endpoint removes all the values for the cached endpoints that are categorised under the 'cache' tag");
});
Code
public static class OutputCacheExtensions
{
/// <summary>
/// Add output caching services using Redis.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection" /> for adding services.</param>
/// <returns></returns>
public static IServiceCollection AddRedisOutputCache(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>();
services.AddSingleton<IOutputCacheStore>(sp =>
{
var distributedCache = sp.GetRequiredService<IDistributedCache>();
var redisCacheOptions = sp.GetRequiredService<IOptions<RedisCacheOptions>>();
return new RedisOutputCacheStore(distributedCache, redisCacheOptions);
});
return services;
}
}
public sealed class RedisOutputCacheStore : IOutputCacheStore
{
private const string SetScript = @"
redis.call('HSET', KEYS[1], 'absexp', ARGV[1], 'sldexp', ARGV[2], 'data', ARGV[4])
if ARGV[3] ~= '-1' then
redis.call('EXPIRE', KEYS[1], ARGV[3])
end
return 1";
private const string SetScriptPreExtendedSetCommand = @"
redis.call('HMSET', KEYS[1], 'absexp', ARGV[1], 'sldexp', ARGV[2], 'data', ARGV[4])
if ARGV[3] ~= '-1' then
redis.call('EXPIRE', KEYS[1], ARGV[3])
end
return 1";
private static readonly Version ServerVersionWithExtendedSetCommand = new(4, 0, 0);
private readonly IDistributedCache _distributedCache;
private readonly RedisCacheOptions _options;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private volatile IConnectionMultiplexer? _connection;
private IDatabase? _cache;
private string _setScript = SetScript;
public RedisOutputCacheStore(IDistributedCache distributedCache, IOptions<RedisCacheOptions> redisCacheOptions)
{
_distributedCache = distributedCache;
_options = redisCacheOptions.Value;
}
public async ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken)
{
await ConnectAsync(cancellationToken).ConfigureAwait(false);
var memberKeys = _cache!.SetMembers($"tag_{tag}").Select(x => x.ToString());
var redisKeys = memberKeys.Select(x => new RedisKey(x)).ToArray();
await _cache.KeyDeleteAsync(redisKeys).ConfigureAwait(false);
}
public async ValueTask<byte[]?> GetAsync(string key, CancellationToken cancellationToken)
{
return await _distributedCache.GetAsync(key, cancellationToken).ConfigureAwait(false);
}
public async ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan validFor, CancellationToken cancellationToken)
{
var distributedCacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = validFor };
await _distributedCache.SetAsync(key, value, distributedCacheEntryOptions, cancellationToken);
await AddKeyToTagSet(key, tags, cancellationToken).ConfigureAwait(false);
}
private async Task AddKeyToTagSet(string key, string[]? tags, CancellationToken cancellationToken)
{
if (tags == null)
{
return;
}
await ConnectAsync(cancellationToken).ConfigureAwait(false);
foreach (var tag in tags)
{
await _cache!.SetAddAsync($"tag_{tag}", key).ConfigureAwait(false);
}
}
private async Task ConnectAsync(CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (_cache != null)
{
Debug.Assert(_connection != null);
return;
}
await _connectionLock.WaitAsync(token).ConfigureAwait(false);
try
{
if (_cache == null)
{
if (_options.ConnectionMultiplexerFactory is null)
{
if (_options.ConfigurationOptions is not null)
{
_connection = await ConnectionMultiplexer.ConnectAsync(_options.ConfigurationOptions).ConfigureAwait(false);
}
else
{
_connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration).ConfigureAwait(false);
}
}
else
{
_connection = await _options.ConnectionMultiplexerFactory().ConfigureAwait(false);
}
PrepareConnection();
_cache = _connection.GetDatabase();
}
}
finally
{
_connectionLock.Release();
}
}
private void PrepareConnection()
{
ValidateServerFeatures();
TryRegisterProfiler();
}
private void ValidateServerFeatures()
{
_ = _connection ?? throw new InvalidOperationException($"{nameof(_connection)} cannot be null.");
try
{
foreach (var endPoint in _connection.GetEndPoints())
{
if (_connection.GetServer(endPoint).Version < ServerVersionWithExtendedSetCommand)
{
_setScript = SetScriptPreExtendedSetCommand;
return;
}
}
}
catch (NotSupportedException)
{
_setScript = SetScriptPreExtendedSetCommand;
}
}
private void TryRegisterProfiler()
{
_ = _connection ?? throw new InvalidOperationException($"{nameof(_connection)} cannot be null.");
if (_options.ProfilingSession != null)
{
_connection.RegisterProfiler(_options.ProfilingSession);
}
}
}
```
StackExchange.Redis
andMicrosoft.Extensions.Caching.StackExchangeRedis
? \$\endgroup\$StackExchange.Redis
and .NET 7.0 \$\endgroup\$