.NET 8 中对web API 进行限速处理实例讲解

作者:微信公众号:【架构师老卢】
5-2 10:24
33

概述:在阅读本文之前,我建议先看一下我的文章:.NET 8 中的最小 API:使用 .NET 8 在最小 API 中构建 Web API 和中间件的简化方法。什么是速率限制?速率限制是一种用于管理传入 Web 应用程序或 API 的流量的方法,方法是限制指定时间范围内允许的请求数。实施速率限制可以增强站点或应用程序的整体性能,并防止其无响应。为什么我们使用速率限制?它通过订阅模式促进商业可行性,用户为一定数量的 API 调用付费。这鼓励升级以获得更高的使用率。它可以防御 DoS 攻击等恶意活动。通过限制 API 调用的数量,它可以防止黑客通过自动机器人请求使系统不堪重负,从而保持服务可用性。在使用“

在阅读本文之前,我建议先看一下我的文章:.NET 8 中的最小 API:使用 .NET 8 在最小 API 中构建 Web API 和中间件的简化方法。

什么是速率限制?

速率限制是一种用于管理传入 Web 应用程序或 API 的流量的方法,方法是限制指定时间范围内允许的请求数。实施速率限制可以增强站点或应用程序的整体性能,并防止其无响应。

为什么我们使用速率限制?

  • 它通过订阅模式促进商业可行性,用户为一定数量的 API 调用付费。这鼓励升级以获得更高的使用率。
  • 它可以防御 DoS 攻击等恶意活动。通过限制 API 调用的数量,它可以防止黑客通过自动机器人请求使系统不堪重负,从而保持服务可用性。
  • 在使用“即用即付”模型的基于云的 API 中,速率限制有助于根据基础设施容量调节流量。这确保了最佳的资源使用,并在底层基础设施的约束下保持平衡的 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);
    }
}
  1. **固定了每分钟 10 个请求的窗口限制:**此测试验证在一分钟内发出的 15 个请求中,有 5 个应因超出限制而被拒绝。
  2. **排队固定窗口限制为每 10 秒 10 个请求:**在这里,测试确保在 10 秒内发出的所有 15 个请求都应被接受,因为使用了排队策略。
  3. 每分钟 60 个请求的链式固定窗口限制: 此测试检查一个更复杂的场景,其中两个固定窗口速率限制器链接在一起。它验证在一分钟内发出的 20 个请求中,应根据组合限制拒绝 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);
    }
}
  1. **2 个请求的并发限制:**此测试可确保在同时发出的 10 个请求中,只有 2 个应被接受,而其余 8 个应因超出并发限制而被拒绝。

并发限制器

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);
    }
}
  1. **2 个请求的并发限制:**此测试可确保在同时发出的 10 个请求中,只有 2 个应被接受,而其余 8 个应因超出并发限制而被拒绝。

令牌桶限制器

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);
    }
}
  1. **20 个请求的令牌存储桶限制:**此测试检查在发出的 25 个请求中,有 5 个应因超出令牌存储桶限制而被拒绝。

滑动窗口限制器

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);
    }
}
  1. 滑动窗口限制为 10 个请求: 此测试验证在 30 秒的滑动窗口内发出的 20 个请求中,有 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);
    }
}
  1. **ListIssues_WhenUnderAuthenticatedUserPolicy_0out50RequestsShouldBeRejected:**测试是否接受 50 个经过身份验证的请求。
  2. ListIssues_WhenUnderAuthenticatedUserPolicy_10out50RequestsShouldBeRejected: 检查 50 个未经身份验证的请求是否被部分拒绝,其中 10 个被拒绝,40 个被接受。

使用 Postman,我们可以轻松地在集合中设置一个运行器,以创建多个迭代以进行测试。

服务器在第 40 个未经授权的请求后开始拒绝请求,这表明策略按预期运行。一切似乎都很好!

总而言之,速率限制在 .NET Core 中管理请求流方面起着至关重要的作用。它保护服务器资源,减少滥用,并促进客户端之间的公平使用。

但是,重要的是要认识到,速率限制只是构建安全且可扩展的 API 的一个组成部分。作为开发人员,及时了解最佳实践并不断优化性能和安全性对于提供无缝可靠的用户体验至关重要。

源代码获取:公众号回复消息【code:80358

相关代码下载地址
重要提示!:取消关注公众号后将无法再启用回复功能,不支持解封!
第一步:微信扫码关键公众号“架构师老卢”
第二步:在公众号聊天框发送code:80358,如:code:80358 获取下载地址
第三步:恭喜你,快去下载你想要的资源吧
阅读排行