-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathServiceCollectionExtensions.cs
More file actions
249 lines (223 loc) · 12.7 KB
/
Copy pathServiceCollectionExtensions.cs
File metadata and controls
249 lines (223 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
// Copyright (c) 2025, MeteredMemoryCache contributors
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace CacheImplementations;
/// <summary>
/// Extension methods for registering <see cref="MeteredMemoryCache"/> instances with dependency injection containers.
/// Provides fluent APIs for both named cache registration and decoration of existing <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> services.
/// </summary>
/// <remarks>
/// These extensions integrate with the .NET options pattern and validation framework to ensure correct configuration
/// at application startup. All registrations include automatic <see cref="System.Diagnostics.Metrics.Meter"/> setup
/// and <see cref="MeteredMemoryCacheOptions"/> validation.
/// </remarks>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers a named <see cref="MeteredMemoryCache"/> instance with comprehensive dependency injection setup.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to. Cannot be <see langword="null"/>.</param>
/// <param name="cacheName">The logical name for the cache instance, used for keyed service resolution and as the "cache.name" metric tag. Cannot be <see langword="null"/> or whitespace.</param>
/// <param name="configureOptions">Optional delegate to configure <see cref="MeteredMemoryCacheOptions"/>. If <see langword="null"/>, default options will be used.</param>
/// <returns>The <paramref name="services"/> collection for method chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="cacheName"/> is <see langword="null"/>, empty, or contains only whitespace.</exception>
/// <remarks>
/// <para>
/// This method performs comprehensive service registration including:
/// <list type="bullet">
/// <item><description>Named options configuration with validation using ValidateDataAnnotations and ValidateOnStart</description></item>
/// <item><description>Keyed service registration for multi-cache scenarios using AddKeyedSingleton</description></item>
/// <item><description>Fallback singleton registration for single-cache applications</description></item>
/// <item><description>Automatic <see cref="System.Diagnostics.Metrics.Meter"/> registration if not already present</description></item>
/// <item><description>Options validator registration using <see cref="MeteredMemoryCacheOptionsValidator"/></description></item>
/// </list>
/// </para>
/// <para>
/// Use <see cref="Microsoft.Extensions.DependencyInjection.ServiceProviderKeyedServiceExtensions.GetRequiredKeyedService{T}(IServiceProvider, object)"/>
/// to resolve specific named caches, or standard <see cref="Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService{T}(IServiceProvider)"/>
/// if only one cache is registered.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Basic registration
/// services.AddNamedMeteredMemoryCache("user-cache");
///
/// // With custom configuration
/// services.AddNamedMeteredMemoryCache("product-cache", opts =>
/// {
/// opts.AdditionalTags["service"] = "catalog-api";
/// opts.AdditionalTags["environment"] = "production";
/// });
///
/// // Usage in controllers or services
/// public class UserService
/// {
/// public UserService([FromKeyedServices("user-cache")] IMemoryCache cache) { }
/// }
/// </code>
/// </example>
public static IServiceCollection AddNamedMeteredMemoryCache(
this IServiceCollection services,
string cacheName,
Action<MeteredMemoryCacheOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
if (string.IsNullOrWhiteSpace(cacheName))
throw new ArgumentException("Cache name must be non-empty.", nameof(cacheName));
// Register options with validation
services.AddOptions<MeteredMemoryCacheOptions>(cacheName)
.Configure(opts =>
{
opts.CacheName = cacheName;
opts.DisposeInner = true; // Set DisposeInner=true for owned caches to prevent memory leaks
configureOptions?.Invoke(opts);
})
.ValidateDataAnnotations()
.ValidateOnStart();
// Register the options validator
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<MeteredMemoryCacheOptions>, MeteredMemoryCacheOptionsValidator>());
// Register the keyed cache service — uses IMeterFactory from DI when available
services.AddKeyedSingleton<IMemoryCache>(cacheName, (sp, key) =>
{
var keyString = (string)key!;
var innerCache = new MemoryCache(new MemoryCacheOptions());
var meterFactory = sp.GetService<IMeterFactory>();
var options = sp.GetRequiredService<IOptionsMonitor<MeteredMemoryCacheOptions>>().Get(keyString);
return new MeteredMemoryCache(innerCache, meterFactory, options);
});
// Try to register as singleton for cases where there's only one named cache
// Use TryAddSingleton to avoid concurrency issues
services.TryAddSingleton<IMemoryCache>(sp => sp.GetRequiredKeyedService<IMemoryCache>(cacheName));
return services;
}
/// <summary>
/// Decorates an existing <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> registration with <see cref="MeteredMemoryCache"/> to add comprehensive metrics.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> containing the existing cache registration. Cannot be <see langword="null"/>.</param>
/// <param name="cacheName">Optional logical name for the cache instance, used as the "cache.name" metric tag. If <see langword="null"/>, no cache name tag will be added.</param>
/// <param name="configureOptions">Optional delegate to configure <see cref="MeteredMemoryCacheOptions"/>. If <see langword="null"/>, default options will be used.</param>
/// <returns>The <paramref name="services"/> collection for method chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">Thrown when no <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> registration is found in the service collection.</exception>
/// <remarks>
/// <para>
/// This method replaces an existing <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> registration with a
/// <see cref="MeteredMemoryCache"/> decorator that wraps the original cache. The decorator preserves all functionality
/// of the original cache while adding OpenTelemetry metrics emission.
/// </para>
/// <para>
/// The decoration process:
/// <list type="number">
/// <item><description>Locates the existing <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> service descriptor</description></item>
/// <item><description>Removes the original registration from the service collection</description></item>
/// <item><description>Creates a new registration that instantiates the original cache and wraps it with <see cref="MeteredMemoryCache"/></description></item>
/// <item><description>Preserves the original service lifetime (Singleton, Scoped, or Transient)</description></item>
/// </list>
/// </para>
/// <para>
/// This approach is ideal for adding metrics to existing applications where cache registration is already established,
/// such as when using <c>services.AddMemoryCache()</c> or custom cache configurations.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Decorate existing cache registration
/// services.AddMemoryCache();
/// services.DecorateMemoryCacheWithMetrics("api-cache");
///
/// // With custom options
/// services.AddMemoryCache();
/// services.DecorateMemoryCacheWithMetrics("user-cache", configureOptions: opts =>
/// {
/// opts.DisposeInner = false; // Don't dispose shared cache
/// opts.AdditionalTags["component"] = "user-service";
/// });
/// </code>
/// </example>
public static IServiceCollection DecorateMemoryCacheWithMetrics(
this IServiceCollection services,
string? cacheName = null,
Action<MeteredMemoryCacheOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
// Use named options configuration to prevent global contamination
// Use timestamp + random to reduce collision probability
var optionsName = $"DecoratedCache_{DateTimeOffset.UtcNow.Ticks}_{Guid.NewGuid():N}";
services.AddOptions<MeteredMemoryCacheOptions>(optionsName)
.Configure(opts =>
{
opts.CacheName = cacheName;
opts.DisposeInner = false; // Don't dispose shared cache in decorator
configureOptions?.Invoke(opts);
})
.ValidateDataAnnotations()
.ValidateOnStart();
// Register the options validator
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<MeteredMemoryCacheOptions>, MeteredMemoryCacheOptionsValidator>());
// Manual decoration - find existing IMemoryCache registration and replace it
var existingDescriptor = FindAndValidateSingleMemoryCacheRegistration(services);
// Remove the existing registration
services.Remove(existingDescriptor);
// Create new descriptor that decorates the original — uses IMeterFactory from DI when available
var decoratedDescriptor = new ServiceDescriptor(
typeof(IMemoryCache),
sp =>
{
var innerCache = CreateInnerCache(existingDescriptor, sp);
var meterFactory = sp.GetService<IMeterFactory>();
var options = sp.GetRequiredService<IOptionsMonitor<MeteredMemoryCacheOptions>>().Get(optionsName);
return new MeteredMemoryCache(innerCache, meterFactory, options);
},
existingDescriptor.Lifetime);
// Add the decorated registration
services.Add(decoratedDescriptor);
return services;
}
private static IMemoryCache CreateInnerCache(ServiceDescriptor existingDescriptor, IServiceProvider serviceProvider)
{
// Keyed service descriptors throw when accessing ImplementationType/Factory/Instance.
// Check this first to provide a clear, actionable error message.
if (existingDescriptor.IsKeyedService)
{
throw new InvalidOperationException(
"Unable to resolve inner IMemoryCache instance. The service descriptor is a keyed service. " +
$"Use {nameof(DecorateMemoryCacheWithMetrics)} only with non-keyed IMemoryCache registrations.");
}
if (existingDescriptor.ImplementationType != null)
{
return (IMemoryCache)ActivatorUtilities.CreateInstance(serviceProvider, existingDescriptor.ImplementationType);
}
if (existingDescriptor.ImplementationFactory != null)
{
return (IMemoryCache)existingDescriptor.ImplementationFactory(serviceProvider);
}
if (existingDescriptor.ImplementationInstance is IMemoryCache instance)
{
return instance;
}
throw new InvalidOperationException("Unable to resolve inner IMemoryCache instance.");
}
/// <summary>
/// Finds and validates that there is exactly one IMemoryCache registration.
/// </summary>
private static ServiceDescriptor FindAndValidateSingleMemoryCacheRegistration(IServiceCollection services)
{
var existingDescriptors = services.Where(d => d.ServiceType == typeof(IMemoryCache)).ToList();
if (existingDescriptors.Count == 0)
{
throw new InvalidOperationException($"No IMemoryCache registration found. Register IMemoryCache before calling {nameof(DecorateMemoryCacheWithMetrics)}.");
}
if (existingDescriptors.Count > 1)
{
throw new InvalidOperationException($"Multiple IMemoryCache registrations found ({existingDescriptors.Count}). {nameof(DecorateMemoryCacheWithMetrics)} can only decorate a single IMemoryCache registration.");
}
return existingDescriptors[0];
}
}