[料理佳餚] ASP.NET Core Feature Flags(Feature Toggle)的 Feature Filters

上一篇文章Microsoft.FeatureManagement.AspNetCore 這個套件做了一個最基本的介紹,但是 Feature 只能設定開啟或關閉,顯然地這有點不夠用,所以套件有提供了一些 Feature Filters 用來實作有條件地開啟或關閉 Feature。

目前套件內建有三個 Feature Filters,分別是:

PercentageFilter

顧名思義,PercentageFilter 就是讓某個 Feature 依照設定的機率開啟,要使用的話,首先我們就要在 Starup.cs 裡面把它加進來。

接著要到 appsettings.json 改用另一種結構設定,請參考下面:

{
  "FeatureManagement": {
    "FeatureA": {
      "EnabledFor": [
        {
          "Name": "Microsoft.Percentage",
          "Parameters": {
            "Value": 20
          }
        }
      ]
    }
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

其中 Name 填入的是 Filter 的 Alias,那 Filter 的 Alias 哪裡看? 在各個 Filter 的原始碼中都有寫,像 PercentageFilter 的原始碼就有寫它的 Alias 是 Microsoft.Percentage,所以我們去翻一下原始碼就可以知道了。

然後 Parameters 就看各個 FilterSettings 的原始碼,看看需要些什麼屬性? 像 PercentageFilterSettings 它只需要一個 Value,那我們就填入 Value 跟它的值。

這樣設定好後,FeatureA 就會依設定好的機率出現,像我設定機率是 20,表示有 20% 的機率 FeatureA 會被開啟。

TimeWindowFilter

TimeWindowFilter 是可以設定讓 Feature 在某一段時間內開啟,比如說我們的網站正在做活動,活動時間內要把活動頁面開啟,我們就可以考慮用 TimeWindowFilter 來做,要使用的話,我們一樣在 Startup.cs 把它加進來。

TimeWindowFilter 的原始碼TimeWindowFilterSettings 的原始碼得知,Alias 是 Microsoft.TimeWindow,以及需要 StartEnd 屬性。

{
  "FeatureManagement": {
    "FeatureB": {
      "EnabledFor": [
        {
          "Name": "Microsoft.TimeWindow",
          "Parameters": {
            "Start": "2020-11-09 04:58:00Z",
            "End": "2020-11-09 05:00:00Z"
          }
        }
      ]
    }
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

這樣一設定之後,FeatureB 就會在特定的時間內被開啟,而 Start 跟 End 必須填寫 DateTimeOffset 可以吃的格式。

TargetingFilter

TargetingFilter 是一個可以根據 TA(Target Audience)來設定 Feature 的開啟或關閉,而它會稍微複雜一點,從 TargetingFilter 的原始碼TargetingFilterSettings 的原始碼得知,Alias 是 Microsoft.Targeting,以及需要一個 Audience 的屬性。

Audience 又有三個屬性,分別是:UsersGroupsDefaultRolloutPercentage,而這三個屬性就是我們可以控制的三種 TA:特定的使用者特定的群組非特定的使用者,設定方式請參考下面:

{
  "FeatureManagement": {
    "FeatureC": {
      "EnabledFor": [
        {
          "Name": "Microsoft.Targeting",
          "Parameters": {
            "Audience": {
              "Users": [
                "Johnny",
                "Amy"
              ],
              "Groups": [
                {
                  "Name": "Group1",
                  "RolloutPercentage": 80
                },
                {
                  "Name": "Group2",
                  "RolloutPercentage": 50
                },
                {
                  "Name": "Group3",
                  "RolloutPercentage": 30
                }
              ],
              "DefaultRolloutPercentage": 10
            }
          }
        }
      ] 
    }
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

這樣設定的意思是說,Johnny 及 Amy 開啟 FeatureC,而 Group1 的使用者有 80% 的機率開啟 FeatureC、... 依此類推,最後預設其他使用者有 10% 的機率開啟 FeatureC,但是這樣設定還沒完,我們至少還需要實作 ITargetingContextAccessor 這個介面,用來取得使用者名稱,以及認定使用者的所屬群組,這樣 TargetingFilter 才會有作用。

public static class ClaimTypes
{
    public const string GroupName = "http://schemas.featureflagdemo.featuremanagement.microsoft.com/claims/groupname";
}

public class HttpContextTargetingContextAccessor : ITargetingContextAccessor
{
    private readonly IHttpContextAccessor httpContextAccessor;

    public HttpContextTargetingContextAccessor(IHttpContextAccessor httpContextAccessor)
    {
        this.httpContextAccessor = httpContextAccessor;
    }

    public ValueTask<TargetingContext> GetContextAsync()
    {
        var httpContext = this.httpContextAccessor.HttpContext;

        return new ValueTask<TargetingContext>(
            new TargetingContext
            {
                UserId = string.IsNullOrEmpty(httpContext.User.Identity.Name)
                             ? Guid.NewGuid().ToString()
                             : httpContext.User.Identity.Name,
                Groups = httpContext.User.Claims.Where(x => x.Type == ClaimTypes.GroupName).Select(x => x.Value).ToList()
            });
    }
}

最後在 Startup.cs 把該註冊的服務註冊一下、該加的服務加一加,就可以了。

有一點要注意的是,RolloutPercentage 主要是由 UserId 計算出來的,同一個 User 算出來的 RolloutPercentage 是一樣的,算出來之後再去跟設定的 RolloutPercentage 比,如果比設定的值小,Feature 就會開啟。

由於匿名的使用者因為沒有 UserId,所以針對匿名的使用者我們必須找一個替代的 UserId,那我這邊是產生一個 Guid 當成匿名使用者的 UserId。

自訂 FeatureFilter

如果內建的 FeatureFilter 不夠用,我們也是可以自己自訂的,我們就參考官方的範例來做一個 DeviceFilter,Feature 可以依據 Device 來開啟,所以我們要實作 IFeatureFilter 這個介面,以及建立一個 DeviceFilterSettings 的類別,然後 Alias 則是用 [FilterAlias] 這個 Attribute 標上去。

public class DeviceFilterSettings
{
    public List<string> Devices { get; set; }
}

[FilterAlias("Device")]
public class DeviceFilter : IFeatureFilter
{
    private static readonly Regex MobileB = new Regex(
        @"(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino",
        RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);

    private static readonly Regex MobileV = new Regex(
        @"1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-",
        RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);

    private readonly IHttpContextAccessor httpContextAccessor;

    public DeviceFilter(IHttpContextAccessor httpContextAccessor)
    {
        this.httpContextAccessor = httpContextAccessor;
    }

    public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
    {
        var settings = context.Parameters.Get<DeviceFilterSettings>() ?? new DeviceFilterSettings();

        var userDevice = GetDevice(this.httpContextAccessor.HttpContext.Request.Headers[HeaderNames.UserAgent]);

        if (settings.Devices.Any(device => device.Equals(userDevice, StringComparison.OrdinalIgnoreCase)))
        {
            return Task.FromResult(true);
        }

        return Task.FromResult(false);
    }

    private static string GetDevice(string userAgent)
    {
        if (string.IsNullOrEmpty(userAgent)) return "Desktop";
        if (MobileB.IsMatch(userAgent)) return "Mobile";
        if (MobileV.IsMatch(userAgent.Substring(0, 4))) return "Mobile";

        return "Desktop";
    }
}

接著,在 appsettings.json 把設定加上去。

{
  "FeatureManagement": {
    "FeatureD": {
      "EnabledFor": [
        {
          "Name": "Device",
          "Parameters": {
            "Devices": [
              "Desktop"
            ]
          }
        }
      ]
    },
    "FeatureE": {
      "EnabledFor": [
        {
          "Name": "Device",
          "Parameters": {
            "Devices": [
              "Mobile"
            ]
          }
        }
      ]
    }
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

最後,一樣在 Startup.cs 中把 DeviceFilter 加進來,這樣就大功告成了。

參考資料

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