Skip to content

Commit 02f4c2d

Browse files
Support keyed service for variant service provider (#606)
* support keyed service * support variant service provider options * update * update * revert variant service provider options * add comment
1 parent 0e8d5aa commit 02f4c2d

4 files changed

Lines changed: 189 additions & 17 deletions

File tree

examples/VariantServiceDemo/Program.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919
builder.Services.AddApplicationInsightsTelemetry();
2020

2121
//
22-
// Add variant implementations of ICalculator
23-
builder.Services.AddSingleton<ICalculator, DefaultCalculator>();
22+
// Add variant implementations of ICalculator using keyed services so that only the
23+
// implementation matching the assigned variant is instantiated on demand.
24+
builder.Services.AddKeyedSingleton<ICalculator, DefaultCalculator>("DefaultCalculator");
2425

25-
builder.Services.AddSingleton<ICalculator, RemoteCalculator>();
26+
builder.Services.AddKeyedSingleton<ICalculator, RemoteCalculator>("RemoteCalculator");
2627

2728
//
2829
// Enter feature management

src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,14 @@ public static IFeatureManagementBuilder WithVariantService<TService>(this IFeatu
6363
builder.Services.AddScoped<IVariantServiceProvider<TService>>(sp => new VariantServiceProvider<TService>(
6464
featureName,
6565
sp.GetRequiredService<IVariantFeatureManager>(),
66-
sp.GetRequiredService<IEnumerable<TService>>()));
66+
sp));
6767
}
6868
else
6969
{
7070
builder.Services.AddSingleton<IVariantServiceProvider<TService>>(sp => new VariantServiceProvider<TService>(
7171
featureName,
7272
sp.GetRequiredService<IVariantFeatureManager>(),
73-
sp.GetRequiredService<IEnumerable<TService>>()));
73+
sp));
7474
}
7575

7676
return builder;

src/Microsoft.FeatureManagement/VariantServiceProvider.cs

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33
//
4+
using Microsoft.Extensions.DependencyInjection;
45
using System;
56
using System.Collections.Concurrent;
67
using System.Collections.Generic;
@@ -16,7 +17,7 @@ namespace Microsoft.FeatureManagement
1617
/// </summary>
1718
internal class VariantServiceProvider<TService> : IVariantServiceProvider<TService> where TService : class
1819
{
19-
private readonly IEnumerable<TService> _services;
20+
private readonly IServiceProvider _serviceProvider;
2021
private readonly IVariantFeatureManager _featureManager;
2122
private readonly string _featureName;
2223
private readonly ConcurrentDictionary<string, TService> _variantServiceCache;
@@ -26,15 +27,15 @@ internal class VariantServiceProvider<TService> : IVariantServiceProvider<TServi
2627
/// </summary>
2728
/// <param name="featureName">The feature flag that should be used to determine which variant of the service should be used.</param>
2829
/// <param name="featureManager">The feature manager to get the assigned variant of the feature flag.</param>
29-
/// <param name="services">Implementation variants of TService.</param>
30+
/// <param name="serviceProvider">The service provider used to resolve implementation variants of TService. If it implements <see cref="IKeyedServiceProvider"/>, keyed resolution is used to enable lazy instantiation; otherwise all registered implementations are enumerated.</param>
3031
/// <exception cref="ArgumentNullException">Thrown if <paramref name="featureName"/> is null.</exception>
3132
/// <exception cref="ArgumentNullException">Thrown if <paramref name="featureManager"/> is null.</exception>
32-
/// <exception cref="ArgumentNullException">Thrown if <paramref name="services"/> is null.</exception>
33-
public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IEnumerable<TService> services)
33+
/// <exception cref="ArgumentNullException">Thrown if <paramref name="serviceProvider"/> is null.</exception>
34+
public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider)
3435
{
3536
_featureName = featureName ?? throw new ArgumentNullException(nameof(featureName));
3637
_featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager));
37-
_services = services ?? throw new ArgumentNullException(nameof(services));
38+
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
3839
_variantServiceCache = new ConcurrentDictionary<string, TService>();
3940
}
4041

@@ -55,16 +56,35 @@ public async ValueTask<TService> GetServiceAsync(CancellationToken cancellationT
5556
{
5657
implementation = _variantServiceCache.GetOrAdd(
5758
variant.Name,
58-
(_) => _services.FirstOrDefault(
59-
service => IsMatchingVariantName(
60-
service.GetType(),
61-
variant.Name))
62-
);
59+
(variantName) => ResolveVariantService(variantName));
6360
}
6461

6562
return implementation;
6663
}
6764

65+
private TService ResolveVariantService(string variantName)
66+
{
67+
//
68+
// If the service provider supports keyed services, try to resolve the variant by its name as the key first.
69+
// This allows lazy instantiation of the variant service.
70+
if (_serviceProvider is IKeyedServiceProvider)
71+
{
72+
TService keyedService = _serviceProvider.GetKeyedService<TService>(variantName);
73+
74+
if (keyedService != null)
75+
{
76+
return keyedService;
77+
}
78+
}
79+
80+
//
81+
// Fall back to enumerating all non-keyed registrations of TService and matching by VariantServiceAliasAttribute or the implementation type name.
82+
IEnumerable<TService> services = _serviceProvider.GetRequiredService<IEnumerable<TService>>();
83+
84+
return services.FirstOrDefault(
85+
service => IsMatchingVariantName(service.GetType(), variantName));
86+
}
87+
6888
private bool IsMatchingVariantName(Type implementationType, string variantName)
6989
{
7090
string implementationName = ((VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)))?.Alias;

tests/Tests.FeatureManagement/FeatureManagementTest.cs

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,12 +524,12 @@ public async Task MergesFeatureFlagsFromDifferentConfigurationSources()
524524
* Feature1: true
525525
* Feature2: true
526526
* FeatureA: true
527-
*
527+
*
528528
* appsettings2.json
529529
* Feature1: true
530530
* Feature2: false
531531
* FeatureB: true
532-
*
532+
*
533533
* appsettings3.json
534534
* Feature1: false
535535
* Feature2: false
@@ -2234,6 +2234,157 @@ public async Task VariantBasedInjection()
22342234
);
22352235
}
22362236

2237+
[Fact]
2238+
public async Task VariantServiceProviderResolvesKeyedService()
2239+
{
2240+
IConfiguration configuration = new ConfigurationBuilder()
2241+
.AddJsonFile("appsettings.json")
2242+
.Build();
2243+
2244+
IServiceCollection services = new ServiceCollection();
2245+
2246+
services.AddKeyedSingleton<IAlgorithm, AlgorithmBeta>("AlgorithmBeta");
2247+
services.AddKeyedSingleton<IAlgorithm, AlgorithmSigma>("Sigma");
2248+
services.AddKeyedSingleton<IAlgorithm>("Omega", (sp, _) => new AlgorithmOmega("OMEGA"));
2249+
2250+
services.AddSingleton(configuration)
2251+
.AddFeatureManagement()
2252+
.AddFeatureFilter<TargetingFilter>()
2253+
.WithVariantService<IAlgorithm>(Features.VariantImplementationFeature);
2254+
2255+
var targetingContextAccessor = new OnDemandTargetingContextAccessor();
2256+
2257+
services.AddSingleton<ITargetingContextAccessor>(targetingContextAccessor);
2258+
2259+
ServiceProvider serviceProvider = services.BuildServiceProvider();
2260+
2261+
IVariantServiceProvider<IAlgorithm> featuredAlgorithm = serviceProvider.GetRequiredService<IVariantServiceProvider<IAlgorithm>>();
2262+
2263+
targetingContextAccessor.Current = new TargetingContext { UserId = "UserBeta" };
2264+
IAlgorithm algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
2265+
Assert.NotNull(algorithm);
2266+
Assert.Equal("Beta", algorithm.Style);
2267+
2268+
targetingContextAccessor.Current = new TargetingContext { UserId = "UserSigma" };
2269+
algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
2270+
Assert.NotNull(algorithm);
2271+
Assert.Equal("Sigma", algorithm.Style);
2272+
2273+
targetingContextAccessor.Current = new TargetingContext { UserId = "UserOmega" };
2274+
algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
2275+
Assert.NotNull(algorithm);
2276+
Assert.Equal("OMEGA", algorithm.Style);
2277+
}
2278+
2279+
[Fact]
2280+
public async Task VariantServiceProviderKeyedServiceIsLazilyInstantiated()
2281+
{
2282+
IConfiguration configuration = new ConfigurationBuilder()
2283+
.AddJsonFile("appsettings.json")
2284+
.Build();
2285+
2286+
IServiceCollection services = new ServiceCollection();
2287+
2288+
int betaInstantiationCount = 0;
2289+
int sigmaInstantiationCount = 0;
2290+
int omegaInstantiationCount = 0;
2291+
2292+
services.AddKeyedSingleton<IAlgorithm>("AlgorithmBeta", (sp, _) =>
2293+
{
2294+
betaInstantiationCount++;
2295+
return new AlgorithmBeta();
2296+
});
2297+
services.AddKeyedSingleton<IAlgorithm>("Sigma", (sp, _) =>
2298+
{
2299+
sigmaInstantiationCount++;
2300+
return new AlgorithmSigma();
2301+
});
2302+
services.AddKeyedSingleton<IAlgorithm>("Omega", (sp, _) =>
2303+
{
2304+
omegaInstantiationCount++;
2305+
return new AlgorithmOmega("OMEGA");
2306+
});
2307+
2308+
services.AddSingleton(configuration)
2309+
.AddFeatureManagement()
2310+
.AddFeatureFilter<TargetingFilter>()
2311+
.WithVariantService<IAlgorithm>(Features.VariantImplementationFeature);
2312+
2313+
var targetingContextAccessor = new OnDemandTargetingContextAccessor();
2314+
2315+
services.AddSingleton<ITargetingContextAccessor>(targetingContextAccessor);
2316+
2317+
ServiceProvider serviceProvider = services.BuildServiceProvider();
2318+
2319+
IVariantServiceProvider<IAlgorithm> featuredAlgorithm = serviceProvider.GetRequiredService<IVariantServiceProvider<IAlgorithm>>();
2320+
2321+
//
2322+
// No variant resolved yet - nothing should be instantiated.
2323+
Assert.Equal(0, betaInstantiationCount);
2324+
Assert.Equal(0, sigmaInstantiationCount);
2325+
Assert.Equal(0, omegaInstantiationCount);
2326+
2327+
//
2328+
// Resolve the Beta variant. Only AlgorithmBeta should be instantiated.
2329+
targetingContextAccessor.Current = new TargetingContext { UserId = "UserBeta" };
2330+
IAlgorithm algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
2331+
Assert.Equal("Beta", algorithm.Style);
2332+
Assert.Equal(1, betaInstantiationCount);
2333+
Assert.Equal(0, sigmaInstantiationCount);
2334+
Assert.Equal(0, omegaInstantiationCount);
2335+
2336+
//
2337+
// Resolving Beta again should reuse the cached instance - no new instantiation.
2338+
algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
2339+
Assert.Equal("Beta", algorithm.Style);
2340+
Assert.Equal(1, betaInstantiationCount);
2341+
Assert.Equal(0, sigmaInstantiationCount);
2342+
Assert.Equal(0, omegaInstantiationCount);
2343+
2344+
//
2345+
// Resolve the Sigma variant. Only AlgorithmSigma should be instantiated additionally.
2346+
targetingContextAccessor.Current = new TargetingContext { UserId = "UserSigma" };
2347+
algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
2348+
Assert.Equal("Sigma", algorithm.Style);
2349+
Assert.Equal(1, betaInstantiationCount);
2350+
Assert.Equal(1, sigmaInstantiationCount);
2351+
Assert.Equal(0, omegaInstantiationCount);
2352+
}
2353+
2354+
[Fact]
2355+
public async Task VariantServiceProviderPrefersKeyedOverNonKeyed()
2356+
{
2357+
IConfiguration configuration = new ConfigurationBuilder()
2358+
.AddJsonFile("appsettings.json")
2359+
.Build();
2360+
2361+
IServiceCollection services = new ServiceCollection();
2362+
2363+
//
2364+
// Register both keyed and non-keyed implementations matching the same variant name.
2365+
// The keyed registration should take precedence.
2366+
services.AddSingleton<IAlgorithm, AlgorithmBeta>();
2367+
services.AddKeyedSingleton<IAlgorithm>("AlgorithmBeta", (sp, _) => new AlgorithmOmega("KeyedBeta"));
2368+
2369+
services.AddSingleton(configuration)
2370+
.AddFeatureManagement()
2371+
.AddFeatureFilter<TargetingFilter>()
2372+
.WithVariantService<IAlgorithm>(Features.VariantImplementationFeature);
2373+
2374+
var targetingContextAccessor = new OnDemandTargetingContextAccessor();
2375+
2376+
services.AddSingleton<ITargetingContextAccessor>(targetingContextAccessor);
2377+
2378+
ServiceProvider serviceProvider = services.BuildServiceProvider();
2379+
2380+
IVariantServiceProvider<IAlgorithm> featuredAlgorithm = serviceProvider.GetRequiredService<IVariantServiceProvider<IAlgorithm>>();
2381+
2382+
targetingContextAccessor.Current = new TargetingContext { UserId = "UserBeta" };
2383+
IAlgorithm algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
2384+
Assert.NotNull(algorithm);
2385+
Assert.Equal("KeyedBeta", algorithm.Style);
2386+
}
2387+
22372388
[Fact]
22382389
public async Task VariantFeatureFlagWithContextualFeatureFilter()
22392390
{

0 commit comments

Comments
 (0)