Thursday, December 31, 2020

[ASP.NET Core] Feature Toggle

  ASP.NET Core   Feature toggle   Resource filter  


 

 

▌Introduction


 

While we have more and more features (APIs…) and environments (SIT, Staging…), we want to enable/disable certain features on specific environments by feature toggle.

 

This article shows how to do feature toggle by two ways

 

l   Implement IAsyncResourceFilter

l   Use official package: Microsoft.FeatureManagement.AspNetCore

 

The full sample code is in my Github: karatejb/AspNetCore.Filters.Sample

 

 

▋Related articles

1.  [MVC] Pass parameters to ActionFilterAttribute

2.  [ASP.Net Core] Action Filter with Parameter(s)

3.  [ASP.NET Core] Hybrid Filter

4.  [ASP.NET Core] Feature toggle

 

 

 

▌Environment


 

▋dotnet core sdk 3.1.300

 

 

 

▌Custom Resource Filter


 

The following example shows how to disable APIs that depends on the environment value of ASPNETCORE_ENVIRONMENT.

 

 

▋Implement IAsyncResourceFilter

 

Resource filters will be after Authorization filters and before Action filters and model binding, so we can implement IAsynResourceFilter to intercept the request and return 404(Not found) immediately.

 

 

▋DisableApiFilter.cs

public class DisableApiFilter : Attribute, IAsyncResourceFilter
{
    private readonly string env = string.Empty;

    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="hostEnvironment">IWebHostEnvironment</param>
    /// <param name="options">IOptions</param>
    /// <param name="onEnv">Regex for checking ASPNETCORE_ENVIRONMENT</param>
    public DisableApiFilter(
        IWebHostEnvironment hostEnvironment,
        IOptions<AppSettings> options,
        string onEnv = "")
    {
        this.env = hostEnvironment.EnvironmentName;
        this.OnEnv = string.IsNullOrEmpty(onEnv) ? options.Value?.EnvForDisableApiFilter : onEnv;
    }

    /// <summary>
    /// Regression expression of ASPNETCORE_ENVIRONMENT for disabling API
    /// </summary>
    public string OnEnv { get; set; }

    /// <summary>
    /// OnResourceExecutionAsync
    /// </summary>
    /// <param name="context">ResourceExecutingContext</param>
    /// <param name="next">ResourceExecutionDelegate</param>
    public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
    {
        if (string.IsNullOrEmpty(this.OnEnv))
        {
            await next();
        }
        else
        {
            var regex = new Regex(this.OnEnv);

            if (regex.IsMatch(this.env))
            {
                context.Result = new EmptyResult();
                context.HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
            }
            else
            {
                await next();
            }
        }
    }
}

 

▋Use DisabelApiFilter by setting regex pattern

 

The variable: OnEnv, is the parameter of the filter that supports Regular Expression.

When environment variable: `ASPNETCORE_ENVIRONMENT` matches the OnEnv, the filter will force the API to return 404.

 

For example, the following API will response 404 when ASPNETCORE_ENVIRONMENT's value contains “Production” or “production”.

[TypeFilter(typeof(DisableApiFilter), Arguments = new object[] { "^(.*)[Pp]roduction(.*)$" })]
[HttpGet]
public async Task<IActionResult> Demo()
{}

 

 

▋Use DisableApiFilter by default regex pattern

 

We can set a default regex pattern to be checked in DisableApiFilter when parameter OnEnv is not specified.

 

▋appsettings.json

{
  "EnvForDisableApiFilter": "^(.*)[Pp]roduction(.*)$",
}

 

And we can disable API on demand environment(s) like this,

[TypeFilter(typeof(DisableApiFilter))]
[HttpGet]
public async Task<IActionResult> Demo()
{}

 

 

▌Microsoft.FeatureManagement.AspNetCore


 

Github: microsoft/FeatureManagement-Dotnet

 

This official package supports Feature toggle dynamically.

 

 

▋Install Nuget package

 

Microsoft.FeatureManagement.AspNetCore

 

 

▋Feature Management Configuration

 

Define the key: FeatureManagement in appsettings.json like following,

▋appsettings.json

"FeatureManagement": {
    "ApiDoc": false,
    "Demo": true,
    "DemoGlobalFilter": true,
    "Tests": true,
    "ServerEnvHeader": false
} 

 

 

We can create an enum for later usage.

public enum FeatureFlags
{
    ApiDoc = 1,
    Demo,
    DemoGlobalFilter,
    Tests,
    ServerEnvHeader
}

 

 

▋Enable Feature Management

 

▋Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    // Add feature mangement services 
    services .AddFeatureManagement();

    services .AddControllers() .AddNewtonsoftJson();
}

 

▋Feature toggle by Attribute

 

Now we can set feature toggle on API (Controller or Action) with FeatureGateAttribute.

 

For example, the API will return 404 if we set `false` on the mapping feature flag on appsettings.json.

[FeatureGate(FeatureFlags.Demo)]
[Route("api/[controller]")]
[ApiController]
public class DemoController : ControllerBase
{ }

 

 

▋ Feature toggle by checking flags from IFeatureManager

 

We can also use the injected Microsoft.FeatureManagement.IFeatureManager instance to get the value of feature flag.

[ApiController]
public class DemoController : ControllerBase
{
    private readonly IFeatureManager featureManager = null;

    public DemoController(IFeatureManager featureManager)
    {
        this.featureManager = featureManager;
    }

    [HttpGet]
    public async Task<IActionResult> TestDisableApiFilter()
    {
        if (await featureManager.IsEnabledAsync(nameof(FeatureFlags.Tests)))
        {
            return this.Ok();
        }
        else 
        {
            return StatusCode(StatusCodes.Status404NotFound);
        }
    }
}

 

This is useful when we can control enabling certain features on startup of application.

For example, we can toggle API Document, like Swagger, with an extension method,

 

▋IApplicationBuilderExtensions.cs

public static IApplicationBuilder UseToggleFeatures(this IApplicationBuilder app)
{
    var featureManager = app.ApplicationServices.GetService<Microsoft.FeatureManagement.IFeatureManager>();
    bool isEnableApiDoc = featureManager.IsEnabledAsync(nameof(FeatureFlags.ApiDoc)).Result;

    if (isEnableApiDoc)
    {
        // Enable something like Swagger, etc...
    }

    return app;
} 


And apply it in Startup.cs,

public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        // Use toggle features
        app.UseToggleFeatures();
    }
}

 

 

▋Enable/Disable global filter

 

We can also control the global filter(s) by Feature Management.

For example, there is a custom filter that will put the environment information to every HTTP response header,

 

▋CustomHeaderFilter.cs

public class CustomHeaderFilter : Attribute, IAsyncActionFilter
{
    private readonly string env;

    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="hostEnvironment">IWebHostEnvironment</param>
    public CustomHeaderFilter(IWebHostEnvironment hostEnvironment)
    {
        this.env = hostEnvironment.EnvironmentName;
    }

    /// <summary>
    /// OnActionExecutionAsyn
    /// </summary>
    /// <param name="context">ActionExecutingContext</param>
    /// <param name="next">ActionExecutionDelegate</param>
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        await next();

        string header = "Server-Env";
        context.HttpContext.Response.Headers.Remove(header);
        context.HttpContext.Response.Headers.Add(header, this.env);
    }

} 

 

 

We can enable/disable it as the global filter as following,

 

▋Startup.cs

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddFeatureManagement();

        // Add the global IAsyncActionFilter by feature toggle
        services .AddControllers(o => o.Filters.AddForFeature<CustomHeaderFilter>(nameof(FeatureFlags.ServerEnvHeader)));
    }
}

 

When we turn on the feature flag, we will see the custom header on response.


 

 

 

 

▋Feature Management Configuration from other service

 

We can implement IFeatureDefinitionProvider to get the Feature Management Configuration from other service's API.

 

We will use json-server to have an API running on http://localhost:3000/feature-configuration, which will return the json configuration as below,


 

 

Now lets see how to load the configuration by implementing the custom IFeatureDefinitionProvider.

 

 

▋Implement IFeatureDefinitionProvider

 

We can take a look at the source code of ConfigurationFeatureDefinitionProvider.cs and try to create our custom one.

The sample code also refer to the following article: [料理佳餚] 實作 IFeatureDefinitionProvider 從外部的服務載入 ASP.NET Core Feature Flags(Feature Toggle)的設定.

 

 

·         RemoteFeatureDefinitionProvider.cs

 

The feature management definitions will be load from remote API and cache for a period of time in memory cache.

public class RemoteFeatureDefinitionProvider : IFeatureDefinitionProvider
{
    private const string FEATURE_MANAGEMENT_SECTION = "FeatureManagement";
    private const string FFEATURE_FILTER_SECTION = "EnabledFor";
    private const int CACHE_TIME = 180; // Cache time in seconds
    private readonly ILogger<RemoteFeatureDefinitionProvider> logger;
    private readonly IHttpClientFactory httpClientFactory;
    private readonly IMemoryCache memoryCache;
    private ConcurrentDictionary<string, FeatureDefinition> definitions;
    private IConfiguration configuration;

    /// <summary>
    /// Custructor
    /// </summary>
    /// <param name="logger">Logger</param>
    /// <param name="httpClientFactory">HttpClientFactory</param>
    /// <param name="memoryCache">Memory cache</param>
    public RemoteFeatureDefinitionProvider(
        ILogger<RemoteFeatureDefinitionProvider> logger,
        IHttpClientFactory httpClientFactory,
        IMemoryCache memoryCache)
    {
        this.logger = logger;
        this.httpClientFactory = httpClientFactory;
        this.memoryCache = memoryCache;
        this.definitions = new ConcurrentDictionary<string, FeatureDefinition>();
    }

    /// <summary>
    /// Get all feature definitions
    /// </summary>
    /// <returns>Async enumerable of FeatrueDefinition</returns>
    public async IAsyncEnumerable<FeatureDefinition> GetAllFeatureDefinitionsAsync()
    {
        await this.reloadConfiguration();

        foreach (var featureSection in this.getFeatureDefinitionSections())
        {
            yield return this.definitions.GetOrAdd(featureSection.Key, _ => this.readFeatureDefinition(featureSection));
        }
    }

    /// <summary>
    /// Get single feature definition
    /// </summary>
    /// <param name="featureName">Feature name</param>
    /// <returns>FeatureDefinition</returns>
    public async Task<FeatureDefinition> GetFeatureDefinitionAsync(string featureName)
    {
        if (featureName == null)
        {
            throw new ArgumentNullException(nameof(featureName));
        }

        await this.reloadConfiguration();
        var definition = this.definitions.GetOrAdd(featureName, name => this.readFeatureDefinition(name));
        return definition;
    }

    private async Task reloadConfiguration()
    {
        string remoteApiUri = this.appSettings.FeatureManagementUri;
        if (!this.memoryCache.TryGetValue(CacheKeys.FeatureConfig, out IConfiguration featureConfig))
        {
            this.definitions.Clear();

            using var httpClient = this.httpClientFactory.CreateClient();
            var streamResponse = await httpClient.GetStreamAsync("http://localhost:3000/feature-management"); // Use json-server to run the json file at /AspNetCore.Filters.WebApi/Assets/feature_management.json
            this.configuration = new ConfigurationBuilder().AddJsonStream(streamResponse).Build();

            this.memoryCache.Set(CacheKeys.FeatureConfig, this.configuration, TimeSpan.FromSeconds(CACHE_TIME));
        }
        else
        {
            this.configuration = featureConfig;
        }
    }

    private FeatureDefinition readFeatureDefinition(string featureName)
    {
        var configurationSection = this.getFeatureDefinitionSections().FirstOrDefault(section => section.Key.Equals(featureName, StringComparison.OrdinalIgnoreCase));

        if (configurationSection == null)
        {
            return null;
        }

        return this.readFeatureDefinition(configurationSection);
    }

    private FeatureDefinition readFeatureDefinition(IConfigurationSection configurationSection)
    {
        var enabledFor = new List<FeatureFilterConfiguration>();

        var val = configurationSection.Value;

        if (string.IsNullOrEmpty(val))
        {
            val = configurationSection[FFEATURE_FILTER_SECTION];
        }

        if (!string.IsNullOrEmpty(val) && bool.TryParse(val, out bool result) && result)
        {
            enabledFor.Add(new FeatureFilterConfiguration { Name = "AlwaysOn" });
        }
        else
        {
            var filterSections = configurationSection.GetSection(FFEATURE_FILTER_SECTION).GetChildren();

            foreach (var filterSection in filterSections)
            {
                if (int.TryParse(filterSection.Key, out int i) && !string.IsNullOrEmpty(filterSection[nameof(FeatureFilterConfiguration.Name)]))
                {
                    enabledFor.Add(
                        new FeatureFilterConfiguration
                        {
                            Name = filterSection[nameof(FeatureFilterConfiguration.Name)],
                            Parameters = filterSection.GetSection(nameof(FeatureFilterConfiguration.Parameters))
                        });
                }
            }
        }

        return new FeatureDefinition { Name = configurationSection.Key, EnabledFor = enabledFor };
    }

    private IEnumerable<IConfigurationSection> getFeatureDefinitionSections()
    {
        bool isExist = this.configuration.GetChildren().Any(x => x.Key.Equals(FEATURE_MANAGEMENT_SECTION));
        if (isExist)
            return this.configuration.GetSection(FEATURE_MANAGEMENT_SECTION).GetChildren();
        else
            return this.configuration.GetChildren();
    }
}

 

 

▋Register the custom RemoteFeatureDefinitionProvider


Last step, we have to register the necessary services and RemoteFeatureDefinitionProvider on Starup.cs.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<AppSettings>(this.Configuration);
        services.AddHttpClient();
        services.AddMemoryCache();

        // Add feature mangement services 
        services
            .AddSingleton<IFeatureDefinitionProvider, RemoteFeatureProvider>()
            .AddFeatureManagement();

        services.AddControllers().AddNewtonsoftJson();
    }
}

 

And don't forget to remove the FeatureManagement settings from appsettings.json!


 

▌Reference


 

▋microsoft/FeatureManagement-Dotnet

▋[料理佳餚] 實作 IFeatureDefinitionProvider 從外部的服務載入 ASP.NET Core Feature Flags(Feature Toggle)的設定

 

 

 

 

No comments:

Post a Comment