diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs index 53dd19cf..10a09a29 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs @@ -47,6 +47,21 @@ public static IFeatureManagementBuilder WithTargeting(this IFeatureManagement /// Thrown if feature name parameter is null. /// Thrown if a variant service of the type has already been added. public static IFeatureManagementBuilder WithVariantService(this IFeatureManagementBuilder builder, string featureName) where TService : class + { + return WithVariantService(builder, featureName, VariantServiceMatchMode.Variant); + } + + /// + /// Adds a to the feature management system that selects the implementation + /// by either the assigned variant name or the enabled status of the feature flag. + /// + /// The used to customize feature management functionality. + /// The feature flag that should be used to determine which implementation of the service should be used. + /// Describes whether the implementation is matched by variant name or by feature flag status. + /// A that can be used to customize feature management functionality. + /// Thrown if feature name parameter is null. + /// Thrown if a variant service of the type has already been added. + public static IFeatureManagementBuilder WithVariantService(this IFeatureManagementBuilder builder, string featureName, VariantServiceMatchMode matchMode) where TService : class { if (string.IsNullOrEmpty(featureName)) { @@ -63,14 +78,16 @@ public static IFeatureManagementBuilder WithVariantService(this IFeatu builder.Services.AddScoped>(sp => new VariantServiceProvider( featureName, sp.GetRequiredService(), - sp)); + sp, + matchMode)); } else { builder.Services.AddSingleton>(sp => new VariantServiceProvider( featureName, sp.GetRequiredService(), - sp)); + sp, + matchMode)); } return builder; diff --git a/src/Microsoft.FeatureManagement/VariantServiceAliasAttribute.cs b/src/Microsoft.FeatureManagement/VariantServiceAliasAttribute.cs index beebb88b..6d0784a9 100644 --- a/src/Microsoft.FeatureManagement/VariantServiceAliasAttribute.cs +++ b/src/Microsoft.FeatureManagement/VariantServiceAliasAttribute.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // using System; @@ -6,14 +6,15 @@ namespace Microsoft.FeatureManagement { /// - /// Allows the name of a variant service to be customized to relate to the variant name specified in configuration. + /// Allows a variant service implementation to be associated with either the assigned variant name + /// or the enabled status of the bound feature flag. /// public class VariantServiceAliasAttribute : Attribute { /// - /// Creates a variant service alias using the provided alias. + /// Creates a variant service alias that matches the assigned variant name of the feature flag. /// - /// The alias of the variant service. + /// The alias of the variant service. Used to match the assigned variant name specified in the configuration. public VariantServiceAliasAttribute(string alias) { if (string.IsNullOrEmpty(alias)) @@ -22,11 +23,27 @@ public VariantServiceAliasAttribute(string alias) } Alias = alias; + MatchMode = VariantServiceMatchMode.Variant; } /// - /// The name that will be used to match variant name specified in the configuration. + /// Creates a variant service alias that matches the enabled status of the feature flag. + /// + /// Whether the variant service should be selected when the feature flag is enabled (true) or disabled (false). + public VariantServiceAliasAttribute(bool enabled) + { + Alias = enabled ? bool.TrueString : bool.FalseString; + MatchMode = VariantServiceMatchMode.Status; + } + + /// + /// The name that will be used to match either the assigned variant name or the enabled status of the feature flag. /// public string Alias { get; } + + /// + /// Describes whether the implementation is matched by variant name or by feature flag status. + /// + public VariantServiceMatchMode MatchMode { get; } } } diff --git a/src/Microsoft.FeatureManagement/VariantServiceMatchMode.cs b/src/Microsoft.FeatureManagement/VariantServiceMatchMode.cs new file mode 100644 index 00000000..ba1e1371 --- /dev/null +++ b/src/Microsoft.FeatureManagement/VariantServiceMatchMode.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement +{ + /// + /// Describes how a variant service implementation is selected from the bound feature flag. + /// + public enum VariantServiceMatchMode + { + /// + /// The implementation is selected based on the assigned variant name of the feature flag. + /// + Variant = 0, + + /// + /// The implementation is selected based on the enabled status of the feature flag. + /// + Status = 1 + } +} diff --git a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs index 8f740618..d637dfdc 100644 --- a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs +++ b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // using Microsoft.Extensions.DependencyInjection; @@ -13,17 +13,18 @@ namespace Microsoft.FeatureManagement { /// - /// Used to get different implementations of TService depending on the assigned variant from a specific variant feature flag. + /// Used to get different implementations of TService depending on either the assigned variant or the enabled status of a feature flag. /// internal class VariantServiceProvider : IVariantServiceProvider where TService : class { private readonly IServiceProvider _serviceProvider; private readonly IVariantFeatureManager _featureManager; private readonly string _featureName; + private readonly VariantServiceMatchMode _matchMode; private readonly ConcurrentDictionary _variantServiceCache; /// - /// Creates a variant service provider. + /// Creates a variant service provider that selects an implementation based on the assigned variant of the feature flag. /// /// The feature flag that should be used to determine which variant of the service should be used. /// The feature manager to get the assigned variant of the feature flag. @@ -32,37 +33,62 @@ internal class VariantServiceProvider : IVariantServiceProviderThrown if is null. /// Thrown if is null. public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider) + : this(featureName, featureManager, serviceProvider, VariantServiceMatchMode.Variant) + { + } + + /// + /// Creates a variant service provider that selects an implementation based on the assigned variant or the enabled status of the feature flag. + /// + /// The feature flag that should be used to determine which variant of the service should be used. + /// The feature manager to evaluate the feature flag. + /// The service provider used to resolve implementation variants of TService. + /// Describes whether the implementation is matched by variant name or by feature flag status. + /// Thrown if is null. + /// Thrown if is null. + /// Thrown if is null. + public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider, VariantServiceMatchMode matchMode) { _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _matchMode = matchMode; _variantServiceCache = new ConcurrentDictionary(); } /// - /// Gets implementation of TService according to the assigned variant from the feature flag. + /// Gets the implementation of TService matched against the assigned variant or the enabled status of the feature flag. /// /// The cancellation token to cancel the operation. - /// An implementation matched with the assigned variant. If there is no matched implementation, it will return null. + /// An implementation matched with the assigned variant or status. If there is no matched implementation, it will return null. public async ValueTask GetServiceAsync(CancellationToken cancellationToken) { Debug.Assert(_featureName != null); - Variant variant = await _featureManager.GetVariantAsync(_featureName, cancellationToken); + if (_matchMode == VariantServiceMatchMode.Status) + { + bool isEnabled = await _featureManager.IsEnabledAsync(_featureName, cancellationToken); + + string statusKey = isEnabled ? bool.TrueString : bool.FalseString; - TService implementation = null; + return _variantServiceCache.GetOrAdd( + statusKey, + (_) => ResolveByStatus(isEnabled)); + } + + Variant variant = await _featureManager.GetVariantAsync(_featureName, cancellationToken); - if (variant != null) + if (variant == null) { - implementation = _variantServiceCache.GetOrAdd( - variant.Name, - (variantName) => ResolveVariantService(variantName)); + return null; } - return implementation; + return _variantServiceCache.GetOrAdd( + variant.Name, + (variantName) => ResolveByVariant(variantName)); } - private TService ResolveVariantService(string variantName) + private TService ResolveByVariant(string variantName) { if (_serviceProvider is IKeyedServiceProvider) { @@ -80,16 +106,54 @@ private TService ResolveVariantService(string variantName) service => IsMatchingVariantName(service.GetType(), variantName)); } - private bool IsMatchingVariantName(Type implementationType, string variantName) + private TService ResolveByStatus(bool enabled) { - string implementationName = ((VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)))?.Alias; + if (_serviceProvider is IKeyedServiceProvider) + { + TService keyedService = _serviceProvider.GetKeyedService(enabled); - if (implementationName == null) + if (keyedService != null) + { + return keyedService; + } + } + + IEnumerable services = _serviceProvider.GetRequiredService>(); + + return services.FirstOrDefault( + service => IsMatchingStatus(service.GetType(), enabled)); + } + + private static bool IsMatchingVariantName(Type implementationType, string variantName) + { + var attribute = (VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)); + + // + // Implementations explicitly declared as status-bound do not participate in variant-name matching. + if (attribute != null && attribute.MatchMode == VariantServiceMatchMode.Status) { - implementationName = implementationType.Name; + return false; } + string implementationName = attribute?.Alias ?? implementationType.Name; + return string.Equals(implementationName, variantName, StringComparison.OrdinalIgnoreCase); } + + private static bool IsMatchingStatus(Type implementationType, bool enabled) + { + var attribute = (VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)); + + // + // Only implementations explicitly declared as status-bound participate in status matching. + if (attribute == null || attribute.MatchMode != VariantServiceMatchMode.Status) + { + return false; + } + + string expected = enabled ? bool.TrueString : bool.FalseString; + + return string.Equals(attribute.Alias, expected, StringComparison.OrdinalIgnoreCase); + } } } diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index bc3c8d25..9e3269bf 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -2385,6 +2385,116 @@ public async Task VariantServiceProviderPrefersKeyedOverNonKeyed() Assert.Equal("KeyedBeta", algorithm.Style); } + [Fact] + public async Task VariantServiceProviderResolvesByFlagStatusWithKeyedServices() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + IServiceCollection enabledServices = new ServiceCollection(); + + enabledServices.AddKeyedSingleton(true); + enabledServices.AddKeyedSingleton(false); + + enabledServices.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OnTestFeature, VariantServiceMatchMode.Status); + + ServiceProvider enabledServiceProvider = enabledServices.BuildServiceProvider(); + + IVariantServiceProvider enabledFeaturedAlgorithm = enabledServiceProvider.GetRequiredService>(); + + IAlgorithm algorithm = await enabledFeaturedAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.NotNull(algorithm); + Assert.Equal("Enabled", algorithm.Style); + + IServiceCollection disabledServices = new ServiceCollection(); + + disabledServices.AddKeyedSingleton(true); + disabledServices.AddKeyedSingleton(false); + + disabledServices.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OffTestFeature, VariantServiceMatchMode.Status); + + ServiceProvider disabledServiceProvider = disabledServices.BuildServiceProvider(); + + IVariantServiceProvider disabledFeaturedAlgorithm = disabledServiceProvider.GetRequiredService>(); + + algorithm = await disabledFeaturedAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.NotNull(algorithm); + Assert.Equal("Disabled", algorithm.Style); + } + + [Fact] + public async Task VariantServiceProviderResolvesByFlagStatusWithAliasAttribute() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + IServiceCollection enabledServices = new ServiceCollection(); + + // + // Non-keyed registrations; matching relies on [VariantServiceAlias(bool)] attributes. + enabledServices.AddSingleton(); + enabledServices.AddSingleton(); + + enabledServices.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OnTestFeature, VariantServiceMatchMode.Status); + + ServiceProvider enabledServiceProvider = enabledServices.BuildServiceProvider(); + + IAlgorithm algorithm = await enabledServiceProvider.GetRequiredService>() + .GetServiceAsync(CancellationToken.None); + Assert.NotNull(algorithm); + Assert.Equal("Enabled", algorithm.Style); + + IServiceCollection disabledServices = new ServiceCollection(); + + disabledServices.AddSingleton(); + disabledServices.AddSingleton(); + + disabledServices.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OffTestFeature, VariantServiceMatchMode.Status); + + ServiceProvider disabledServiceProvider = disabledServices.BuildServiceProvider(); + + algorithm = await disabledServiceProvider.GetRequiredService>() + .GetServiceAsync(CancellationToken.None); + Assert.NotNull(algorithm); + Assert.Equal("Disabled", algorithm.Style); + } + + [Fact] + public async Task VariantServiceProviderStatusModeIgnoresVariantBoundImplementations() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + IServiceCollection services = new ServiceCollection(); + + // + // Only variant-bound implementations registered; none of them should match in Status mode. + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OnTestFeature, VariantServiceMatchMode.Status); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IAlgorithm algorithm = await serviceProvider.GetRequiredService>() + .GetServiceAsync(CancellationToken.None); + + Assert.Null(algorithm); + } + [Fact] public async Task VariantFeatureFlagWithContextualFeatureFilter() { diff --git a/tests/Tests.FeatureManagement/VariantServices.cs b/tests/Tests.FeatureManagement/VariantServices.cs index 942b110c..f8eeed83 100644 --- a/tests/Tests.FeatureManagement/VariantServices.cs +++ b/tests/Tests.FeatureManagement/VariantServices.cs @@ -37,4 +37,16 @@ public AlgorithmOmega(string style) Style = style; } } + + [VariantServiceAlias(true)] + class EnabledAlgorithm : IAlgorithm + { + public string Style { get; } = "Enabled"; + } + + [VariantServiceAlias(false)] + class DisabledAlgorithm : IAlgorithm + { + public string Style { get; } = "Disabled"; + } }