A feature flag or 'feature toggle' is a common technique to enable or disable application features dynamically. For example, feature flags enable product owners to turn on and off features during runtime of the application. Certain features may be turned on/off for given environments, users, or regions only. This way features may be A-B tested, or tested with a given percentage of users, or different countries of the world. It can also provide a solution to meet regional restrictions of different countries.
In this post I investigate integrating ConfigCat's feature management with an ASP.NET Core 6 web API service and the Options<T>
pattern. I will also focus on using the AutoPolling mechanism built into the ConfigCat client library, to refresh feature flags' state during application runtime.
Throughout my carrier I have used some sort of a feature flag solution. In some cases, this was a conscious decision built upon a well-designed application architecture, while in other cases it was just an
if
statement with a key-value pair in the configuration file. However, I have not yet encountered such a complete service as the one provided by ConfigCat.
I will focus on using feature flags solution within web services. Although, I see an even bigger need for such a robust solution in desktop applications. Managing the configuration of a few web service instances is inherently simpler to manage hundreds or thousands of desktop applications, which is common in the enterprise world.
Implementation Options
Feature flags can be leveraged in applications many ways. In the past, when computer networks were less ubiquitous, feature flags were typically implemented as compiler directives. This way a given code path was compiled into the application or remained as commented out section. The advantage of this solution is less branching and smaller code size. Although to 'toggle' a feature a new compilation of the source code is required, which makes this solution less dynamic. Users would need to uninstall/install or upgrade their application with the new binaries to get a feature toggled.
Today the most common technique is branching by if
statements. If the features flag is in enabled state a certain code path of the application is executed. For example, when a button is clicked to start an order processing, if a given feature flag enabled an SMS is also sent to the user. One could express this as:
// ...
ProcessOrder();
var isSmsFeatureEneabled = client.GetValue("sendSMS", false);
if(isSmsFeatureEneabled)
SendSms();
// ...
Another approach would be to leverage branching by abstractions. As this is a larger topic, I am not detailing it within this post, exploring this area may worth its own writing.
One very recent feature flag I encountered comes from .NET itself: using the HTTP3 preview feature in HttpClient
requires the developers to proactively enable the feature by setting the <EnablePreviewFeatures>True</EnablePreviewFeatures>
flag in the csproj file.
ConfigCat and Asp.Net Core and .NET 6
Using the ConfigCat's service does not restrict us choosing any of implementation techniques, although one would probably not choose to use compiler directives. In this section I show how one can integrate the ConfigCat's configuration with the Option<T>
pattern of .NET 6. I am using a late preview version of .NET6 at the time of writing this post. DotNet 6 provides a new configuration concept with ConfigurationManager
type, which is not available in the previous versions. ConfigurationManager
allows to initialize configuration sources while using configuration values of previously initialized sources. It achieves this by implementing IConfigurationBuilder, IConfigurationRoot, IConfiguration
interfaces at the same time.
One consideration to make is that no user specific feature flag will be used, which means that in this implementation flags will not be respected if set for specific users or 'target % users' on the ConfigCat's portal. In all cases the 'To all users' value of the feature flag is used.
In general, when using Options<T>
pattern, there is no good API to query user specific settings, and in a web application used by multiple users, there is also no effective way to fetch flags for one or a few users during application startup. Thus, all feature flags shall be independent of users when being registered with Options. We can leverage though non-user specific information i.e. semantic version of the application is greater than 1.2.3. I will leave it up for the reader to extend the presented solution with such extensions.
Let me first preview the whole 'startup' code and the action of the service, then describe the necessary types I created for the solution. Here is Program.cs:
using ConfigCat.Client;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddConfigCat(false);
builder.Services.Configure<FeatureSet>(builder.Configuration.GetSection(nameof(FeatureSet)));
var app = builder.Build();
app.UseHttpsRedirection();
app.MapGet("api/feature", async (HttpContext context, IOptionsSnapshot<FeatureSet> features) =>
{
if (features.Value.GrandFeature)
{
context.Response.StatusCode = StatusCodes.Status200OK;
await context.Response.WriteAsync("Hello World!");
}
else
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
}
});
app.Run();
public class FeatureSet
{
public bool GrandFeature { get; set; }
}
This web API has a single GET endpoint api/feature
. The response depends on a feature flag: GrandFeature
. When the feature is turned on, it returns HTTP 200 OK, with Hello World!
as the content. When the feature is turned off, it returns 404 Not Found. The state of the feature flag is accessed through IOptionsSnapshot<FeatureSet> features
, which I will explain later in this post.
The builder.Configuration.AddConfigCat(false);
uses a custom extension method to add the toggle values of ConfigCat to the Asp.Net Core's configuration.
The second line builder.Services.Configure<FeatureSet>(builder.Configuration.GetSection(nameof(FeatureSet)));
sets up the feature flags with the Options<T>
pattern. This is the standard way to bind a given section of the configuration to a type, while also registering the type with the DI container. Here, I bind the configuration to a type called FeatureSet
which has a single boolean property GrandFeature.
Let's investigate the custom extension method. The upcoming code focuses on getting the configuration values of ConfigCat into Asp.Net's configuration. Below the extension method uses ConfigurationManager
to read the ConfigCat API key and poll interval settings from the 'appsettings.json' file. These configuration values are added by Asp.Net web application's file provider during startup. In production, one would prefer to pass the ConfigCat key as a secret or as an environment variable. In either case a configuration source would set the value before AddConfigCat
is invoked. As '0' is an invalid interval for polling, the extension method validates it. The method has an optional parameter which indicates the desired behavior when ConfigCat cannot fetch the feature flags, while onError parameter is an action that is invoked in case of an exception. Feature flags are fetched from the service periodically, the onError parameter provides a way to observe errors during the background polls.
public static class Extensions
{
public static IConfigurationBuilder AddConfigCat(
this ConfigurationManager manager,
bool optional = false,
Action<Exception>? onError = null)
{
var key = manager["ConfigCat:Key"] ?? throw new ArgumentNullException("ConfigCat:Key");
var pollInterval = manager.GetValue<TimeSpan>("ConfigCat:PollInterval");
if (pollInterval == TimeSpan.Zero)
throw new ArgumentNullException("ConfigCat:PollInterval");
var options = new ConfigCatOptions(key, pollInterval, optional, onError);
if (manager is IConfigurationBuilder builder)
builder.Add(new ConfigCatConfigurationSource(options));
return manager;
}
}
public record ConfigCatOptions(string Key, TimeSpan RefreshInterval, bool IsOptional, Action<Exception>? OnError);
ConfigCatOptions
record type is encapsulating the parameters for ConfigCatConfigurationProvider
.
The next type is ConfigCatConfigurationSource
. An IConfigurationSource
is required to be implemented as this is the type added to the configuration sources. The responsibility of the type is to create an IConfigurationProvider
. With .NET6 when a configuration provider is removed or modified, all the remaining sources are rebuilt. This implementation returns a lazily instantiated ConfigCatConfigurationProvider
instance. I use the singleton semantics because auto polling built into the ConfigCatConfigurationProvider
refreshes the configuration automatically.
public class ConfigCatConfigurationSource : IConfigurationSource
{
private readonly ConfigCatOptions _options;
private ConfigCatConfigurationProvider? _provider;
public ConfigCatConfigurationSource(ConfigCatOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public IConfigurationProvider Build(IConfigurationBuilder builder) =>
_provider ??= new ConfigCatConfigurationProvider(_options);
}
Another use case could be when the feature flags are read only at application startup. In certain applications this could be a valid scenario. For this, manual polling would be a better choice, and creating a new instance of
ConfigCatConfigurationProvider
on everyBuild()
method invocation would also make sense.
The last and most complex class to implement is ConfigCatConfigurationProvider
. This type derives from ConfigurationProvider
which already implements many of the IConfigurationProvider
interface members. Here, I only override the Load()
method, which is invoked by the Host right after the configuration provider is instantiated. In the first invocation I create a new ConfigCatClient
, and because AutoPollConfiguration
uses a Timer
to load configuration data asynchronously, a task completion source must be waited. Unfortunately, the method signature does not allow to use the await
keyword. Without waiting for the task completion source, further providers would not be able to read the data set by this provider.
public class ConfigCatConfigurationProvider : ConfigurationProvider, IDisposable
{
private readonly ConfigCatOptions _options;
private readonly AutoPollConfiguration _polling;
private readonly TaskCompletionSource _initialLoad;
private IConfigCatClient? _configCatClient;
public ConfigCatConfigurationProvider(ConfigCatOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_polling = new AutoPollConfiguration
{
SdkKey = _options.Key ?? throw new ArgumentNullException(nameof(_options.Key)),
PollIntervalSeconds = (uint)_options.RefreshInterval.TotalSeconds,
};
_initialLoad = new TaskCompletionSource();
_polling.OnConfigurationChanged += OnConfigurationChanged;
}
private void OnConfigurationChanged(
object sender,
OnConfigurationChangedEventArgs eventArgs)
{
LoadData();
_initialLoad.TrySetResult();
}
public void Dispose() { }
public override void Load()
{
_configCatClient ??= new ConfigCatClient(_polling);
_initialLoad.Task.GetAwaiter().GetResult();
}
public void LoadData()
{
try
{
Data = ParseKeys();
}
catch (Exception ex)
{
if (_options.IsOptional)
Data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (_options.OnError is { })
_options.OnError.Invoke(ex);
else
throw;
}
OnReload();
}
private IDictionary<string, string> ParseKeys()
{
if (_configCatClient == null)
throw new InvalidOperationException(nameof(_configCatClient));
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in _configCatClient.GetAllKeys())
{
var value = _configCatClient.GetValue(key, string.Empty);
result.Add(key.Replace('_', ':'), value);
}
return result;
}
}
Once ConfigCatClient
has loaded the data, the OnConfigurationChanged
event is fired. This is when all key value pairs are loaded. LoadData()
and ParseKeys()
methods read and parse the keys and corresponding values. The result dictionary is set in the Data property, which is declared by the base class. The only additional logic applied here is to replace the underscore characters with semicolons. This is done, as the ':' character is unsupported in key names, so to deal with the hierarchy of configuration values, another character must be used for the ConfigCat feature names. Using the '_' character resembles a similar behavior to using configuration values with environment variables.
Note, that LoadData()
method invokes a method from the base type: OnReload();
. This will generate a new change token signaling the configuration provider that the configuration values have changed. The values of options might change due to the built-in auto-polling mechanism, however the OnConfigurationChanged
event is only fired when the values have changed.
To read the latest values of configuration while serving the HTTP request, an IOptionsSnapshot<FeatureSet>
is passed to the GET request's action handler. This type is useful in scenarios where options should be recomputed on every request.
Conclusion
In one way or another an aging, but still maintained application requires a solution for feature flags. The more robust this solution is the more choice is given to the development team to isolate certain preview features to certain users. Implementing a custom feature flag solution does not usually provide a competitive advantage, using service built for the purpose makes sense. In this regards, ConfigCat's solution seems a reasonable choice for my next project.
Top comments (0)