2020年12月31日 星期四

[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 : AttributeIAsyncResourceFilter
{
    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<AppSettingsoptions,
        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 { getset; }

    /// <summary>
    /// OnResourceExecutionAsync
    /// </summary>
    /// <param name="context">ResourceExecutingContext</param>
    /// <param name="next">ResourceExecutionDelegate</param>
    public async Task OnResourceExecutionAsync(ResourceExecutingContext contextResourceExecutionDelegate 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<IActionResultDemo()
{}

 

 

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<IActionResultDemo()
{}

 

 

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<IActionResultTestDisableApiFilter()
    {
        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 : AttributeIAsyncActionFilter
{
    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 contextActionExecutionDelegate next)
    {
        await next();

        string header = "Server-Env";
        context.HttpContext.Response.Headers.Remove(header);
        context.HttpContext.Response.Headers.Add(headerthis.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 FlagsFeature 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<stringFeatureDefinition> 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<RemoteFeatureDefinitionProviderlogger,
        IHttpClientFactory httpClientFactory,
        IMemoryCache memoryCache)
    {
        this.logger = logger;
        this.httpClientFactory = httpClientFactory;
        this.memoryCache = memoryCache;
        this.definitions = new ConcurrentDictionary<stringFeatureDefinition>();
    }

    /// <summary>
    /// Get all feature definitions
    /// </summary>
    /// <returns>Async enumerable of FeatrueDefinition</returns>
    public async IAsyncEnumerable<FeatureDefinitionGetAllFeatureDefinitionsAsync()
    {
        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<FeatureDefinitionGetFeatureDefinitionAsync(string featureName)
    {
        if (featureName == null)
        {
            throw new ArgumentNullException(nameof(featureName));
        }

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

    private async Task reloadConfiguration()
    {
        string remoteApiUri = this.appSettings.FeatureManagementUri;
        if (!this.memoryCache.TryGetValue(CacheKeys.FeatureConfigout 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.FeatureConfigthis.configurationTimeSpan.FromSeconds(CACHE_TIME));
        }
        else
        {
            this.configuration = featureConfig;
        }
    }

    private FeatureDefinition readFeatureDefinition(string featureName)
    {
        var configurationSection = this.getFeatureDefinitionSections().FirstOrDefault(section => section.Key.Equals(featureNameStringComparison.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(valout 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.Keyout 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.KeyEnabledFor = enabledFor };
    }

    private IEnumerable<IConfigurationSectiongetFeatureDefinitionSections()
    {
        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<IFeatureDefinitionProviderRemoteFeatureProvider>()
            .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 FlagsFeature Toggle)的設定