学习

适用于 Redis 和 ASP.NET Core 的可配置滑动窗口限流中间件

让我们考虑一种情况(这可能是大多数情况),即我们有多个需要限流的端点;在这种情况下,将限流逻辑嵌入到路由本身的逻辑中并没有多大意义。相反,可以有一个中间件来拦截请求,并在请求到达适当的端点之前检查是否已达到限流。为了实现这一点,我们将专门为此目的构建一些中间件。通过一些简单的配置工作,我们将能够构建一个可以处理一组可配置限制的中间件。

先决条件#

启动 Redis#

在开始之前,请启动 Redis。在本示例中,我们将使用 Redis docker 镜像

docker run -p 6379:6379 redis

创建项目#

在你的终端中,导航到你希望应用所在的目录并运行

dotnet new webapi -n RateLimitingMiddleware --no-https

进入 RateLimitingMiddleware 文件夹并运行命令 dotnet add package StackExchange.Redis。

在 Rider、Visual Studio 中打开 RateLimitingMiddleware.csproj,或在 VS Code 中打开该文件夹。然后,在 Controllers 文件夹中,添加一个名为 RateLimitedController 的 API 控制器。完成后,RateLimitedController.cs 应该如下所示

namespace SlidingWindowRateLimiter.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class RateLimitedController : ControllerBase
    {
    }
}

创建配置对象#

现在是深入探讨这个中间件背后逻辑的时候了。我们首先应该考虑用于配置中间件的配置项。我们将把配置对象视为在应用配置中包含以下形式的内容

{
  "RedisRateLimits": [
    {
      "Path": "/api/ratelimited/limited",
      "Window": "30s",
      "MaxRequests": 5
    },
    {
      "PathRegex": "/api/*",
      "Window": "1d",
      "MaxRequests": 1000
    }
  ]
}

换句话说,我们有四个参数。

参数名

描述

路径

需限流的字面路径;如果路径完全匹配,将触发限流检查

路径正则表达式

需限流的路径正则表达式;如果路径匹配,将触发限流检查

窗口

限流的滑动窗口应匹配模式 `([0-9]+(s

m

d

h))`

最大请求数

在该时间段内允许的最大请求数

这些参数将存储在我们配置中的 RedisRateLimits 配置节点下。

构建配置对象#

我们将为此使用的配置对象将包含规则的逻辑和一些解析逻辑来处理从窗口模式解析超时。所以我们将创建一个新类,名为 RateLimitRule。在这个类中,我们将添加一个正则表达式来匹配我们的窗口模式:

public class RateLimitRule
{

}

时间正则表达式#

private static readonly Regex TimePattern = new ("([0-9]+(s|m|d|h))");

时间单位枚举#

然后我们将创建一个枚举,用于存储窗口大小的单位部分

private enum TimeUnit
{
    s = 1,
    m = 60,
    h = 3600,
    d = 86400
}

解析时间#

我们将以秒为单位测量时间窗口(因为这对 Redis 来说是最原生的),所以现在我们需要一个方法将我们的时间窗口转换为秒

private static int ParseTime(string timeStr)
{
    var match = TimePattern.Match(timeStr);
    if (string.IsNullOrEmpty(match.Value))
        throw new ArgumentException("Rate limit window was not provided or was not " +
                                    "properly formatted, must be of the form ([0-9]+(s|m|d|h))");
    var unit = Enum.Parse<TimeUnit>(match.Value.Last().ToString());
    var num = int.Parse(match.Value.Substring(0, match.Value.Length - 1));
    return num * (int) unit;
}

添加属性#

接下来,我们需要添加此类的属性,以便我们无需重复计算。我们将把 _windowSeconds 存储在一个单独的私有字段中:

public string Path { get; set; }
public string PathRegex { get; set; }
public string Window { get; set; }
public int MaxRequests { get; set; }
internal int _windowSeconds = 0;
internal string PathKey => string.IsNullOrEmpty(Path) ? Path : PathRegex;
internal int WindowSeconds
{
    get
    {
        if (_windowSeconds < 1)
        {
            _windowSeconds = ParseTime(Window);
        }
        return _windowSeconds;
    }
}

匹配路径#

最后,我们将对路径执行模式匹配

public bool MatchPath(string path)
{
    if (!string.IsNullOrEmpty(Path))
    {
        return path.Equals(Path, StringComparison.InvariantCultureIgnoreCase);
    }
    if (!string.IsNullOrEmpty(PathRegex))
    {
        return Regex.IsMatch(path, PathRegex);
    }
    return false;
}

编写我们的 Lua 脚本#

我们需要编写一个 Lua 脚本,该脚本将考虑适用于特定用户在特定端点上的所有规则。我们将使用有序集合来检查每个规则和用户的限流情况。在每个请求中,它将遍历每个适用的规则并

  1. 1.检查当前时间
  2. 2.删除超出窗口范围的条目
  3. 3.检查下一个请求是否违反规则
  • 如果请求将违反任何规则,则返回 1
  1. 1.对于每个适用的规则
  2. 2.向有序集合添加一个新条目,其分数为当前时间的秒数,成员名为当前时间的微秒数
  3. 3.返回 0

由于我们提前有不确定数量的规则,无法直接使用 StackExchange.Redis 库的功能,但我们仍然可以使用 Lua 脚本来实现这一点。

local current_time = redis.call('TIME')
local num_windows = ARGV[1]
for i=2, num_windows*2, 2 do
    local window = ARGV[i]
    local max_requests = ARGV[i+1]
    local curr_key = KEYS[i/2]
    local trim_time = tonumber(current_time[1]) - window
    redis.call('ZREMRANGEBYSCORE', curr_key, 0, trim_time)
    local request_count = redis.call('ZCARD',curr_key)
    if request_count >= tonumber(max_requests) then
        return 1
    end
end
for i=2, num_windows*2, 2 do
    local curr_key = KEYS[i/2]
    local window = ARGV[i]
    redis.call('ZADD', curr_key, current_time[1], current_time[1] .. current_time[2])
    redis.call('EXPIRE', curr_key, window)
end
return 0

上述脚本有不确定数量的参数和不确定数量的键。因此,确保所有键都在同一个分片上至关重要,所以在构建键时,我们将使用形式 path_pattern:apiKey:window_size_seconds,并用大括号 {apiKey} 将键的公共部分 apiKey 括起来。

构建中间件#

现在是实际构建中间件的时候了。添加一个新文件 SlidingWindowRateLimiter.cs。在该文件中,添加两个类 SlidingWindowRateLimiter 和 SlidingWindowRateLimiterExtensions。

在 SlidingWindowRateLimiterExtensions 类中,添加一个方法将 SlidingWIndowRateLimiter 添加到中间件管道中,完成后的类将如下所示

public static class SlidingWindowRateLimiterExtensions
{
    public static void UseSlidingWindowRateLimiter(this IApplicationBuilder builder)
    {
        builder.UseMiddleware<SlidingWindowRateLimiter>();
    }
}

在 SlidingWindowRateLimiter 类中,首先将上面提到的脚本添加为类的 const 字符串:

private const string SlidingRateLimiter = @"
    local current_time = redis.call('TIME')
    local num_windows = ARGV[1]
    for i=2, num_windows*2, 2 do
        local window = ARGV[i]
        local max_requests = ARGV[i+1]
        local curr_key = KEYS[i/2]
        local trim_time = tonumber(current_time[1]) - window
        redis.call('ZREMRANGEBYSCORE', curr_key, 0, trim_time)
        local request_count = redis.call('ZCARD',curr_key)
        if request_count >= tonumber(max_requests) then
            return 1
        end
    end
    for i=2, num_windows*2, 2 do
        local curr_key = KEYS[i/2]
        local window = ARGV[i]
        redis.call('ZADD', curr_key, current_time[1], current_time[1] .. current_time[2])
        redis.call('EXPIRE', curr_key, window)
    end
    return 0
    ";

构造函数#

我们需要为这个类注入一个 IDatabase 来访问 Redis,一个 IConfiguration 来访问配置,当然还有管道中的下一个处理程序以便继续。因此,我们将通过依赖注入的方式将这些都注入到我们的中间件中:

private readonly IDatabase _db;
private readonly IConfiguration _config;
private readonly RequestDelegate _next;

public SlidingWindowRateLimiter(RequestDelegate next, IConnectionMultiplexer muxer, IConfiguration config)
{
    _db = muxer.GetDatabase();
    _config = config;
    _next = next;
}

提取 Api Key#

在本例中,我们将使用 基本认证,因此我们将使用基本认证结构中的用户名作为我们的 apiKey。相应地,我们将需要一个方法来提取它:

private static string GetApiKey(HttpContext context)
{
    var encoded = string.Empty;
    var auth = context.Request.Headers["Authorization"];
    if (!string.IsNullOrEmpty(auth)) encoded = AuthenticationHeaderValue.Parse(auth).Parameter;
    if (string.IsNullOrEmpty(encoded)) return encoded;
    return Encoding.UTF8.GetString(Convert.FromBase64String(encoded)).Split(':')[0];
}

提取适用的规则#

从我们之前生成的配置结构中,我们将提取 RedisRateLimits 部分,并将其放入一个 RateLimitRule 对象数组中。然后我们需要提取适用于当前路径的规则,根据它们窗口的秒数以及与之相关的路径键组件进行分组。如果存在相同的路径键,例如两个 ^/api/* 实例,我们将采用限制更严格(允许请求数最少)的那个。我们可以使用 LINQ 查询来提取:

public IEnumerable<RateLimitRule> GetApplicableRules(HttpContext context)
{
    var limits = _config.GetSection("RedisRateLimits").Get<RateLimitRule[]>();
    var applicableRules = limits
        .Where(x => x.MatchPath(context.Request.Path))
        .OrderBy(x => x.MaxRequests)
        .GroupBy(x => new{x.PathKey, x.WindowSeconds})
        .Select(x=>x.First());
    return applicableRules;
}

检查限制#

下一步是检查键当前是否受到限制。我们的脚本期望一个 Redis 键数组,其模式如上所述 path_pattern:{apiKey}:window_size_seconds,然后需要强制执行的规则数量,最后需要按 window_size num_requests 顺序附加规则。脚本的所有参数都已生成,我们只需要评估脚本并检查它是否返回 1:

private async Task<bool> IsLimited( IEnumerable<RateLimitRule> rules, string apiKey)
{
    var keys = rules.Select(x => new RedisKey($"{x.PathKey}:{{{apiKey}}}:{x.WindowSeconds}")).ToArray();
    var args = new List<RedisValue>{rules.Count()};
    foreach (var rule in rules)
    {
        args.Add(rule.WindowSeconds);
        args.Add(rule.MaxRequests);
    }
    return (int) await _db.ScriptEvaluateAsync(SlidingRateLimiter, keys,args.ToArray()) == 1;
}

拦截或允许#

最后,在我们的中间件的 InvokeAsync 方法中,我们将把这些都整合起来。首先,我们将解析出 apiKey。如果 apiKey 不存在,我们将返回 401。否则,我们将执行限流检查,并根据情况进行限制或放行。

public async Task InvokeAsync(HttpContext httpContext)
{
    var apiKey = GetApiKey(httpContext);
    if (string.IsNullOrEmpty(apiKey))
    {
        httpContext.Response.StatusCode = 401;
        return;
    }
    var applicableRules = GetApplicableRules(httpContext);
    var limited = await IsLimited(applicableRules, apiKey);
    if (limited)
    {
        httpContext.Response.StatusCode = 429;
        return;
    }
    await _next(httpContext);
}

构建控制器#

在 Controllers 文件夹下,添加一个名为 RateLimitedController 的类。然后,在该控制器中,声明一个新的 ApiController。

[ApiController]
[Route("api/[controller]")]
public class RateLimitedController : ControllerBase
{
}

在此类中,添加两个新路由,一个用于 limited,另一个用于 indirectly-limited

[HttpGet]
[HttpPost]
[Route("limited")]
public async Task<IActionResult> Limited()
{
    return new JsonResult(new {Limited = false});
}

[HttpGet]
[HttpPost]
[Route("indirectly-limited")]
public async Task<IActionResult> IndirectlyLimited()
{
    return new JsonResult(new {NeverLimited = true});
}

将中间件添加到应用#

打开 startup.cs

在 ConfigureServices 方法中,添加以下行

services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect("localhost"));

在 Configure 方法中,添加以下行:

app.UseSlidingWindowRateLimiter();

配置应用#

appsettings.json 或 appsettings.Development.json 中,添加一个用于限流的配置项:

"RedisRateLimits":[
    {
      "Path":"/api/RateLimited/limited",
      "Window":"30s",
      "MaxRequests": 5
    },
    {
      "PathRegex":"^/api/*",
      "Window":"1h",
      "MaxRequests": 50
    }
]

测试一下#

剩下的就是测试了。如果你在终端中运行 dotnet run,你可以测试这两个端点,它们分别位于

http://localhost:5000/api/ratelimited/limited 和 http://localhost:5000/api/ratelimited/indirectly-limited

你可以使用以下命令重复访问这些端点

for n in {1..7}; do echo $(curl -s -w " HTTP %{http_code}, %{time_total} s" -X POST -H "Content-Length: 0" --user "foobar:password" http://localhost:5000/api/ratelimited/limited); sleep 0.5; done

这将发送七个请求,其中两个将被拒绝。在此之后如果你运行

for n in {1..47}; do echo $(curl -s -w " HTTP %{http_code}, %{time_total} s" -X POST -H "Content-Length: 0" --user "foobar:password" http://localhost:5000/api/ratelimited/indirectly-limited); sleep 0.5; done

它应该会再拒绝两个,因为它们被限流了。

资源#

  • 本教程的源代码位于 GitHub