在本教程中,我们将构建一个实现基本固定窗口速率限制的应用程序,使用 Redis 和 ASP.NET Core。
在开始之前,启动 Redis。在本例中,我们将使用 Redis docker 镜像:
docker run -dp 6379:6379 redis
在终端中,导航到您希望应用程序存放和运行的位置
dotnet new webapi -n FixedRateLimiter --no-https
更改目录到 FixedRateLimiter 并运行以下命令
dotnet add package StackExchange.Redis
在 Visual Studio 或 Rider 中打开 FixedRateLimiter.csproj 文件(或在 VS Code 中打开文件夹),并在 Controllers 文件夹中添加一个名为 RateLimitedController 的 API 控制器,当所有这些完成时, RateLimitedController.cs 应该看起来像下面这样
namespace FixedRateLimiter.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class RateLimitedController : ControllerBase
{
}
}
要使用 Redis,我们将从 StackExchange.Redis 初始化 ConnectionMultiplexer 的一个实例,为此,请转到 Startup.cs 中的 ConfigureServices 方法,并添加以下行:
services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect("localhost"));
在 RateLimitedController.cs 中,将 ConnectionMultiplexer 注入到控制器中,并使用以下内容从中提取一个 IDatabase 对象:
private readonly IDatabase _db;
public RateLimitedController(IConnectionMultiplexer mux)
{
_db = mux.GetDatabase();
}
我们将添加一个简单的路由,我们将对其进行速率限制;它将是我们控制器上的一个 POST 请求路由。此 POST 请求将使用 基本身份验证 - 这意味着每个请求都将期望一个形式为 Authorization: Basic <base64encoded> 的标头,其中 base64encoded 将是一个形式为 apiKey:apiSecret 的字符串,经过 Base64 编码,例如 Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==。此路由将解析标头中的密钥,并返回一个 OK 结果。
[HttpPost("simple")]
public async Task<IActionResult> Simple([FromHeader]string authorization)
{
var encoded = string.Empty;
if(!string.IsNullOrEmpty(authorization)) encoded = AuthenticationHeaderValue.Parse(authorization).Parameter;
if (string.IsNullOrEmpty(encoded)) return new UnauthorizedResult();
var apiKey = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)).Split(':')[0];
return Ok();
}
使用该设置,您应该使用 dotnet run 运行项目,如果您向 https://localhost:5001/api/RateLimited/simple 发出 POST 请求 - 使用 apiKey foobar 和密码 password,您将获得 200 OK 响应。
您可以使用此 cURL 请求来引发该响应
curl -X POST -H "Content-Length: 0" --user "foobar:password" https://localhost:5000/api/RateLimited/simple
我们将构建一个固定窗口速率限制脚本。固定窗口速率限制器将限制特定时间窗口内的请求数量。在我们的示例中,我们将限制特定 API 密钥对特定路由的请求数量。例如,如果我们有 apiKey foobar 在 12:00:05 访问我们的路由 api/ratelimited/simple ,并且我们有一个 60 秒的窗口,在该窗口内您可以发送不超过 10 个请求,那么我们需要
我们需要解决的问题是,这种速率限制需要我们所有命令的原子性(例如,在我们获取和增加密钥之间,我们不希望任何人进来并点击它)。因此,我们将通过 Lua 脚本 在服务器上运行所有内容。现在,有两种方法可以编写此 Lua 脚本。传统方式,您将所有内容都从密钥和参数中驱动,以下
local key = KEYS[1]
local max_requests = tonumber(ARGV[1])
local expiry = tonumber(ARGV[2])
local requests = redis.call('INCR',key)
redis.call('EXPIRE', key, expiry)
if requests < max_requests then
return 0
else
return 1
end
或者,StackExchange.Redis 包含对 更易读的脚本模式 的支持,它们将允许您为您的脚本命名参数,并且库将负责在执行时填写适当的项目。我们将在此处使用的脚本模式将产生以下脚本:
local requests = redis.call('INCR',@key)
redis.call('EXPIRE', @key, @expiry)
if requests < tonumber(@maxRequests) then
return 0
else
return 1
end
要使用 StackExchange.Redis 运行 Lua 脚本,您需要准备一个脚本并运行它。因此,相应地在项目中添加一个名为 Scripts.cs 的新文件,并在该文件中添加一个名为 Scripts 的新静态类;这将包含一个包含我们的脚本的常量字符串和一个 getter 属性,用于准备脚本以供执行。
using StackExchange.Redis;
namespace FixedRateLimiter
{
public static class Scripts
{
public static LuaScript RateLimitScript => LuaScript.Prepare(RATE_LIMITER);
private const string RATE_LIMITER = @"
local requests = redis.call('INCR',@key)
redis.call('EXPIRE', @key, @expiry)
if requests < tonumber(@maxRequests) then
return 0
else
return 1
end
";
}
}
设置完脚本后,剩下的就是构建我们的密钥、运行脚本并检查结果。我们之前已经提取了 apiKey ,因此;我们将使用它、请求路径和当前时间来创建我们的密钥。然后,我们将运行 ScriptEvaluateAsync 来执行脚本,我们将使用其结果来确定是否返回 429 或我们的 JSON 结果。在我们的 Simple 方法中,在返回之前添加以下内容:
var script = Scripts.RateLimitScript;
var key = $"{Request.Path.Value}:{apiKey}:{DateTime.Now:hh:mm}";
var res = await _db.ScriptEvaluateAsync(script, new {key = new RedisKey(key), expiry = 60, maxRequests = 10});
if ((int) res == 1)
return new StatusCodeResult(429);
我们的 Simple 路由的代码应如下所示:
[HttpPost("simple")]
public async Task<IActionResult> Simple([FromHeader]string authorization)
{
var encoded = string.Empty;
if(!string.IsNullOrEmpty(authorization)) encoded = AuthenticationHeaderValue.Parse(authorization).Parameter;
if (string.IsNullOrEmpty(encoded)) return new UnauthorizedResult();
var apiKey = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)).Split(':')[0];
var script = Scripts.RateLimitScript;
var key = $"{Request.Path.Value}:{apiKey}:{DateTime.UtcNow:hh:mm}";
var res = await _db.ScriptEvaluateAsync(script, new {key = new RedisKey(key), expiry = 60, maxRequests = 10});
if ((int) res == 1)
return new StatusCodeResult(429);
return new JsonResult(new {key});
}
现在,如果我们使用 dotnet run 重新启动服务器,并尝试运行以下命令:
for n in {1..21}; 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/simple); sleep 0.5; done
你会看到一些请求返回 200,至少有一个请求返回 429。具体多少取决于你开始发送请求的时间。请记住,请求在单分钟窗口内进行时间限制,所以如果你在 21 个请求的中间切换到下一分钟,计数器将重置。因此,你应该预计收到大约 10 到 20 个 OK 结果,以及 1 到 11 个 429 结果。响应应该类似于以下内容:
HTTP 200, 0.002680 s
HTTP 200, 0.001535 s
HTTP 200, 0.001653 s
HTTP 200, 0.001449 s
HTTP 200, 0.001604 s
HTTP 200, 0.001423 s
HTTP 200, 0.001492 s
HTTP 200, 0.001449 s
HTTP 200, 0.001551 s
{"status":429,"traceId":"00-16e9da63f77c994db719acff5333c509-f79ac0c862c5a04c-00"} HTTP 429, 0.001803 s
{"status":429,"traceId":"00-3d2e4e8af851024db121935705d5425f-0e23eb80eae0d549-00"} HTTP 429, 0.001521 s
{"status":429,"traceId":"00-b5e824c9ebc4f94aa0bda2a414afa936-8020a7b8f2845544-00"} HTTP 429, 0.001475 s
{"status":429,"traceId":"00-bd6237c5d0362a409c436dcffd0d4a7a-87b544534f397247-00"} HTTP 429, 0.001549 s
{"status":429,"traceId":"00-532d64033c54a148a98d8efe1f9f53b2-b1dbdc7d8fbbf048-00"} HTTP 429, 0.001476 s
{"status":429,"traceId":"00-8c210b1c1178554fb10aa6a7540d3488-0fedba48e38fdd4b-00"} HTTP 429, 0.001606 s
{"status":429,"traceId":"00-633178f569dc8c46badb937c0363cda8-ab1d1214b791644d-00"} HTTP 429, 0.001661 s
{"status":429,"traceId":"00-12f01e448216c64b8bfe674f242a226f-d90ff362926aa14e-00"} HTTP 429, 0.001858 s
{"status":429,"traceId":"00-63ef51cee3bcb6488b04395f09d94def-be9e4d6d6057754a-00"} HTTP 429, 0.001622 s
{"status":429,"traceId":"00-80a971db60fdf543941e2457e35ac2fe-3555f5cb9c907e4c-00"} HTTP 429, 0.001710 s
{"status":429,"traceId":"00-f718734ae0285343ac927df617eeef92-91a49e127f2e4245-00"} HTTP 429, 0.001582 s
{"status":429,"traceId":"00-9da2569cce4d714480dd4f0edc0506d2-8a1ce375b1a9504f-00"} HTTP 429, 0.001629 s