Skip to content

Commit

Permalink
Feature-based Injection (#335)
Browse files Browse the repository at this point in the history
* init

* draft

* use ValueTask

* support factory method

* add example

* update

* Update

* Update

* update example

* match variant name or configuration value

* update to the latest design

* merge with preview branch

* resolve comments

* rename to VariantService

* update & add comments

* remove POC example

* add testcases & use method name GetServiceAsync

* update comments

* add variant service cache

* resolve comments

* throw exception for duplicated registration

* add testcase

* remove unused package

* update comment

* set feature name in constructor
  • Loading branch information
zhiyuanliang-ms authored Feb 9, 2024
1 parent 8906633 commit 613380b
Show file tree
Hide file tree
Showing 8 changed files with 337 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,44 @@ public static IFeatureManagementBuilder WithTargeting<T>(this IFeatureManagement
return builder;
}

/// <summary>
/// Adds a <see cref="VariantServiceProvider{TService}"/> to the feature management system.
/// </summary>
/// <param name="builder">The <see cref="IFeatureManagementBuilder"/> used to customize feature management functionality.</param>
/// <param name="featureName">The feature flag that should be used to determine which variant of the service should be used. The <see cref="VariantServiceProvider{TService}"/> will return different implementations of TService according to the assigned variant.</param>
/// <returns>A <see cref="IFeatureManagementBuilder"/> that can be used to customize feature management functionality.</returns>
/// <exception cref="ArgumentNullException">Thrown if feature name parameter is null.</exception>
/// <exception cref="InvalidOperationException">Thrown if a variant service of the type has already been added.</exception>
public static IFeatureManagementBuilder WithVariantService<TService>(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<TService>)))
{
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<IVariantServiceProvider<TService>>(sp => new VariantServiceProvider<TService>(
featureName,
sp.GetRequiredService<IVariantFeatureManager>(),
sp.GetRequiredService<IEnumerable<TService>>()));
}
else
{
builder.Services.AddSingleton<IVariantServiceProvider<TService>>(sp => new VariantServiceProvider<TService>(
featureName,
sp.GetRequiredService<IVariantFeatureManager>(),
sp.GetRequiredService<IEnumerable<TService>>()));
}

return builder;
}

/// <summary>
/// Adds a telemetry publisher to the feature management system.
/// </summary>
Expand Down
21 changes: 21 additions & 0 deletions src/Microsoft.FeatureManagement/IVariantServiceProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.FeatureManagement
{
/// <summary>
/// Used to get different implementation variants of TService.
/// </summary>
public interface IVariantServiceProvider<TService> where TService : class
{
/// <summary>
/// Gets an implementation variant of TService.
/// </summary>
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
/// <returns>An implementation of TService.</returns>
ValueTask<TService> GetServiceAsync(CancellationToken cancellationToken);
}
}
32 changes: 32 additions & 0 deletions src/Microsoft.FeatureManagement/VariantServiceAliasAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using System;

namespace Microsoft.FeatureManagement
{
/// <summary>
/// Allows the name of a variant service to be customized to relate to the variant name specified in configuration.
/// </summary>
public class VariantServiceAliasAttribute : Attribute
{
/// <summary>
/// Creates a variant service alias using the provided alias.
/// </summary>
/// <param name="alias">The alias of the variant service.</param>
public VariantServiceAliasAttribute(string alias)
{
if (string.IsNullOrEmpty(alias))
{
throw new ArgumentNullException(nameof(alias));
}

Alias = alias;
}

/// <summary>
/// The name that will be used to match variant name specified in the configuration.
/// </summary>
public string Alias { get; }
}
}
80 changes: 80 additions & 0 deletions src/Microsoft.FeatureManagement/VariantServiceProvider.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Used to get different implementations of TService depending on the assigned variant from a specific variant feature flag.
/// </summary>
internal class VariantServiceProvider<TService> : IVariantServiceProvider<TService> where TService : class
{
private readonly IEnumerable<TService> _services;
private readonly IVariantFeatureManager _featureManager;
private readonly string _featureName;
private readonly ConcurrentDictionary<string, TService> _variantServiceCache;

/// <summary>
/// Creates a variant service provider.
/// </summary>
/// <param name="featureName">The feature flag that should be used to determine which variant of the service should be used.</param>
/// <param name="featureManager">The feature manager to get the assigned variant of the feature flag.</param>
/// <param name="services">Implementation variants of TService.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="featureName"/> is null.</exception>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="featureManager"/> is null.</exception>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="services"/> is null.</exception>
public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IEnumerable<TService> 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<string, TService>();
}

/// <summary>
/// Gets implementation of TService according to the assigned variant from the feature flag.
/// </summary>
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
/// <returns>An implementation matched with the assigned variant. If there is no matched implementation, it will return null.</returns>
public async ValueTask<TService> 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);
}
}
}
77 changes: 77 additions & 0 deletions tests/Tests.FeatureManagement/FeatureManagement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAlgorithm, AlgorithmBeta>();
services.AddSingleton<IAlgorithm, AlgorithmSigma>();
services.AddSingleton<IAlgorithm>(sp => new AlgorithmOmega("OMEGA"));

services.AddSingleton(configuration)
.AddFeatureManagement()
.AddFeatureFilter<TargetingFilter>()
.WithVariantService<IAlgorithm>(Features.VariantImplementationFeature);

var targetingContextAccessor = new OnDemandTargetingContextAccessor();

services.AddSingleton<ITargetingContextAccessor>(targetingContextAccessor);

ServiceProvider serviceProvider = services.BuildServiceProvider();

IVariantFeatureManager featureManager = serviceProvider.GetRequiredService<IVariantFeatureManager>();

IVariantServiceProvider<IAlgorithm> featuredAlgorithm = serviceProvider.GetRequiredService<IVariantServiceProvider<IAlgorithm>>();

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<InvalidOperationException>(() =>
{
services.AddFeatureManagement()
.WithVariantService<IAlgorithm>("DummyFeature1")
.WithVariantService<IAlgorithm>("DummyFeature2");
}
);
}
}
}
1 change: 1 addition & 0 deletions tests/Tests.FeatureManagement/Features.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
40 changes: 40 additions & 0 deletions tests/Tests.FeatureManagement/VariantServices.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
48 changes: 48 additions & 0 deletions tests/Tests.FeatureManagement/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
}
}
}
}

0 comments on commit 613380b

Please sign in to comment.