diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs index c4705c3e..e958b628 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs @@ -39,6 +39,44 @@ public static IFeatureManagementBuilder WithTargeting(this IFeatureManagement 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 variant of the service should be used. The will return different implementations of TService according to the assigned variant. + /// 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) where TService : class + { + if (string.IsNullOrEmpty(featureName)) + { + throw new ArgumentNullException(nameof(featureName)); + } + + if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IVariantServiceProvider))) + { + throw new InvalidOperationException($"A variant 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 VariantServiceProvider( + featureName, + sp.GetRequiredService(), + sp.GetRequiredService>())); + } + else + { + builder.Services.AddSingleton>(sp => new VariantServiceProvider( + featureName, + sp.GetRequiredService(), + sp.GetRequiredService>())); + } + + return builder; + } + /// /// Adds a telemetry publisher to the feature management system. /// diff --git a/src/Microsoft.FeatureManagement/IVariantServiceProvider.cs b/src/Microsoft.FeatureManagement/IVariantServiceProvider.cs new file mode 100644 index 00000000..67d3cc0f --- /dev/null +++ b/src/Microsoft.FeatureManagement/IVariantServiceProvider.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Used to get different implementation variants of TService. + /// + public interface IVariantServiceProvider where TService : class + { + /// + /// Gets an implementation variant of TService. + /// + /// The cancellation token to cancel the operation. + /// An implementation of TService. + ValueTask GetServiceAsync(CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.FeatureManagement/VariantServiceAliasAttribute.cs b/src/Microsoft.FeatureManagement/VariantServiceAliasAttribute.cs new file mode 100644 index 00000000..beebb88b --- /dev/null +++ b/src/Microsoft.FeatureManagement/VariantServiceAliasAttribute.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; + +namespace Microsoft.FeatureManagement +{ + /// + /// Allows the name of a variant service to be customized to relate to the variant name specified in configuration. + /// + public class VariantServiceAliasAttribute : Attribute + { + /// + /// Creates a variant service alias using the provided alias. + /// + /// The alias of the variant service. + public VariantServiceAliasAttribute(string alias) + { + if (string.IsNullOrEmpty(alias)) + { + throw new ArgumentNullException(nameof(alias)); + } + + Alias = alias; + } + + /// + /// The name that will be used to match variant name specified in the configuration. + /// + public string Alias { get; } + } +} diff --git a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs new file mode 100644 index 00000000..d4b3f514 --- /dev/null +++ b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Used to get different implementations of TService depending on the assigned variant from a specific variant feature flag. + /// + internal class VariantServiceProvider : IVariantServiceProvider where TService : class + { + private readonly IEnumerable _services; + private readonly IVariantFeatureManager _featureManager; + private readonly string _featureName; + private readonly ConcurrentDictionary _variantServiceCache; + + /// + /// Creates a variant service provider. + /// + /// 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. + /// Implementation variants of TService. + /// Thrown if is null. + /// Thrown if is null. + /// Thrown if is null. + public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IEnumerable services) + { + _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); + _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); + _services = services ?? throw new ArgumentNullException(nameof(services)); + _variantServiceCache = new ConcurrentDictionary(); + } + + /// + /// Gets implementation of TService according to the assigned variant from 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. + public async ValueTask GetServiceAsync(CancellationToken cancellationToken) + { + Debug.Assert(_featureName != null); + + Variant variant = await _featureManager.GetVariantAsync(_featureName, cancellationToken); + + TService implementation = null; + + if (variant != null) + { + implementation = _variantServiceCache.GetOrAdd( + variant.Name, + (_) => _services.FirstOrDefault( + service => IsMatchingVariantName( + service.GetType(), + variant.Name)) + ); + } + + return implementation; + } + + private bool IsMatchingVariantName(Type implementationType, string variantName) + { + string implementationName = ((VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)))?.Alias; + + if (implementationName == null) + { + implementationName = implementationType.Name; + } + + return string.Equals(implementationName, variantName, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index 8ac431b0..97c13e68 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -1459,5 +1459,82 @@ public async Task VariantsInvalidScenarios() Assert.Equal(FeatureManagementError.InvalidConfigurationSetting, e.Error); Assert.Contains(ConfigurationFields.PercentileAllocationFrom, e.Message); } + + [Fact] + public async Task VariantBasedInjection() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + IServiceCollection services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new AlgorithmOmega("OMEGA")); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .AddFeatureFilter() + .WithVariantService(Features.VariantImplementationFeature); + + var targetingContextAccessor = new OnDemandTargetingContextAccessor(); + + services.AddSingleton(targetingContextAccessor); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IVariantFeatureManager featureManager = serviceProvider.GetRequiredService(); + + IVariantServiceProvider featuredAlgorithm = serviceProvider.GetRequiredService>(); + + targetingContextAccessor.Current = new TargetingContext + { + UserId = "Guest" + }; + + IAlgorithm algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + + Assert.Null(algorithm); + + targetingContextAccessor.Current = new TargetingContext + { + UserId = "UserSigma" + }; + + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + + Assert.Null(algorithm); + + targetingContextAccessor.Current = new TargetingContext + { + UserId = "UserBeta" + }; + + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + + Assert.NotNull(algorithm); + Assert.Equal("Beta", algorithm.Style); + + targetingContextAccessor.Current = new TargetingContext + { + UserId = "UserOmega" + }; + + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + + Assert.NotNull(algorithm); + Assert.Equal("OMEGA", algorithm.Style); + + services = new ServiceCollection(); + + Assert.Throws(() => + { + services.AddFeatureManagement() + .WithVariantService("DummyFeature1") + .WithVariantService("DummyFeature2"); + } + ); + } } } \ No newline at end of file diff --git a/tests/Tests.FeatureManagement/Features.cs b/tests/Tests.FeatureManagement/Features.cs index d3d2b81f..c0819716 100644 --- a/tests/Tests.FeatureManagement/Features.cs +++ b/tests/Tests.FeatureManagement/Features.cs @@ -30,5 +30,6 @@ static class Features public const string VariantFeatureBothConfigurations = "VariantFeatureBothConfigurations"; public const string VariantFeatureInvalidStatusOverride = "VariantFeatureInvalidStatusOverride"; public const string VariantFeatureInvalidFromTo = "VariantFeatureInvalidFromTo"; + public const string VariantImplementationFeature = "VariantImplementationFeature"; } } diff --git a/tests/Tests.FeatureManagement/VariantServices.cs b/tests/Tests.FeatureManagement/VariantServices.cs new file mode 100644 index 00000000..942b110c --- /dev/null +++ b/tests/Tests.FeatureManagement/VariantServices.cs @@ -0,0 +1,40 @@ +using Microsoft.FeatureManagement; + +namespace Tests.FeatureManagement +{ + interface IAlgorithm + { + public string Style { get; } + } + + class AlgorithmBeta : IAlgorithm + { + public string Style { get; set; } + + public AlgorithmBeta() + { + Style = "Beta"; + } + } + + class AlgorithmSigma : IAlgorithm + { + public string Style { get; set; } + + public AlgorithmSigma() + { + Style = "Sigma"; + } + } + + [VariantServiceAlias("Omega")] + class AlgorithmOmega : IAlgorithm + { + public string Style { get; set; } + + public AlgorithmOmega(string style) + { + Style = style; + } + } +} diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index 4bfa426e..d6a3d798 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -493,6 +493,54 @@ "Name": "On" } ] + }, + "VariantImplementationFeature": { + "EnabledFor": [ + { + "Name": "Targeting", + "Parameters": { + "Audience": { + "Users": [ + "UserOmega", "UserSigma", "UserBeta" + ] + } + } + } + ], + "Variants": [ + { + "Name": "AlgorithmBeta" + }, + { + "Name": "Sigma", + "ConfigurationValue": "AlgorithmSigma" + }, + { + "Name": "Omega" + } + ], + "Allocation": { + "User": [ + { + "Variant": "AlgorithmBeta", + "Users": [ + "UserBeta" + ] + }, + { + "Variant": "Omega", + "Users": [ + "UserOmega" + ] + }, + { + "Variant": "Sigma", + "Users": [ + "UserSigma" + ] + } + ] + } } } } \ No newline at end of file