4
\$\begingroup\$

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 previous string token as a feature-key
  • I created decorators for each of the features of the previous god-class FeatureToggle
    • FeatureToggle - executes body when a feature is ON or fallback when it's OFF
    • FeatureToggler - can automatically toggle between ON/OFF after each execution or only once with ToggleOnce option
    • FeatureTelemetry - 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 features
    • FeatureOptionRepository - stores feature options in a dictionary and saves feature-option changes by setting Saved flag and removing Dirty. I added ReaderWriterLockSlim to it because I'm using it with a web-service that toggles features at runtime based on the request
    • FeatureOptionRepositoryDecorator - simplifies creating decorators by providing default and overridable implementations
    • FeatureOptionFallback - provides default feature-option when the current one is None
    • 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?

\$\endgroup\$
  • 1
    \$\begingroup\$ also on github + tests \$\endgroup\$ – t3chb0t Jul 28 at 10:37
  • 3
    \$\begingroup\$ 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 introducing FeatureIdentifier? \$\endgroup\$ – Henrik Hansen Jul 28 at 16:44
  • 1
    \$\begingroup\$ @HenrikHansen I've added a real-world example. \$\endgroup\$ – t3chb0t Jul 29 at 6:41
  • 2
    \$\begingroup\$ @t3chb0t - I have no idea why, but the "<--" in your C# comments appeared to be the reason the formatting was broken. I've removed them for you :) \$\endgroup\$ – RobH Jul 29 at 7:36
  • 1
    \$\begingroup\$ @RobH thanks! This makes sense now as the generic arguments in the first snippet are gone too .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\$ – t3chb0t Jul 29 at 7:40

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.