diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs index 53dd19cf..16eb2912 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs @@ -75,5 +75,51 @@ public static IFeatureManagementBuilder WithVariantService(this IFeatu return builder; } + + /// + /// Adds a to the feature management system. + /// + /// The used to customize feature management functionality. + /// The feature flag that should be used to determine which implementation of the service should be used. The will return different implementations of TService according to the feature status. + /// Options used to configure the feature service provider. + /// A that can be used to customize feature management functionality. + /// Thrown if feature name parameter is null. + /// Thrown if a feature service of the type has already been added. + public static IFeatureManagementBuilder WithFeatureService(this IFeatureManagementBuilder builder, string featureName, FeatureServiceProviderOptions options = null) + where TService : class + where TEnabled : class, TService + where TDisabled : class, TService + { + if (string.IsNullOrEmpty(featureName)) + { + throw new ArgumentNullException(nameof(featureName)); + } + + options ??= new FeatureServiceProviderOptions(); + + if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureServiceProvider))) + { + throw new InvalidOperationException($"A feature service of {typeof(TService).FullName} has already been added."); + } + + if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped)) + { + builder.Services.AddScoped>(sp => new FeatureServiceProvider( + sp, + sp.GetRequiredService(), + featureName, + options)); + } + else + { + builder.Services.AddSingleton>(sp => new FeatureServiceProvider( + sp, + sp.GetRequiredService(), + featureName, + options)); + } + + return builder; + } } } diff --git a/src/Microsoft.FeatureManagement/FeatureServiceProvider.cs b/src/Microsoft.FeatureManagement/FeatureServiceProvider.cs new file mode 100644 index 00000000..777fb9f8 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureServiceProvider.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + internal class FeatureServiceProvider : IFeatureServiceProvider + where TService : class + where TEnabled : class, TService + where TDisabled : class, TService + { + private readonly IServiceProvider _serviceProvider; + private readonly IFeatureManager _featureManager; + private readonly string _featureName; + private readonly FeatureServiceProviderOptions _options; + private TService _enabledService; + private TService _disabledService; + + /// + /// Creates a feature service provider. + /// + /// The service provider used to resolve implementation variants of TService. If it implements , keyed resolution is used to enable lazy instantiation; otherwise all registered implementations are enumerated. + /// The feature manager to get the assigned variant of the feature flag. + /// The feature flag that should be used to determine which variant of the service should be used. + /// Options used to configure the feature service provider. + /// Thrown if is null. + /// Thrown if is null. + /// Thrown if is null. + /// Thrown if is null. + public FeatureServiceProvider(IServiceProvider serviceProvider, IFeatureManager featureManager, string featureName, FeatureServiceProviderOptions options) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); + _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public ValueTask GetServiceAsync() + { + return GetServiceAsync(null); + } + + /// + public async ValueTask GetServiceAsync(TContext context) + { + var isEnabled = await _featureManager.IsEnabledAsync(_featureName, context); + + var implementation = isEnabled ? _enabledService : _disabledService; + if (implementation != null) + { + return implementation; + } + + // + // If the service provider supports keyed services, try to resolve the implementation by the configured key first. + // This allows lazy instantiation of the feature service. + if (_serviceProvider is IKeyedServiceProvider keyedServiceProvider) + { + implementation = isEnabled + ? _enabledService ??= keyedServiceProvider.GetKeyedService(_options.EnabledKey) + : _disabledService ??= keyedServiceProvider.GetKeyedService(_options.DisabledKey); + if (implementation != null) + { + return implementation; + } + } + + // + // Fall back to enumerating all non-keyed registrations of TService and matching by the implementation type. + return isEnabled + ? _enabledService ??= _serviceProvider.GetServices().OfType().FirstOrDefault() + : _disabledService ??= _serviceProvider.GetServices().OfType().FirstOrDefault(); + } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureServiceProviderOptions.cs b/src/Microsoft.FeatureManagement/FeatureServiceProviderOptions.cs new file mode 100644 index 00000000..87c2bd89 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureServiceProviderOptions.cs @@ -0,0 +1,18 @@ +namespace Microsoft.FeatureManagement +{ + /// + /// Specifies the keys used by a feature service provider to resolve an implementation based on the feature flag status when keyed di is available. + /// + public class FeatureServiceProviderOptions + { + /// + /// The key used to resolve the service when the feature flag is enabled and keyed di is available. + /// + public object EnabledKey { get; set; } = true; + + /// + /// The alias used to resolve the service when the feature flag is disabled and keyed di is available. + /// + public object DisabledKey { get; set; } = false; + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureServiceProvider.cs b/src/Microsoft.FeatureManagement/IFeatureServiceProvider.cs new file mode 100644 index 00000000..acd4748e --- /dev/null +++ b/src/Microsoft.FeatureManagement/IFeatureServiceProvider.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Used to get TService implementation based on the feature status. + /// + public interface IFeatureServiceProvider where TService : class + { + /// + /// Gets an implementation of TService. + /// + /// An implementation of TService. + ValueTask GetServiceAsync(); + + /// + /// Gets an implementation of TService with additional usage of the feature filters. + /// + /// A context that provides information to evaluate whether a feature should be on or off. + /// An implementation of TService. + ValueTask GetServiceAsync(TContext context); + } +} diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 4219a518..e8c78745 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -2385,6 +2385,121 @@ public async Task VariantServiceProviderPrefersKeyedOverNonKeyed() Assert.Equal("KeyedBeta", algorithm.Style); } + [Fact] + public async Task VariantServiceProviderStatusEagerResolution() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("DotnetFeatureManagementSchema.json") + .Build(); + + // + // OnTestFeature has no variants and is always enabled; OffTestFeature has none and is always disabled. + // The provider should fall back to the EnabledKey / DisabledKey respectively. + IServiceCollection services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithFeatureService(Features.OnTestFeature); + + IAlgorithm algorithm = await services.BuildServiceProvider() + .GetRequiredService>() + .GetServiceAsync(); + Assert.Equal("Beta", algorithm.Style); + + services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithFeatureService(Features.OffTestFeature); + + algorithm = await services.BuildServiceProvider() + .GetRequiredService>() + .GetServiceAsync(); + Assert.Equal("Sigma", algorithm.Style); + } + + [Fact] + public async Task VariantServiceProviderStatusLazyResolution() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("DotnetFeatureManagementSchema.json") + .Build(); + + // + // OnTestFeature has no variants and is always enabled; OffTestFeature has none and is always disabled. + // The provider should fall back to the EnabledKey / DisabledKey respectively. + IServiceCollection services = new ServiceCollection(); + + services.AddKeyedSingleton(true); + services.AddKeyedSingleton(false); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithFeatureService(Features.OnTestFeature); + + IAlgorithm algorithm = await services.BuildServiceProvider() + .GetRequiredService>() + .GetServiceAsync(); + Assert.Equal("Beta", algorithm.Style); + + services = new ServiceCollection(); + + services.AddKeyedSingleton(true); + services.AddKeyedSingleton(false); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithFeatureService(Features.OffTestFeature); + + algorithm = await services.BuildServiceProvider() + .GetRequiredService>() + .GetServiceAsync(); + Assert.Equal("Sigma", algorithm.Style); + } + + [Fact] + public async Task VariantServiceProviderStatusLazyResolutionWithContextualFeatureFilter() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("DotnetFeatureManagementSchema.json") + .Build(); + + IServiceCollection services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .AddFeatureFilter() + .WithFeatureService(Features.ConditionalFeature); + + ServiceProvider provider = services.BuildServiceProvider(); + + StringContextualTestFilter contextualTestFeatureFilter = provider.GetRequiredService>().OfType().First(); + + contextualTestFeatureFilter.ContextualCallback = (ctx, stringContext) => + { + var stringValue = ctx.Parameters.GetValue("P1"); + + return stringValue == stringContext; + }; + + IFeatureServiceProvider featureAlgorithm = provider.GetRequiredService>(); + + IAlgorithm algorithm = await featureAlgorithm.GetServiceAsync("V1"); + Assert.Equal("Beta", algorithm.Style); + + algorithm = await featureAlgorithm.GetServiceAsync("V2"); + Assert.Equal("Sigma", algorithm.Style); + } + [Fact] public async Task VariantFeatureFlagWithContextualFeatureFilter() { diff --git a/tests/Tests.FeatureManagement/StringContextualTestFilter.cs b/tests/Tests.FeatureManagement/StringContextualTestFilter.cs new file mode 100644 index 00000000..1f36a7f2 --- /dev/null +++ b/tests/Tests.FeatureManagement/StringContextualTestFilter.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement; +using System; +using System.Threading.Tasks; + +namespace Tests.FeatureManagement +{ + [FilterAlias("Test")] + class StringContextualTestFilter : IContextualFeatureFilter + { + public Func ContextualCallback { get; set; } + + public Task EvaluateAsync(FeatureFilterEvaluationContext context, string stringContext) + { + return Task.FromResult(ContextualCallback?.Invoke(context, stringContext) ?? false); + } + } +}