This is the second version of my FeatureToggle
service (see previous question). It still has the same purpose which is to help control whether a feature is ON
or OFF
and provide an alternative funtionality.
Changes
What is different in this version?
- I created a
FeatureIdentifier
that replaces the previousstring
token as a feature-key - I created decorators for each of the features of the previous god-class
FeatureToggle
FeatureToggle
- executesbody
when a feature isON
orfallback
when it'sOFF
FeatureToggler
- can automatically toggle betweenON/OFF
after each execution or only once withToggleOnce
optionFeatureTelemetry
- logs feature statistics like execution time and whether its options are dirty
- I moved feature-options to the new
IFeatureOptionRepository
and created multiple decorators for new featuresFeatureOptionRepository
- stores feature options in a dictionary and saves feature-option changes by settingSaved
flag and removingDirty
. I addedReaderWriterLockSlim
to it because I'm using it with a web-service that toggles features at runtime based on the requestFeatureOptionRepositoryDecorator
- simplifies creating decorators by providing default and overridable implementationsFeatureOptionFallback
- provides default feature-option when the current one isNone
FeatureOptionLock
- prevents accidental feature-options changes
Core
The new FeatureIdentifier
that currently has two properties Name
and Description
(this I might use later).
public class FeatureIdentifier : IEquatable<FeatureIdentifier>, IEquatable<string>
{
public FeatureIdentifier([NotNull] string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
[AutoEqualityProperty]
public string Name { get; }
public string Description { get; set; }
public override string ToString() => Name;
public override int GetHashCode() => AutoEquality<FeatureIdentifier>.Comparer.GetHashCode(this);
public override bool Equals(object obj) => obj is FeatureIdentifier fn && Equals(fn);
public bool Equals(FeatureIdentifier featureIdentifier) => AutoEquality<FeatureIdentifier>.Comparer.Equals(this, featureIdentifier);
public bool Equals(string name) => Equals(this, new FeatureIdentifier(name));
public static implicit operator FeatureIdentifier(string name) => new FeatureIdentifier(name);
public static implicit operator FeatureIdentifier(Selector selector) => new FeatureIdentifier(selector.ToString());
public static implicit operator string(FeatureIdentifier featureIdentifier) => featureIdentifier.ToString();
}
The FeatureToggle
with its decorators:
public interface IFeatureToggle
{
IFeatureOptionRepository Options { get; }
Task<T> ExecuteAsync<T>(FeatureIdentifier name, Func<Task<T>> body, Func<Task<T>> fallback);
}
public class FeatureToggle : IFeatureToggle
{
public FeatureToggle(IFeatureOptionRepository options)
{
Options = options;
}
public IFeatureOptionRepository Options { get; }
public async Task<T> ExecuteAsync<T>(FeatureIdentifier name, Func<Task<T>> body, Func<Task<T>> fallback)
{
// Not catching exceptions because the caller should handle them.
return await (this.IsEnabled(name) ? body : fallback)().ConfigureAwait(false);
}
}
public class FeatureToggler : IFeatureToggle
{
private readonly IFeatureToggle _featureToggle;
public FeatureToggler(IFeatureToggle featureToggle)
{
_featureToggle = featureToggle;
}
public IFeatureOptionRepository Options => _featureToggle.Options;
public Task<T> ExecuteAsync<T>(FeatureIdentifier name, Func<Task<T>> body, Func<Task<T>> fallback)
{
try
{
return _featureToggle.ExecuteAsync(name, body, fallback);
}
finally
{
if (Options[name].Contains(FeatureOption.Toggle))
{
Options.Toggle(name);
if (Options[name].Contains(FeatureOption.ToggleOnce))
{
Options[name] = Options[name].RemoveFlag(FeatureOption.Toggle | FeatureOption.ToggleOnce);
Options.SaveChanges(name);
}
}
}
}
}
public class FeatureTelemetry : IFeatureToggle
{
private readonly ILogger _logger;
private readonly IFeatureToggle _featureToggle;
public FeatureTelemetry(ILogger<FeatureTelemetry> logger, IFeatureToggle featureToggle)
{
_logger = logger;
_featureToggle = featureToggle;
}
public IFeatureOptionRepository Options => _featureToggle.Options;
public async Task<T> ExecuteAsync<T>(FeatureIdentifier name, Func<Task<T>> body, Func<Task<T>> fallback)
{
if (Options[name].Contains(FeatureOption.Telemetry))
{
using (_logger.BeginScope().CorrelationHandle(nameof(FeatureTelemetry)).AttachElapsed())
{
_logger.Log(Abstraction.Layer.Service().Meta(new { FeatureName = name }).Trace());
if (_featureToggle.IsDirty(name))
{
_logger.Log(Abstraction.Layer.Service().Meta(new { CustomFeatureOptions = _featureToggle.Options[name] }));
}
return await _featureToggle.ExecuteAsync(name, body, fallback);
}
}
else
{
return await _featureToggle.ExecuteAsync(name, body, fallback);
}
}
}
The FeatureOptionRepository
with its decorators:
public interface IFeatureOptionRepository
{
// Gets or sets feature options.
[NotNull]
FeatureOption this[FeatureIdentifier name] { get; set; }
// Saves current options as default.
void SaveChanges(FeatureIdentifier name = default);
}
public class FeatureOptionRepository : IFeatureOptionRepository
{
private readonly Dictionary<FeatureIdentifier, FeatureOption> _options;
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
public FeatureOptionRepository()
{
_options = new Dictionary<FeatureIdentifier, FeatureOption>();
}
public FeatureOption this[FeatureIdentifier name]
{
get
{
_lock.EnterReadLock();
try
{
return _options.TryGetValue(name, out var option) ? option : FeatureOption.None;
}
finally
{
_lock.ExitReadLock();
}
}
set
{
_lock.EnterWriteLock();
try
{
_options[name] = value.RemoveFlag(FeatureOption.Saved).SetFlag(FeatureOption.Dirty);
}
finally
{
_lock.ExitWriteLock();
}
}
}
public void SaveChanges(FeatureIdentifier name = default)
{
_lock.EnterWriteLock();
try
{
var names =
name is null
? _options.Keys.ToList() // <-- prevents collection-modified-exception
: new List<FeatureIdentifier> { name };
foreach (var n in names)
{
_options[n] = _options[n].RemoveFlag(FeatureOption.Dirty).SetFlag(FeatureOption.Saved);
}
}
finally
{
_lock.ExitWriteLock();
}
}
}
public abstract class FeatureOptionRepositoryDecorator : IFeatureOptionRepository
{
protected FeatureOptionRepositoryDecorator(IFeatureOptionRepository instance)
{
Instance = instance;
}
protected IFeatureOptionRepository Instance { get; }
public virtual FeatureOption this[FeatureIdentifier name]
{
get => Instance[name];
set => Instance[name] = value;
}
public virtual void SaveChanges(FeatureIdentifier name = default) => Instance.SaveChanges();
}
// Provides default feature-options if not already configured.
public class FeatureOptionFallback : FeatureOptionRepositoryDecorator
{
private readonly FeatureOption _defaultOption;
public FeatureOptionFallback(IFeatureOptionRepository options, FeatureOption defaultOption) : base(options)
{
_defaultOption = defaultOption;
}
public override FeatureOption this[FeatureIdentifier name]
{
get => Instance[name] is var option && option == FeatureOption.None ? _defaultOption : option;
set => Instance[name] = value;
}
public class Enabled : FeatureOptionFallback
{
public Enabled(IFeatureOptionRepository options, FeatureOption other = default)
: base(options, FeatureOption.Enabled | (other ?? FeatureOption.None)) { }
}
}
// Locks feature option setter.
public class FeatureOptionLock : FeatureOptionRepositoryDecorator
{
public FeatureOptionLock(IFeatureOptionRepository options) : base(options) { }
public override FeatureOption this[FeatureIdentifier name]
{
get => Instance[name];
set
{
if (Instance[name].Contains(FeatureOption.Locked))
{
throw new InvalidOperationException($"Cannot set options for feature '{name}' because it's locked.");
}
Instance[name] = value;
}
}
}
Helpers
The most common operations are made convenient with these extensions:
public static class FeatureServiceHelpers
{
public static bool IsEnabled(this IFeatureToggle toggle, FeatureIdentifier name)
{
return toggle.Options[name].Contains(FeatureOption.Enabled);
}
public static bool IsLocked(this IFeatureToggle toggle, FeatureIdentifier name)
{
return toggle.Options[name].Contains(FeatureOption.Locked);
}
// Returns True if options are different from default.
public static bool IsDirty(this IFeatureToggle toggle, FeatureIdentifier name)
{
return toggle.Options[name].Contains(FeatureOption.Dirty);
}
#region Execute
public static async Task ExecuteAsync(this IFeatureToggle features, FeatureIdentifier name, Func<Task> body, Func<Task> fallback)
{
await features.ExecuteAsync<object>
(
name,
() =>
{
body();
return default;
},
() =>
{
fallback();
return default;
}
);
}
public static async Task ExecuteAsync(this IFeatureToggle features, FeatureIdentifier name, Func<Task> body)
{
await features.ExecuteAsync(name, body, () => Task.FromResult<object>(default));
}
public static T Execute<T>(this IFeatureToggle featureToggle, FeatureIdentifier name, Func<T> body, Func<T> fallback)
{
return
featureToggle
.ExecuteAsync
(
name,
() => body().ToTask(),
() => fallback().ToTask()
)
.GetAwaiter()
.GetResult();
}
public static void Execute(this IFeatureToggle featureToggle, FeatureIdentifier name, Action body, Action fallback)
{
featureToggle
.ExecuteAsync<object>
(
name,
() =>
{
body();
return default;
},
() =>
{
fallback();
return default;
}
)
.GetAwaiter()
.GetResult();
}
public static void Execute(this IFeatureToggle featureToggle, FeatureIdentifier name, Action body)
{
featureToggle.Execute(name, body, () => { });
}
public static T Switch<T>(this IFeatureToggle featureToggle, FeatureIdentifier name, T value, T fallback)
{
return featureToggle.Execute(name, () => value, () => fallback);
}
#endregion
public static IFeatureOptionRepository Batch(this IFeatureOptionRepository options, IEnumerable<string> names, FeatureOption featureOption, BatchOption batchOption)
{
foreach (var name in names)
{
if (batchOption == BatchOption.Set)
{
options[name] = options[name].SetFlag(featureOption);
}
if (batchOption == BatchOption.Remove)
{
options[name] = options[name].RemoveFlag(featureOption);
}
}
return options;
}
public static IFeatureToggle Toggle(this IFeatureToggle featureToggle, FeatureIdentifier name)
{
featureToggle.Options.Toggle(name);
return featureToggle;
}
public static IFeatureOptionRepository Toggle(this IFeatureOptionRepository options, FeatureIdentifier name)
{
options[name] =
options[name].Contains(FeatureOption.Enabled)
? options[name].RemoveFlag(FeatureOption.Enabled)
: options[name].SetFlag(FeatureOption.Enabled);
return options;
}
public static IFeatureToggle EnableToggler(this IFeatureToggle featureToggle, FeatureIdentifier name, bool once = true)
{
featureToggle.Options[name] =
featureToggle.Options[name]
.SetFlag(FeatureOption.Toggle)
.SetFlag(once ? FeatureOption.ToggleOnce : FeatureOption.None);
return featureToggle;
}
}
public class BatchOption : Option<BatchOption>
{
public BatchOption(SoftString name, IImmutableSet<SoftString> values) : base(name, values) { }
public static readonly BatchOption Set = CreateWithCallerName();
public static readonly BatchOption Remove = CreateWithCallerName();
}
Options
These are the options that are currently supported:
public class FeatureOption : Option<FeatureOption>
{
public FeatureOption(SoftString name, IImmutableSet<SoftString> values) : base(name, values) { }
/// <summary>
/// Indicates that a feature is enabled.
/// </summary>
public static readonly FeatureOption Enabled = CreateWithCallerName();
/// <summary>
/// Indicates that a warning should be logged when a feature is dirty.
/// </summary>
public static readonly FeatureOption WarnIfDirty = CreateWithCallerName();
/// <summary>
/// Indicates that feature telemetry should be logged.
/// </summary>
public static readonly FeatureOption Telemetry = CreateWithCallerName();
/// <summary>
/// Indicates that a feature should be toggled after each execution.
/// </summary>
public static readonly FeatureOption Toggle = CreateWithCallerName();
/// <summary>
/// Indicates that a feature should be toggled only once.
/// </summary>
public static readonly FeatureOption ToggleOnce = CreateWithCallerName();
/// <summary>
/// Indicates that feature-options must not be changed.
/// </summary>
public static readonly FeatureOption Locked = CreateWithCallerName();
/// <summary>
/// Indicates that feature-options have been changed.
/// </summary>
public static readonly FeatureOption Dirty = CreateWithCallerName();
// You use this to distinguish between FeatureOption.None which results in default-options.
/// <summary>
/// Indicates that feature-options have been saved.
/// </summary>
public static readonly FeatureOption Saved = CreateWithCallerName();
}
Tests / Examples
The FeatureToggle
can be used anywhere for anything. These two tests show how the Switch
extension is used for efficient testing.
[Fact]
public void Can_configure_features_by_tags()
{
var builder = new ContainerBuilder();
builder.RegisterType<FeatureOptionRepository>().As<IFeatureOptionRepository>();
builder.RegisterDecorator<IFeatureOptionRepository>((context, parameters, instance) => new FeatureOptionFallback.Enabled(instance));
var container = builder.Build();
var options = container.Resolve<IFeatureOptionRepository>();
var features = new FeatureToggle(options);
var names =
ImmutableList<Selector>
.Empty
.AddFrom<DemoFeatures>()
.AddFrom<DatabaseFeatures>()
.Where<TagsAttribute>("io")
.Format();
features.Options.Batch(names, FeatureOption.Enabled, BatchOption.Remove);
features.Options.SaveChanges();
Assert.True(features.Switch(DemoFeatures.Greeting, true, false));
Assert.True(features.Switch(DemoFeatures.ReadFile, false, true));
Assert.True(features.Switch(DatabaseFeatures.Commit, false, true));
}
[Fact]
public void Can_undo_feature_toggle()
{
var featureToggle = new FeatureToggle(new FeatureOptionRepository()).DecorateWith<IFeatureToggle>(instance => new FeatureToggler(instance));
Assert.False(featureToggle.IsEnabled("test")); // it disabled by default
featureToggle.EnableToggler("test"); // activate feature-toggler
Assert.True(featureToggle.Switch("test", false, true)); // it's still disabled and will now switch
Assert.True(featureToggle.IsEnabled("test")); // now it should be enabled
Assert.True(featureToggle.Switch("test", true, false));
Assert.True(featureToggle.Switch("test", true, false));
Assert.True(featureToggle.IsEnabled("test")); // now it should be still be enabled because it was a one-time-switch
}
Real-world example
I currently use the FeatureToggle
in an email-seding web-service. So, in Startup.cs
I register all components with Autofac
:
public class Startup { public IServiceProvider ConfigureServices(IServiceCollection services) { // .. return AutofacLifetimeScopeBuilder .From(services) .Configure(builder => { builder .RegisterType<FeatureOptionRepository>() .As<IFeatureOptionRepository>() .SingleInstance(); builder .RegisterDecorator<IFeatureOptionRepository>( (context, parameters, repository) => new FeatureOptionFallback.Enabled(repository, FeatureOption.Telemetry)); builder .RegisterType<FeatureToggle>() .As<IFeatureToggle>() .SingleInstance(); builder .RegisterDecorator<FeatureToggler, IFeatureToggle>(); builder .RegisterDecorator<FeatureTelemetry, IFeatureToggle>(); }) .ToServiceProvider(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { // .. } }
APIs (actions) for sending emails are decorated with the [SendEmail]
filter. It checks if the query-string isDesignMode
was specified and if so, it then deactivates the feture Features.SendEmail[GUID]
for this particular email because each email has a GUID
that I can add to the name as an [Index]
.
public class SendEmail : ActionFilterAttribute { private readonly ILogger _logger; private readonly IFeatureToggle _featureToggle; public SendEmail(ILogger<SendEmail> logger, IFeatureToggle featureToggle) { _logger = logger; _featureToggle = featureToggle; } public override void OnActionExecuting(ActionExecutingContext context) { if (context.ActionArguments.Values.OfType<IEmail>().SingleOrDefault() is var email && !(email is null)) { context.HttpContext.Items.SetItem(HttpContextItems.Email, email); context.HttpContext.Items.SetItem(HttpContextItems.EmailTheme, email.Theme); if (bool.TryParse(context.HttpContext.Request.Query[QueryStringNames.IsDesignMode].FirstOrDefault(), out var isDesignMode)) { if (isDesignMode) { // name: Features.SendEmail[GUID] _featureToggle.With( Features.SendEmail.Index(email.Id), f => f .Remove(FeatureOption.Enabled) // disable this feature for this email .Set(FeatureOption.Toggle) // enable toggle .Set(FeatureOption.ToggleOnce) // toggle only once .Set(FeatureOption.ToggleReset) // reset options back to default ); } } } } }
Finally, the middleware that does the actual job and sends emails, uses the FeatureToglge
to either send it or leave it. FeatureTelemetry
takes care logging and FeatueOptionFallback
enables this feature by default for all emails.
await _featureToggle.ExecuteAsync<IResource> ( name: Features.SendEmail.Index(email.Id), body: async () => await _mailProvider.SendEmailAsync(smtpEmail, requestContext) );
Questions
I believe, I managed to implement most of your suggestions and like this version much better now. What do you think? Is this more future-proof, easier to use and extendable than before? Is there anything that still could be made better?
public static implicit operator FeatureIdentifier(string name)
: when you provide this operator, haven't you then lost the benefits/strength that was the meaning by introducingFeatureIdentifier
? \$\endgroup\$.As()
it should be.As<T>()
. I changed the formatting to use html element instead of backticks because of linebreaks and mixing<
with html breaks everything. I'll need to escape this code... \$\endgroup\$