Skip to content

Commit 613380b

Browse files
Feature-based Injection (#335)
* 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
1 parent 8906633 commit 613380b

File tree

8 files changed

+337
-0
lines changed

8 files changed

+337
-0
lines changed

src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,44 @@ public static IFeatureManagementBuilder WithTargeting<T>(this IFeatureManagement
3939
return builder;
4040
}
4141

42+
/// <summary>
43+
/// Adds a <see cref="VariantServiceProvider{TService}"/> to the feature management system.
44+
/// </summary>
45+
/// <param name="builder">The <see cref="IFeatureManagementBuilder"/> used to customize feature management functionality.</param>
46+
/// <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>
47+
/// <returns>A <see cref="IFeatureManagementBuilder"/> that can be used to customize feature management functionality.</returns>
48+
/// <exception cref="ArgumentNullException">Thrown if feature name parameter is null.</exception>
49+
/// <exception cref="InvalidOperationException">Thrown if a variant service of the type has already been added.</exception>
50+
public static IFeatureManagementBuilder WithVariantService<TService>(this IFeatureManagementBuilder builder, string featureName) where TService : class
51+
{
52+
if (string.IsNullOrEmpty(featureName))
53+
{
54+
throw new ArgumentNullException(nameof(featureName));
55+
}
56+
57+
if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IVariantServiceProvider<TService>)))
58+
{
59+
throw new InvalidOperationException($"A variant service of {typeof(TService).FullName} has already been added.");
60+
}
61+
62+
if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped))
63+
{
64+
builder.Services.AddScoped<IVariantServiceProvider<TService>>(sp => new VariantServiceProvider<TService>(
65+
featureName,
66+
sp.GetRequiredService<IVariantFeatureManager>(),
67+
sp.GetRequiredService<IEnumerable<TService>>()));
68+
}
69+
else
70+
{
71+
builder.Services.AddSingleton<IVariantServiceProvider<TService>>(sp => new VariantServiceProvider<TService>(
72+
featureName,
73+
sp.GetRequiredService<IVariantFeatureManager>(),
74+
sp.GetRequiredService<IEnumerable<TService>>()));
75+
}
76+
77+
return builder;
78+
}
79+
4280
/// <summary>
4381
/// Adds a telemetry publisher to the feature management system.
4482
/// </summary>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
7+
namespace Microsoft.FeatureManagement
8+
{
9+
/// <summary>
10+
/// Used to get different implementation variants of TService.
11+
/// </summary>
12+
public interface IVariantServiceProvider<TService> where TService : class
13+
{
14+
/// <summary>
15+
/// Gets an implementation variant of TService.
16+
/// </summary>
17+
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
18+
/// <returns>An implementation of TService.</returns>
19+
ValueTask<TService> GetServiceAsync(CancellationToken cancellationToken);
20+
}
21+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
using System;
5+
6+
namespace Microsoft.FeatureManagement
7+
{
8+
/// <summary>
9+
/// Allows the name of a variant service to be customized to relate to the variant name specified in configuration.
10+
/// </summary>
11+
public class VariantServiceAliasAttribute : Attribute
12+
{
13+
/// <summary>
14+
/// Creates a variant service alias using the provided alias.
15+
/// </summary>
16+
/// <param name="alias">The alias of the variant service.</param>
17+
public VariantServiceAliasAttribute(string alias)
18+
{
19+
if (string.IsNullOrEmpty(alias))
20+
{
21+
throw new ArgumentNullException(nameof(alias));
22+
}
23+
24+
Alias = alias;
25+
}
26+
27+
/// <summary>
28+
/// The name that will be used to match variant name specified in the configuration.
29+
/// </summary>
30+
public string Alias { get; }
31+
}
32+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Collections.Generic;
7+
using System.Diagnostics;
8+
using System.Linq;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
12+
namespace Microsoft.FeatureManagement
13+
{
14+
/// <summary>
15+
/// Used to get different implementations of TService depending on the assigned variant from a specific variant feature flag.
16+
/// </summary>
17+
internal class VariantServiceProvider<TService> : IVariantServiceProvider<TService> where TService : class
18+
{
19+
private readonly IEnumerable<TService> _services;
20+
private readonly IVariantFeatureManager _featureManager;
21+
private readonly string _featureName;
22+
private readonly ConcurrentDictionary<string, TService> _variantServiceCache;
23+
24+
/// <summary>
25+
/// Creates a variant service provider.
26+
/// </summary>
27+
/// <param name="featureName">The feature flag that should be used to determine which variant of the service should be used.</param>
28+
/// <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+
/// <exception cref="ArgumentNullException">Thrown if <paramref name="featureName"/> is null.</exception>
31+
/// <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)
34+
{
35+
_featureName = featureName ?? throw new ArgumentNullException(nameof(featureName));
36+
_featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager));
37+
_services = services ?? throw new ArgumentNullException(nameof(services));
38+
_variantServiceCache = new ConcurrentDictionary<string, TService>();
39+
}
40+
41+
/// <summary>
42+
/// Gets implementation of TService according to the assigned variant from the feature flag.
43+
/// </summary>
44+
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
45+
/// <returns>An implementation matched with the assigned variant. If there is no matched implementation, it will return null.</returns>
46+
public async ValueTask<TService> GetServiceAsync(CancellationToken cancellationToken)
47+
{
48+
Debug.Assert(_featureName != null);
49+
50+
Variant variant = await _featureManager.GetVariantAsync(_featureName, cancellationToken);
51+
52+
TService implementation = null;
53+
54+
if (variant != null)
55+
{
56+
implementation = _variantServiceCache.GetOrAdd(
57+
variant.Name,
58+
(_) => _services.FirstOrDefault(
59+
service => IsMatchingVariantName(
60+
service.GetType(),
61+
variant.Name))
62+
);
63+
}
64+
65+
return implementation;
66+
}
67+
68+
private bool IsMatchingVariantName(Type implementationType, string variantName)
69+
{
70+
string implementationName = ((VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)))?.Alias;
71+
72+
if (implementationName == null)
73+
{
74+
implementationName = implementationType.Name;
75+
}
76+
77+
return string.Equals(implementationName, variantName, StringComparison.OrdinalIgnoreCase);
78+
}
79+
}
80+
}

tests/Tests.FeatureManagement/FeatureManagement.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,5 +1459,82 @@ public async Task VariantsInvalidScenarios()
14591459
Assert.Equal(FeatureManagementError.InvalidConfigurationSetting, e.Error);
14601460
Assert.Contains(ConfigurationFields.PercentileAllocationFrom, e.Message);
14611461
}
1462+
1463+
[Fact]
1464+
public async Task VariantBasedInjection()
1465+
{
1466+
IConfiguration configuration = new ConfigurationBuilder()
1467+
.AddJsonFile("appsettings.json")
1468+
.Build();
1469+
1470+
IServiceCollection services = new ServiceCollection();
1471+
1472+
services.AddSingleton<IAlgorithm, AlgorithmBeta>();
1473+
services.AddSingleton<IAlgorithm, AlgorithmSigma>();
1474+
services.AddSingleton<IAlgorithm>(sp => new AlgorithmOmega("OMEGA"));
1475+
1476+
services.AddSingleton(configuration)
1477+
.AddFeatureManagement()
1478+
.AddFeatureFilter<TargetingFilter>()
1479+
.WithVariantService<IAlgorithm>(Features.VariantImplementationFeature);
1480+
1481+
var targetingContextAccessor = new OnDemandTargetingContextAccessor();
1482+
1483+
services.AddSingleton<ITargetingContextAccessor>(targetingContextAccessor);
1484+
1485+
ServiceProvider serviceProvider = services.BuildServiceProvider();
1486+
1487+
IVariantFeatureManager featureManager = serviceProvider.GetRequiredService<IVariantFeatureManager>();
1488+
1489+
IVariantServiceProvider<IAlgorithm> featuredAlgorithm = serviceProvider.GetRequiredService<IVariantServiceProvider<IAlgorithm>>();
1490+
1491+
targetingContextAccessor.Current = new TargetingContext
1492+
{
1493+
UserId = "Guest"
1494+
};
1495+
1496+
IAlgorithm algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
1497+
1498+
Assert.Null(algorithm);
1499+
1500+
targetingContextAccessor.Current = new TargetingContext
1501+
{
1502+
UserId = "UserSigma"
1503+
};
1504+
1505+
algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
1506+
1507+
Assert.Null(algorithm);
1508+
1509+
targetingContextAccessor.Current = new TargetingContext
1510+
{
1511+
UserId = "UserBeta"
1512+
};
1513+
1514+
algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
1515+
1516+
Assert.NotNull(algorithm);
1517+
Assert.Equal("Beta", algorithm.Style);
1518+
1519+
targetingContextAccessor.Current = new TargetingContext
1520+
{
1521+
UserId = "UserOmega"
1522+
};
1523+
1524+
algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None);
1525+
1526+
Assert.NotNull(algorithm);
1527+
Assert.Equal("OMEGA", algorithm.Style);
1528+
1529+
services = new ServiceCollection();
1530+
1531+
Assert.Throws<InvalidOperationException>(() =>
1532+
{
1533+
services.AddFeatureManagement()
1534+
.WithVariantService<IAlgorithm>("DummyFeature1")
1535+
.WithVariantService<IAlgorithm>("DummyFeature2");
1536+
}
1537+
);
1538+
}
14621539
}
14631540
}

tests/Tests.FeatureManagement/Features.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ static class Features
3030
public const string VariantFeatureBothConfigurations = "VariantFeatureBothConfigurations";
3131
public const string VariantFeatureInvalidStatusOverride = "VariantFeatureInvalidStatusOverride";
3232
public const string VariantFeatureInvalidFromTo = "VariantFeatureInvalidFromTo";
33+
public const string VariantImplementationFeature = "VariantImplementationFeature";
3334
}
3435
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using Microsoft.FeatureManagement;
2+
3+
namespace Tests.FeatureManagement
4+
{
5+
interface IAlgorithm
6+
{
7+
public string Style { get; }
8+
}
9+
10+
class AlgorithmBeta : IAlgorithm
11+
{
12+
public string Style { get; set; }
13+
14+
public AlgorithmBeta()
15+
{
16+
Style = "Beta";
17+
}
18+
}
19+
20+
class AlgorithmSigma : IAlgorithm
21+
{
22+
public string Style { get; set; }
23+
24+
public AlgorithmSigma()
25+
{
26+
Style = "Sigma";
27+
}
28+
}
29+
30+
[VariantServiceAlias("Omega")]
31+
class AlgorithmOmega : IAlgorithm
32+
{
33+
public string Style { get; set; }
34+
35+
public AlgorithmOmega(string style)
36+
{
37+
Style = style;
38+
}
39+
}
40+
}

tests/Tests.FeatureManagement/appsettings.json

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,54 @@
493493
"Name": "On"
494494
}
495495
]
496+
},
497+
"VariantImplementationFeature": {
498+
"EnabledFor": [
499+
{
500+
"Name": "Targeting",
501+
"Parameters": {
502+
"Audience": {
503+
"Users": [
504+
"UserOmega", "UserSigma", "UserBeta"
505+
]
506+
}
507+
}
508+
}
509+
],
510+
"Variants": [
511+
{
512+
"Name": "AlgorithmBeta"
513+
},
514+
{
515+
"Name": "Sigma",
516+
"ConfigurationValue": "AlgorithmSigma"
517+
},
518+
{
519+
"Name": "Omega"
520+
}
521+
],
522+
"Allocation": {
523+
"User": [
524+
{
525+
"Variant": "AlgorithmBeta",
526+
"Users": [
527+
"UserBeta"
528+
]
529+
},
530+
{
531+
"Variant": "Omega",
532+
"Users": [
533+
"UserOmega"
534+
]
535+
},
536+
{
537+
"Variant": "Sigma",
538+
"Users": [
539+
"UserSigma"
540+
]
541+
}
542+
]
543+
}
496544
}
497545
}
498546
}

0 commit comments

Comments
 (0)