学习

用于 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
    }
  ]
}

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

参数名称

描述

路径

要进行速率限制的文字路径,如果路径完全匹配,则将触发速率限制检查

PathRegex

要进行速率限制的路径正则表达式;如果路径匹配,则将触发速率限制检查

窗口

要进行速率限制的滑动窗口应匹配模式 `([0-9]+(s

m

d

h))`

MaxRequests

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

这些参数将存储在我们的配置中的 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 类中,首先将上面提到的脚本添加为类的常量字符串:

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 密钥#

在本例中,我们将使用 基本身份验证,因此我们将使用基本身份验证结构中的用户名作为我们的 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 ,您可以尝试两个端点中的每一个,它们都可以在以下地址访问

https://localhost:5000/api/ratelimited/limited 和 https://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