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

上一篇提到我們可以把 ASP.NET Core Feature Flags 的設定,儲存在 Azure App Configuration(應用程式組態)裡面,現在再進階一點,我們可以實作 IFeatureDefinitionProvider 建立一個 Feature Definition Provider,讓 Feature Flags 的設定可以儲存在我們想要的地方。

Feature Definition 依賴 Microsoft.Extensions.Configuration 套件,所以我們要自製 Feature Definition Provider 相對單純,只要能夠提供 ConfigurationBuilder 可以支援的格式就行了。

接下來,我就將 Feature Flags 的設定存放在一個 my-settings.json 檔案,檔案內容如下:

{
  "FeatureManagement": {
    "FeatureA": {
      "EnabledFor": [
        {
          "Name": "Microsoft.TimeWindow",
          "Parameters": {
            "Start": "2020-11-23 10:00:00Z",
            "End": "2020-11-23 11:00:00Z"
          }
        }
      ]
    }
  }
}

並且,在自己的專案上建立一個 /feature-management-settings 的 Web Api,讀取 my-settings.json 檔案的內容並回傳。

[HttpGet("/feature-management-settings")]
public IActionResult FeatureManagementSettings()
{
    return this.File(System.IO.File.OpenRead(@"E:\my-settings.json"), "application/json");
}

實作 IFeatureDefinitionProvider

再來就是重點了,我們建立一個 CustomFeatureProvider 類別,實作 IFeatureDefinitionProvider 介面,程式碼內容如下:

public class CustomFeatureProvider : IFeatureDefinitionProvider
{
    private const string CacheKey = "CustomFeatureProvider";
    private const string FeatureFiltersSectionName = "EnabledFor";
    private readonly IHttpClientFactory httpClientFactory;
    private readonly IMemoryCache memoryCache;
    private readonly SemaphoreSlim locker = new SemaphoreSlim(1, 1);
    private readonly ConcurrentDictionary<string, FeatureDefinition> definitions;
    private IConfiguration configuration;

    public CustomFeatureProvider(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache)
    {
        this.httpClientFactory = httpClientFactory;
        this.memoryCache = memoryCache;

        this.definitions = new ConcurrentDictionary<string, FeatureDefinition>();
    }

    public async Task<FeatureDefinition> GetFeatureDefinitionAsync(string featureName)
    {
        await this.TryReloadConfiguration();

        var definition = this.definitions.GetOrAdd(featureName, name => this.ReadFeatureDefinition(name));

        return definition;
    }

    public async IAsyncEnumerable<FeatureDefinition> GetAllFeatureDefinitionsAsync()
    {
        await this.TryReloadConfiguration();

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

    private async Task TryReloadConfiguration()
    {
        if (!this.memoryCache.TryGetValue(CacheKey, out _))
        {
            await this.locker.WaitAsync();

            if (this.memoryCache.TryGetValue(CacheKey, out _))
            {
                this.locker.Release();
            }
            else
            {
                using (var httpClient = this.httpClientFactory.CreateClient())
                {
                    var response = await httpClient.GetStreamAsync("http://localhost:5000/feature-management-settings");

                    this.configuration = new ConfigurationBuilder().AddJsonStream(response).Build();
                }

                this.definitions.Clear();

                this.memoryCache.Set(CacheKey, new object(), TimeSpan.FromMinutes(1));

                this.locker.Release();
            }
        }
    }

    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[FeatureFiltersSectionName];
        }

        if (!string.IsNullOrEmpty(val) && bool.TryParse(val, out bool result) && result)
        {
            enabledFor.Add(new FeatureFilterConfiguration { Name = "AlwaysOn" });
        }
        else
        {
            var filterSections = configurationSection.GetSection(FeatureFiltersSectionName).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()
    {
        const string FeatureManagementSectionName = "FeatureManagement";

        if (this.configuration.GetChildren().Any(s => s.Key.Equals(FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase)))
        {
            return this.configuration.GetSection(FeatureManagementSectionName).GetChildren();
        }
        else
        {
            return this.configuration.GetChildren();
        }
    }
}

關鍵在 TryReloadConfiguration() 這個方法,我利用 HttpClientFactory 及 MemoryCache 服務,來讓 Feature Flags 的設定每 1 分鐘重新載入一次,其他的部分就參考 ConfigurationFeatureDefinitionProvider.cs 原始碼內容。

最後,只要將需要的服務在 Startup.cs 註冊好,自製的 Feature Definition Provider 就完成了。

參考資料

C# 指南 ASP.NET 教學 ASP.NET MVC 指引
Azure SQL Database 教學 SQL Server 教學 Xamarin.Forms 教學