Skip to content

Commit

Permalink
Extend interface for extension dev and optimize In-Memory caching (#124)
Browse files Browse the repository at this point in the history
* General tweaks and changes

- More efficient In-Memory cache purging and compacting background workers
- Provide more ways to register a custom cache implementations
- Little optimizations
- Create interface for CustomRedisCacheService
- Fix dynamic access warnings for health checks

* Reflect changes in Documentation

* Update extension developer documentation

* fix FormatByteCount to choose proper suffix
  • Loading branch information
JonathanBout authored Jan 27, 2025
1 parent 41478a4 commit 896cc13
Show file tree
Hide file tree
Showing 27 changed files with 483 additions and 215 deletions.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ dotnet run --property:PublishAot=false -- --CDN:DataRoot <your_cdn_data>
| `Cache:Type` | `InMemory`, `Redis` or `Disabled` | `InMemory`, or if Redis has been configured, `Redis` | What cache provider to use, if any. |
| **In-Memory Options** |
| `Cache:InMemory:MaxSize` | A size in kB | `500_000` | How big the cache may grow. When an entry is added, the oldest entries will be removed until this limit is met. |
| `Cache:InMemory:PurgeInterval` | A TimeSpan | 5 minutes | How often the purge loop should wake up, to remove stale items older than `Cache:MaxAge` |
| **Redis Options** |
| `Cache:Redis:ConnectionString` | A redis connection string | None. Required when using Redis | How SimpleCDN should connect to your Redis instance. |
| `Cache:Redis:ClientName` | A string, without spaces | `SimpleCDN` | How SimpleCDN should identify itself to Redis. |
Expand Down
9 changes: 9 additions & 0 deletions extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,21 @@ public static class SimpleCDNBuilderExtensions
{
public static ISimpleCDNBuilder AddSomeCachingProvider(this ISimpleCDNBuilder builder)
{
// === 1. Using the backing type ===
// register the service as a singleton
builder.Services.AddSingleton<IDistributedCache, MyCachingProvider>();

// tell SimpleCDN what the service implementation type is
builder.UseCacheImplementation<MyCachingProvider>();

// === 2. Using the public-facing type ===
builder.Services.AddSingleton<IMyCachingProvider, MyCachingProvider>();
builder.UseCachingImplementation<IMyCachingProvider>();

// === 3. Using a resolver delegate ===
builder.Services.AddSingleton<MyCachingProviderWrapper>();
builder.UseCachingImplementation(sp => sp.GetRequiredService<MyCachingProviderWrapper>().GetMyCachingProvider());

return builder;
}
}
Expand Down
4 changes: 2 additions & 2 deletions extensions/Redis/CustomRedisCacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

namespace SimpleCDN.Extensions.Redis
{
public sealed class CustomRedisCacheService(IOptionsMonitor<RedisCacheConfiguration> options, IOptionsMonitor<CacheConfiguration> cacheOptions)
: IDistributedCache, IAsyncDisposable, IDisposable
internal sealed class CustomRedisCacheService(IOptionsMonitor<RedisCacheConfiguration> options, IOptionsMonitor<CacheConfiguration> cacheOptions)
: IRedisCacheService, IAsyncDisposable, IDisposable
{
private readonly IOptionsMonitor<RedisCacheConfiguration> options = options;
private readonly IOptionsMonitor<CacheConfiguration> cacheOptions = cacheOptions;
Expand Down
26 changes: 26 additions & 0 deletions extensions/Redis/IRedisCacheService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Caching.Distributed;
using StackExchange.Redis;

namespace SimpleCDN.Extensions.Redis
{
/// <summary>
/// Represents a service for storing and retrieving data in a Redis cache.
/// </summary>
/// <seealso cref="IDistributedCache"/>
public interface IRedisCacheService : IDistributedCache
{
/// <summary>
/// Gets the <see cref="ConnectionMultiplexer"/> instance used for the Redis cache.
/// </summary>
/// <seealso cref="ConnectionMultiplexer"/>
/// <seealso cref="GetRedisConnectionAsync"/>
public ConnectionMultiplexer GetRedisConnection();

/// <summary>
/// Gets the <see cref="ConnectionMultiplexer"/> instance used for the Redis cache asynchronously.
/// </summary>
/// <seealso cref="ConnectionMultiplexer"/>
/// <seealso cref="GetRedisConnection"/>
public Task<ConnectionMultiplexer> GetRedisConnectionAsync();
}
}
5 changes: 4 additions & 1 deletion extensions/Redis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ in for example a kubernetes cluster.

[The SimpleCDN Docker Image](https://ghcr.io/jonathanbout/simplecdn) comes with Redis support by default.

After registering the extension, the connection with Redis is accesible by injecting `IRedisCacheService` in your services.
For typical usage, you don't need to interact with this service directly, as SimpleCDN will handle this for you.

## Features
- Redis caching

Expand All @@ -19,4 +22,4 @@ var cdnBuilder = builder.Services.AddSimpleCDN();

- `options.ConnectionString`: The configuration string for the Redis server. This is a required property.
- `options.ClientName`: How the client should be identified to Redis. Default is `SimpleCDN`. This value can't contain whitespace.
- `options.KeyPrefix`: A string to prepend to all keys SimpleCDN inserts. Default is `SimpleCDN::`. An empty value is allowed, meaning no prefix is added.
- `options.KeyPrefix`: A string to prepend to all keys SimpleCDN inserts. Default is `SimpleCDN::`. An empty value is allowed, meaning no prefix is added.
20 changes: 12 additions & 8 deletions extensions/Redis/SimpleCDN.Extensions.Redis.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,28 @@

<PropertyGroup>
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Title>SimpleCDN Redis Extension</Title>
<Description>An extension for using Redis with SimpleCDN, the static file server.</Description>
<PackageIcon>logo.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Authors>JonathanBout</Authors>
<Company>SimpleCDN</Company>
<Copyright>Contributors of SimpleCDN</Copyright>
<Description>An extension for using Redis with SimpleCDN, the static file server.</Description>
<Copyright>© 2025 Contributors of SimpleCDN</Copyright>
<PackageProjectUrl>https://github.com/JonathanBout/SimpleCDN</PackageProjectUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/JonathanBout/SimpleCDN.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>simplecdn-extension;redis</PackageTags>
<PackageTags>SimpleCDN Extension;Redis;ASP.NET Core</PackageTags>
<IsPackable>true</IsPackable>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<IncludeSymbols>True</IncludeSymbols>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<OutputType>Library</OutputType>
<PackageIcon>logo.png</PackageIcon>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<IsAotCompatible>true</IsAotCompatible>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>

Expand Down
6 changes: 2 additions & 4 deletions extensions/Redis/SimpleCDNBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@ public static class SimpleCDNBuilderExtensions
/// </summary>
public static ISimpleCDNBuilder AddRedisCache(this ISimpleCDNBuilder builder, Action<RedisCacheConfiguration> configure)
{
builder.Services.AddSingleton<CustomRedisCacheService>();

builder.Services.AddSingleton<IDistributedCache>(sp => sp.GetRequiredService<CustomRedisCacheService>());
builder.Services.AddSingleton<IRedisCacheService, CustomRedisCacheService>();

builder.Services.AddOptionsWithValidateOnStart<RedisCacheConfiguration>()
.Configure(configure)
.Validate<ILogger<RedisCacheConfiguration>>((config, logger) => config.Validate(logger), InvalidConfigurationMessage);

builder.UseCacheImplementation<CustomRedisCacheService>();
builder.UseCacheImplementation<IRedisCacheService>();

return builder;
}
Expand Down
12 changes: 12 additions & 0 deletions extensions/Redis/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
"version": 1,
"dependencies": {
"net8.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[8.0.11, )",
"resolved": "8.0.11",
"contentHash": "zk5lnZrYJgtuJG8L4v17Ej8rZ3PUcR2iweNV08BaO5LbYHIi2wNaVNcJoLxvqgQdnjLlKnCCfVGLDr6QHeAarQ=="
},
"Roslynator.Analyzers": {
"type": "Direct",
"requested": "[4.12.10, )",
Expand Down Expand Up @@ -41,6 +47,12 @@
}
},
"net9.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "zAwp213evC3UkimtVXRb+Dlgc/40QG145nmZDtp2LO9zJJMfrp+i/87BnXN7tRXEA4liyzdFkjqG1HE8/RPb4A=="
},
"Roslynator.Analyzers": {
"type": "Direct",
"requested": "[4.12.10, )",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SimpleCDN.Services;
using SimpleCDN.Services.Caching;
using SimpleCDN.Services.Caching.Implementations;
using SimpleCDN.Services.Compression;
using SimpleCDN.Services.Compression.Implementations;
using SimpleCDN.Services.Implementations;
using System.ComponentModel;
using System.Text.Json;

namespace SimpleCDN.Configuration
{
/// <summary>
/// Provides extension methods for configuring SimpleCDN.
/// </summary>
public static class ConfigurationExtensions
public static class CDNBuilderExtensions
{
const string InvalidConfigurationMessage = "See the log meessages for details.";

Expand Down Expand Up @@ -53,15 +56,13 @@ public static ISimpleCDNBuilder AddSimpleCDN(this IServiceCollection services)

private class SimpleCDNBuilder : ISimpleCDNBuilder
{
private Type _cacheImplementationType = typeof(DisabledCache);

public SimpleCDNBuilder(IServiceCollection services)
{
Services = services;

Services.AddSingleton<IDistributedCache, DisabledCache>();
Services.AddSingleton<ICacheManager, CacheManager>();
Services.AddSingleton<ICacheImplementationResolver>(sp => new CacheImplementationResolver(sp, _cacheImplementationType));
UseCacheImplementation<DisabledCache>();
Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(SourceGenerationContext.Default));
}

Expand All @@ -79,29 +80,33 @@ public ISimpleCDNBuilder ConfigureCaching(Action<CacheConfiguration> configure)
return this;
}

public ISimpleCDNBuilder DisableCaching() => UseCacheImplementation<DisabledCache>();

private void RemoveCacheImplementations()
{
Services.RemoveAll<ICacheImplementationResolver>();
}

public ISimpleCDNBuilder UseCacheImplementation<TImplementation>() where TImplementation : IDistributedCache
{
/*
* - if the service is registered as IDistributedCache
* - and
* | the implementation type is the specified type
* | or the implementation instance is the specified type
* | or the implementation factory is not null (we can't check the type)
*
* then we can use the specified cache implementation.
*/
if (!Services.Any(s => s.ServiceType == typeof(IDistributedCache)
&& (s.ImplementationType == typeof(TImplementation)
|| s.ImplementationInstance?.GetType() == typeof(TImplementation)
|| s.ImplementationFactory is not null)))
{
throw new InvalidOperationException("The specified cache implementation is not registered.");
}

_cacheImplementationType = typeof(TImplementation);
RemoveCacheImplementations();

Services.AddSingleton<ICacheImplementationResolver>(sp => new CacheImplementationResolver(sp, typeof(TImplementation)));
return this;
}

public ISimpleCDNBuilder UseCacheImplementation<TImplementation>(Func<IServiceProvider, TImplementation> resolve) where TImplementation : IDistributedCache
{
RemoveCacheImplementations();

Services.AddSingleton<ICacheImplementationResolver>(sp => new CacheImplementationResolver(resolve(sp)));
return this;
}

public ISimpleCDNBuilder UseCacheImplementation<TImplementation>(TImplementation implementation) where TImplementation : IDistributedCache
{
return UseCacheImplementation(_ => implementation);
}
}
}

Expand All @@ -125,11 +130,33 @@ public interface ISimpleCDNBuilder
/// </summary>
ISimpleCDNBuilder ConfigureCaching(Action<CacheConfiguration> configure);

/// <summary>
/// Disables caching. This may be overridden again when you register a cache implementation afterwards.
/// </summary>
ISimpleCDNBuilder DisableCaching();

/// <summary>
/// Configures SimpleCDN to use the specified cache implementation. This should be used when a custom cache implementation is used.
/// </summary>
/// <typeparam name="TImplementation">
/// The serivce type to use as the cache implementation. Must implement <see cref="IDistributedCache"/>.
/// </typeparam>
[Browsable(false)]
[EditorBrowsable(EditorBrowsableState.Never)]
ISimpleCDNBuilder UseCacheImplementation<TImplementation>() where TImplementation : IDistributedCache;

/// <summary>
/// Configures SimpleCDN to use the specified cache implementation. This should be used when a custom cache implementation is used.
/// </summary>
[Browsable(false)]
[EditorBrowsable(EditorBrowsableState.Never)]
ISimpleCDNBuilder UseCacheImplementation<TImplementation>(Func<IServiceProvider, TImplementation> provider) where TImplementation : IDistributedCache;

/// <summary>
/// Configures SimpleCDN to use the specified cache implementation. This should be used when a custom cache implementation is used.
/// </summary>
[Browsable(false)]
[EditorBrowsable(EditorBrowsableState.Never)]
ISimpleCDNBuilder UseCacheImplementation<TImplementation>(TImplementation implementation) where TImplementation : IDistributedCache;
}
}
4 changes: 4 additions & 0 deletions src/core/Configuration/InMemoryCacheConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ public class InMemoryCacheConfiguration
public uint MaxSize { get; set; } = 500_000; // 500 MB

/// <summary>
/// This value is not used anymore, and will be removed in a future version.
/// The cache doesn't work with purging intervals anymore, and instead directly waits for the oldest item to expire.
/// <br/> <br/>
/// The interval at which the cache is purged of expired items in Minutes. Default is 5 minutes.
/// Set to 0 to disable automatic purging.
/// </summary>
[Obsolete("The cache doesn't work with purging intervals anymore. This value is not used anymore, and will be removed in a future version.")]
public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromMinutes(5); // 5 minutes
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ public static ISimpleCDNBuilder AddInMemoryCache(this ISimpleCDNBuilder builder,
if (configure is not null)
builder.Services.Configure(configure);

builder.Services.AddSingleton<InMemoryCache>()
.AddHostedService(sp => sp.GetRequiredService<InMemoryCache>())
.AddSingleton<IDistributedCache>(sp => sp.GetRequiredService<InMemoryCache>());
builder.Services.AddSingleton<IDistributedCache, InMemoryCache>();
builder.UseCacheImplementation<InMemoryCache>();
return builder;
}
Expand Down
14 changes: 10 additions & 4 deletions src/core/GlobalConstants.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/*
* This file combines AssemblyInfo.cs and GlobalSuppressions.cs into a single file, as they are both used to configure the assembly.
* In here, we also have the Globals class for constant Assembly-wide values.
* This file combines AssemblyInfo.cs and GlobalSuppressions.cs into a single file,
* as they are both used to configure things for the whole assembly.
* In here, we also have the Globals class for constant Assembly-wide values, and the SourceGenerationContext for JSON serialization.
*/

using Microsoft.Extensions.Diagnostics.HealthChecks;
using SimpleCDN.Services.Caching.Implementations;
using SimpleCDN.Services.Implementations;
using System.Runtime.CompilerServices;
Expand All @@ -25,24 +27,28 @@ internal static class GlobalConstants
// NOTE: when changing this value, make sure to also change the references in
// other places like the Dockerfile health check (use the global search feature of your IDE!)
// but ultimately, this value should never change.
// This value should not start nor end with a slash.
/// <summary>
/// The root URL for the system files, like the style sheet.
/// </summary>
public const string SystemFilesRelativePath = "_cdn";

// This value should only contain letters, numbers, and dashes.
// But if you need to change it, you're probably doing something wrong.
/// <summary>
/// The key of the <see cref="HttpRequest.RouteValues"/> item that contains the CDN route.
/// This is used by the <see cref="CDNContext"/> to determine the base URL the CDN is placed at.
/// </summary>
public const string CDNRouteValueKey = "cdnRoute";
}

[JsonSourceGenerationOptions]
[JsonSerializable(typeof(SizeLimitedCacheDebugView))]
[JsonSourceGenerationOptions(Converters = [typeof(JsonStringEnumConverter<HealthStatus>)])]
#if DEBUG // only generate serializers for debug views in debug mode
[JsonSerializable(typeof(SizeLimitedCacheDebugView))]
[JsonSerializable(typeof(BasicDebugView))]
[JsonSerializable(typeof(DebugView))]
[JsonSerializable(typeof(DetailedDebugView))]
#endif
[JsonSerializable(typeof(object))]
internal partial class SourceGenerationContext : JsonSerializerContext;
}
Loading

0 comments on commit 896cc13

Please sign in to comment.