在阅读本文之前,我建议先看一下我的文章:.NET 8 中的最小 API:使用 .NET 8 在最小 API 中构建 Web API 和中间件的简化方法。
速率限制是一种用于管理传入 Web 应用程序或 API 的流量的方法,方法是限制指定时间范围内允许的请求数。实施速率限制可以增强站点或应用程序的整体性能,并防止其无响应。
要添加限速中间件,首先,我们将所需的服务添加到容器中
builder.Services.AddRateLimiter(options =>
{
});
将中间件添加到管道
app.UseRateLimiter();
这个项目是一个基本的最小 API(如果你需要复习,请查看我之前的帖子),具有一个显示 «GitHubIssues» 集合的端点。我选择以最少的配置保持纤薄的Program.cs,同时将每个限制器合并到集成测试中。
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using MinimalApi.Constants;
using System.Net;
using System.Threading.RateLimiting;
namespace MinimalApi.Tests.Base;
public class FixedWindowLimiterTests : IntegrationTestBase
{
public FixedWindowLimiterTests(WebApplicationFactory<Program> factory)
: base(factory) { }
[Fact]
public async Task ListIssues_WhenFixedWindowLimitOf10RequestsPerMinute_5out15RequestsShouldBeRejected()
{
// Arrange
var numberOfRequests = 15;
var permitLimit = 10;
var timeWindow = TimeSpan.FromMinutes(1);
var results = new List<HttpResponseMessage>();
using var client = CreateClient(services =>
services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: GetPartitionKey(httpContext),
factory: partition => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = timeWindow
}
)
);
options.OnRejected = async (context, token) =>
await HandleRateLimiterRejectionAsync(context, token);
})
);
var route = BuildFullRoute(Routes.ListIssues);
// Act
for (int i = 0; i < numberOfRequests; i++)
results.Add(await client.GetAsync(route));
// Assert
AssertStatusCodeResponses(results, HttpStatusCode.TooManyRequests, expectedCount: 5);
AssertStatusCodeResponses(results, HttpStatusCode.OK, expectedCount: 10);
}
[Fact]
public async Task ListIssues_WhenQueueingFixedWindowLimitOf10RequestsPer10Sec_0out15RequestsShouldBeRejected()
{
// Arrange
var numberOfRequests = 15;
var permitLimit = 10;
var queueLimit = 5;
var timeWindow = TimeSpan.FromSeconds(10);
var results = new List<HttpResponseMessage>();
using var client = CreateClient(services =>
services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: GetPartitionKey(httpContext),
factory: partition => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = permitLimit,
QueueLimit = queueLimit,
Window = timeWindow,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}
)
);
options.OnRejected = async (context, token) =>
await HandleRateLimiterRejectionAsync(context, token);
})
);
var route = BuildFullRoute(Routes.ListIssues);
// Act
for (int i = 0; i < numberOfRequests; i++)
results.Add(await client.GetAsync(route));
// Assert
AssertStatusCodeResponses(results, HttpStatusCode.TooManyRequests, expectedCount: 0);
AssertStatusCodeResponses(results, HttpStatusCode.OK, expectedCount: 15);
}
[Fact]
public async Task ListIssues_WhenChainedFixedWindowLimitOf60RequestsPerMinute_10out20RequestsShouldBeRejected()
{
// Arrange
var numberOfRequests = 20;
var initialPermitLimit = 10;
var initialTimeWindow = TimeSpan.FromSeconds(10);
var totalPermitLimit = 60;
var totalTimeWindow = TimeSpan.FromMinutes(1);
var results = new List<HttpResponseMessage>();
using var client = CreateClient(services =>
services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: GetPartitionKey(httpContext),
factory => new FixedWindowRateLimiterOptions
{
PermitLimit = initialPermitLimit,
Window = initialTimeWindow,
})
),
PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: GetPartitionKey(httpContext),
factory => new FixedWindowRateLimiterOptions
{
PermitLimit = totalPermitLimit,
Window = totalTimeWindow
})
)
);
options.OnRejected = async (context, token) =>
await HandleRateLimiterRejectionAsync(context, token);
})
);
var route = BuildFullRoute(Routes.ListIssues);
// Act
for (int i = 0; i < numberOfRequests; i++)
results.Add(await client.GetAsync(route));
// Assert
AssertStatusCodeResponses(results, HttpStatusCode.TooManyRequests, expectedCount: 10);
AssertStatusCodeResponses(results, HttpStatusCode.OK, expectedCount: 10);
}
}
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using MinimalApi.Constants;
using System.Net;
using System.Threading.RateLimiting;
namespace MinimalApi.Tests.Base;
public class ConcurrencyLimiterTests : IntegrationTestBase
{
public ConcurrencyLimiterTests(WebApplicationFactory<Program> factory)
: base(factory) { }
[Fact]
public async Task ListIssues_WhenConcurrencyLimitOf2Requests_8out10RequestsShouldBeRejected()
{
// Arrange
var numberOfRequests = 10;
var permitLimit = 2; // Only two requests at the time
using var client = CreateClient(services =>
services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetConcurrencyLimiter(
partitionKey: GetPartitionKey(httpContext),
factory: partition => new ConcurrencyLimiterOptions
{
PermitLimit = permitLimit
}
)
);
options.OnRejected = async (context, token) =>
await HandleRateLimiterRejectionAsync(context, token);
})
);
var route = BuildFullRoute(Routes.ListIssues);
var apiCalls = Enumerable.Range(0, numberOfRequests)
.Select(_ => client.GetAsync(route));
// Act
var results = await Task.WhenAll(apiCalls); // concurrent requests
// Assert
AssertStatusCodeResponses(results, HttpStatusCode.TooManyRequests, expectedCount: 8);
AssertStatusCodeResponses(results, HttpStatusCode.OK, expectedCount: 2);
}
}
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using MinimalApi.Constants;
using System.Net;
using System.Threading.RateLimiting;
namespace MinimalApi.Tests.Base;
public class ConcurrencyLimiterTests : IntegrationTestBase
{
public ConcurrencyLimiterTests(WebApplicationFactory<Program> factory)
: base(factory) { }
[Fact]
public async Task ListIssues_WhenConcurrencyLimitOf2Requests_8out10RequestsShouldBeRejected()
{
// Arrange
var numberOfRequests = 10;
var permitLimit = 2; // Only two requests at the time
using var client = CreateClient(services =>
services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetConcurrencyLimiter(
partitionKey: GetPartitionKey(httpContext),
factory: partition => new ConcurrencyLimiterOptions
{
PermitLimit = permitLimit
}
)
);
options.OnRejected = async (context, token) =>
await HandleRateLimiterRejectionAsync(context, token);
})
);
var route = BuildFullRoute(Routes.ListIssues);
var apiCalls = Enumerable.Range(0, numberOfRequests)
.Select(_ => client.GetAsync(route));
// Act
var results = await Task.WhenAll(apiCalls); // concurrent requests
// Assert
AssertStatusCodeResponses(results, HttpStatusCode.TooManyRequests, expectedCount: 8);
AssertStatusCodeResponses(results, HttpStatusCode.OK, expectedCount: 2);
}
}
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using MinimalApi.Constants;
using System.Net;
using System.Threading.RateLimiting;
namespace MinimalApi.Tests.Base;
public class TokenBucketLimiterTests : IntegrationTestBase
{
public TokenBucketLimiterTests(WebApplicationFactory<Program> factory)
: base(factory) { }
[Fact]
public async Task ListIssues_WhenTokenBucketLimitOf20Requests_5out25RequestsShouldBeRejected()
{
// Arrange
var numberOfRequests = 25;
var bucketTokenLimit = 20;
var tokensToRestorePerPeriod = 10;
var replenishmentPeriod = TimeSpan.FromMinutes(1);
var results = new List<HttpResponseMessage>();
using var client = CreateClient(services =>
services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetTokenBucketLimiter(
partitionKey: GetPartitionKey(httpContext),
factory: partition => new TokenBucketRateLimiterOptions
{
AutoReplenishment = true,
TokenLimit = bucketTokenLimit,
ReplenishmentPeriod = replenishmentPeriod,
TokensPerPeriod = tokensToRestorePerPeriod,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}
)
);
options.OnRejected = async (context, token) =>
await HandleRateLimiterRejectionAsync(context, token);
})
);
var route = BuildFullRoute(Routes.ListIssues);
// Act
for (int i = 0; i < numberOfRequests; i++)
results.Add(await client.GetAsync(route));
// Assert
AssertStatusCodeResponses(results, HttpStatusCode.TooManyRequests, expectedCount: 5);
AssertStatusCodeResponses(results, HttpStatusCode.OK, expectedCount: 20);
}
}
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using MinimalApi.Constants;
using System.Net;
using System.Threading.RateLimiting;
namespace MinimalApi.Tests.Base;
public class SlidingWindowLimiterTests : IntegrationTestBase
{
public SlidingWindowLimiterTests(WebApplicationFactory<Program> factory)
: base(factory) { }
[Fact]
public async Task ListIssues_WhenSlidingWindowLimitOf10Requests_10out20RequestsShouldBeRejected()
{
// Arrange
var numberOfRequests = 20;
var permitLimit = 10; // maximum of requests per segment
var window = TimeSpan.FromSeconds(30); // 30 seconds window
var segmentsPerWindow = 3;
var results = new List<HttpResponseMessage>();
// Segments sliding interval = (30 seconds / 3) equals 3 segments of 10 seconds
using var client = CreateClient(services =>
services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: GetPartitionKey(httpContext),
factory: partition => new SlidingWindowRateLimiterOptions
{
Window = window,
PermitLimit = permitLimit,
SegmentsPerWindow = segmentsPerWindow,
}
)
);
options.OnRejected = async (context, token) =>
await HandleRateLimiterRejectionAsync(context, token);
})
);
var route = BuildFullRoute(Routes.ListIssues);
// Act
for (int i = 0; i < numberOfRequests; i++)
results.Add(await client.GetAsync(route));
// Assert
AssertStatusCodeResponses(results, HttpStatusCode.TooManyRequests, expectedCount: 10);
AssertStatusCodeResponses(results, HttpStatusCode.OK, expectedCount: 10);
}
}
AuthenticatedUserPolicy 用于根据请求者的身份验证状态定制速率限制。经过身份验证的用户每分钟最多允许 400 个请求,而未经身份验证的用户每分钟最多允许 40 个请求。
尽管我已全局将此策略应用于使用 .RequireRateLimiting() 扩展方法,您可以通过对单个终结点采用不同的策略或完全选择退出来灵活地进一步优化它。
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
namespace MinimalApi.Constants;
public class AuthenticatedUserPolicy : IRateLimiterPolicy<string>
{
public static readonly AuthenticatedUserPolicy Instance = new();
public RateLimitPartition<string> GetPartition(HttpContext httpContext)
{
var nonAuthPermitLimit = 40; // 40 requests per minute
var authenticatedPermitLimit = 400; // 400 requests per minute
var window = TimeSpan.FromMinutes(1);
var isAuthenticated = httpContext.User.Identity?.IsAuthenticated == true;
// Authenticated requests
if (isAuthenticated)
{
var identityName = httpContext.User.Identity?.Name!.ToString();
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: identityName!,
partition => new FixedWindowRateLimiterOptions
{
PermitLimit = authenticatedPermitLimit,
Window = window
}
);
}
// Non-authenticated requests
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Request.Headers.Host.ToString(),
partition => new FixedWindowRateLimiterOptions
{
PermitLimit = nonAuthPermitLimit,
Window = window
}
);
}
public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected
{
get => (context, lease) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
return new ValueTask();
};
}
}
让我们来测试一下。
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using MinimalApi.Constants;
using MinimalApi.Tests.Base;
using System.Net;
using System.Net.Http.Headers;
namespace MinimalApi.Tests;
public class AuthenticatedUserPolicyTests : IntegrationTestBase
{
public AuthenticatedUserPolicyTests(WebApplicationFactory<Program> appFactory)
: base(appFactory) {}
[Fact]
public async Task ListIssues_WhenUnderAuthenticatedUserPolicy_0out50RequestsShouldBeRejected()
{
// Arrange
var numberOfRequests = 50;
var results = new List<HttpResponseMessage>();
var scheme = "TestScheme";
using var client = CreateClient(services =>
services
.AddAuthentication(defaultScheme: scheme)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
scheme, options => {}
)
);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
scheme: scheme);
var route = BuildFullRoute(Routes.ListIssues);
// Act
for (int i = 0; i < numberOfRequests; i++)
results.Add(await client.GetAsync(route));
// Assert
AssertStatusCodeResponses(results, HttpStatusCode.TooManyRequests, expectedCount: 0);
AssertStatusCodeResponses(results, HttpStatusCode.OK, expectedCount: 50);
}
[Fact]
public async Task ListIssues_WhenUnderAuthenticatedUserPolicy_10out50RequestsShouldBeRejected()
{
// Arrange
var numberOfRequests = 50;
var results = new List<HttpResponseMessage>();
using var client = CreateClient(services => { });
var route = BuildFullRoute(Routes.ListIssues);
// Act
for (int i = 0; i < numberOfRequests; i++) // limited requests for non-authenticated users
results.Add(await client.GetAsync(route));
// Assert
AssertStatusCodeResponses(results, HttpStatusCode.TooManyRequests, expectedCount: 10);
AssertStatusCodeResponses(results, HttpStatusCode.OK, expectedCount: 40);
}
}
使用 Postman,我们可以轻松地在集合中设置一个运行器,以创建多个迭代以进行测试。
服务器在第 40 个未经授权的请求后开始拒绝请求,这表明策略按预期运行。一切似乎都很好!
总而言之,速率限制在 .NET Core 中管理请求流方面起着至关重要的作用。它保护服务器资源,减少滥用,并促进客户端之间的公平使用。
但是,重要的是要认识到,速率限制只是构建安全且可扩展的 API 的一个组成部分。作为开发人员,及时了解最佳实践并不断优化性能和安全性对于提供无缝可靠的用户体验至关重要。
源代码获取:公众号回复消息【code:80358
】