اتصال InputValidatorFilter به Endpoint
فیلتر جنریک فصل قبل را میتوان روی Endpoint ایجاد کشور اعمال کرد. در اینجا Country نوع جنریک Filter است و خود Endpoint دیگر نیازی به دریافت مستقیم IValidator ندارد.
app.MapPost("/countries", ([FromBody] Country country) => {
return Results.CreatedAtRoute("countryById", new {
Id = 1 });
}).AddEndpointFilter<InputValidatorFilter<Country>>();
Listing 5-20 — اتصال InputValidatorFilter<Country> به POST /countries
رفتار اعتبارسنجی همان است که Validator از طریق Dependency Injection مستقیماً در Endpoint دریافت میشد؛ تفاوت این است که منطق اعتبارسنجی از Lambda خارج شده و کد Endpoint تمیزتر است. Endpoint Filterها در سناریوهای دیگری نیز قابل استفادهاند.
Rate Limiting (محدودسازی نرخ)
Rate Limiting یکی از قابلیتهای مهم ASP.NET Core 8 است و دسترسی به API را بر اساس قواعد مشخص محدود میکند. این قابلیت چند هدف اصلی دارد:
- محافظت از سامانه: با محدود کردن تعداد درخواستهایی که یک کاربر یا برنامه میتواند ارسال کند، به کاهش اثر حملات Denial of Service (DoS) کمک میکند.
- تضمین کیفیت خدمت: محدودکردن Throughput باعث میشود منابع بهشکل عادلانهتری میان مصرفکنندگان تقسیم شوند و Performance (کارایی) سامانه در بار بالا حفظ شود.
- ایجاد سطوح دسترسی تجاری: میتوان برای Tier رایگان محدودیت بیشتر و برای Subscription پولی محدودیت کمتر یا دسترسی گستردهتر تعریف کرد.
مدلهای Rate Limiter
ASP.NET Core 8 چهار دستهٔ اصلی Limiter ارائه میکند:
- Fixed Window: تعداد مشخصی درخواست در یک بازهٔ زمانی ثابت مجاز است. هر درخواست مجاز شمارنده را کم میکند و با پایان Window شمارنده دوباره تنظیم میشود. تعداد محدودی درخواست نیز میتواند تا آزاد شدن ظرفیت در Queue منتظر بماند و درخواستهای بیشتر رد میشوند.
- Sliding Window: Window به چند Segment تقسیم میشود. ظرفیت درخواست میان Segmentها جابهجا میشود و در حرکت پنجره، ظرفیت آزادشده از Segmentهای قبلی به دورهٔ بعدی منتقل میشود. این مدل توزیع نرمتری از ظرفیت در طول زمان ایجاد میکند و میتواند Queue داشته باشد.
- Token Bucket: ظرفیت بهصورت Token در یک Bucket نمایش داده میشود. هر درخواست مجاز یک Token مصرف میکند. تعداد مشخصی Token در دورههای تعیینشده دوباره اضافه میشود، اما از سقف Bucket فراتر نمیرود. اگر Token موجود نباشد درخواست بهصورت خودکار رد میشود و این مدل برای نبود Token صف انتظار ایجاد نمیکند.
- Concurrency: سادهترین مدل است و تعداد درخواستهای همزمان مجاز را محدود میکند. در صورت پر بودن ظرفیت، میتوان تعدادی درخواست را در Queue نگه داشت و بقیه را رد کرد.
هر مدل میتواند Partition Key داشته باشد؛ برای مثال شناسهٔ کاربر، IP یا معیار دیگری که Limiting بر اساس آن مستقل شود. اگر Partition Key تعریف نشود، محدودیت Global است. در پیکربندی پیشفرض، رد درخواست میتواند با 503 Service Unavailable پاسخ داده شود؛ نویسنده برای انطباق بهتر با مفهوم محدودسازی نرخ، 429 Too Many Requests را پیشنهاد میکند.
فعالسازی Rate Limiting
قواعد با AddRateLimiter ثبت و با Middleware نوع UseRateLimiter در Pipeline فعال میشوند.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
// Code here
});
var app = builder.Build();
// Your ASP.NET Core pipeline
app.UseRateLimiter();
// Your ASP.NET Core pipeline
app.Run();
Listing 5-21 — AddRateLimiter و UseRateLimiter
مدل Fixed Window
نمونهٔ زیر برای هر IP در یک Window پانزدهثانیهای حداکثر ۵۰ درخواست مجاز میکند. در صورت رسیدن به حد، تا ۱۰ درخواست در Queue قرار میگیرند. سایر درخواستها رد میشوند و پاسخ سفارشی با وضعیت ۴۲۹ فرستاده میشود.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = (int)HttpStatusCode.TooManyRequests;
options.OnRejected = async (context, token) =>
{
await context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.");
};
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress.ToString(),
factory: _ => new FixedWindowRateLimiterOptions
{
QueueLimit = 10,
PermitLimit = 50,
Window = TimeSpan.FromSeconds(15)
}));
});
Listing 5-22 — پیادهسازی Fixed Window Limiter
PartitionedRateLimiter.Create دسترسی به HttpContext را فراهم میکند؛ بنابراین میتوان Partition Key را از IP، شناسهٔ کاربر احراز هویتشده یا هر دادهٔ Contextual دیگری ساخت. در این نمونه چون Limiter به GlobalLimiter نسبت داده شده، روی همهٔ Endpointها اعمال میشود.
برای مستثنا کردن یک Endpoint از Limiter سراسری از DisableRateLimiting استفاده کنید.
app.MapGet("/notlimited", () =>
{
return Results.Ok();
}).DisableRateLimiting();
Listing 5-23 — غیرفعال کردن Rate Limiting سراسری برای یک Endpoint
Policyهای اختصاصی در کنار Limiter سراسری
میتوان چند Limiter نامگذاریشده ساخت و هرکدام را تنها روی Endpointهای مشخص اعمال کرد. در نمونهٔ زیر Policy با نام ShortLimit در کنار Limiter سراسری تعریف میشود.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = (int)HttpStatusCode.TooManyRequests;
options.OnRejected = async (context, token) =>
{
await context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.");
};
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress.ToString(),
factory: _ => new FixedWindowRateLimiterOptions
{
QueueLimit = 10,
PermitLimit = 50,
Window = TimeSpan.FromSeconds(15)
}));
options.AddPolicy(policyName: "ShortLimit", context =>
{
return RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress.ToString(),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromSeconds(15)
});
});
});
Listing 5-24 — ترکیب Global Limiter با Policy نوع ShortLimit
Policy با RequireRateLimiting روی Endpoint اعمال میشود.
app.MapGet("/limited", () =>
{
return Results.Ok();
}).RequireRateLimiting("ShortLimit");
Listing 5-25 — اعمال Policy با RequireRateLimiting
Rate Limiting بر اساس Pricing Tier
انعطاف Limiter اجازه میدهد قواعد بر اساس سطح تجاری مشتری تغییر کنند. فرض کنید Tier مشتری بر اساس IP تعیین میشود. قرارداد سرویس بهشکل زیر است.
using Domain.Enum;
namespace Domain.Services;
public interface IPricingTierService
{
public PricingTier GetPricingTier(string ipAddress);
}
Listing 5-26 — سرویس IPricingTierService
خروجی سرویس Enum زیر است.
namespace Domain.Enum;
public enum PricingTier
{
Free = 0,
Paid = 1
}
Listing 5-27 — Enum نوع PricingTier
پیادهسازی سرویس با builder.Services.AddScoped<IPricingTierService, PricingTierService>(); ثبت میشود. چون HttpContext در Factory در دسترس است، سرویس ثبتشده را میتوان با httpContext.RequestServices.GetRequiredService<T>() از DI دریافت کرد.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = (int)HttpStatusCode.TooManyRequests;
options.OnRejected = async (context, token) =>
{
await context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.");
};
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var priceTierService = httpContext.RequestServices.GetRequiredService<IPricingTierService>();
var ip = httpContext.Connection.RemoteIpAddress.ToString();
var priceTier = priceTierService.GetPricingTier(ip);
return priceTier switch
{
PricingTier.Paid => RateLimitPartition.GetFixedWindowLimiter(
ip,
_ => new FixedWindowRateLimiterOptions
{
QueueLimit = 10,
PermitLimit = 50,
Window = TimeSpan.FromSeconds(15)
}),
PricingTier.Free => RateLimitPartition.GetFixedWindowLimiter(
ip,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 1,
Window = TimeSpan.FromSeconds(15)
})
};
});
});
Listing 5-28 — Global Limiter وابسته به Pricing Tier
در این نمونه مشتری Paid تا ۵۰ Permit در پانزده ثانیه و Queue دهتایی دارد، درحالیکه Tier رایگان تنها یک Permit در همان بازه دریافت میکند. نویسنده Fixed Window را بهخاطر سادگی محدودکردن ورودی در یک بازهٔ مشخص، مدل مورد علاقهٔ خود معرفی میکند.
شکل ۵-۱۴ — پاسخ HTTP 429 Too Many Requests پس از رد درخواست
مدل Sliding Window
در Sliding Window علاوه بر ظرفیت و طول Window باید تعداد Segmentها با SegmentsPerWindow تعیین شود. نمونهٔ زیر همان Tierها را با Sliding Window پیادهسازی میکند.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = (int)HttpStatusCode.TooManyRequests;
options.OnRejected = async (context, token) =>
{
await context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.");
};
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var priceTierService = httpContext.RequestServices.GetRequiredService<IPricingTierService>();
var ip = httpContext.Connection.RemoteIpAddress.ToString();
var priceTier = priceTierService.GetPricingTier(ip);
return priceTier switch
{
PricingTier.Paid => RateLimitPartition.GetSlidingWindowLimiter(
ip,
_ => new SlidingWindowRateLimiterOptions
{
QueueLimit = 10,
PermitLimit = 50,
SegmentsPerWindow = 2,
Window = TimeSpan.FromSeconds(15)
}),
PricingTier.Free => RateLimitPartition.GetSlidingWindowLimiter(
ip,
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 2,
SegmentsPerWindow = 2,
Window = TimeSpan.FromSeconds(15)
})
};
});
});
Listing 5-29 — Global Limiter با مدل Sliding Window
در این نسخه، GetSlidingWindowLimiter و SlidingWindowRateLimiterOptions جای همتایان Fixed Window را میگیرند.
مدل Token Bucket
Token Bucket به سه تنظیم اصلی نیاز دارد:
TokenLimit: حداکثر تعداد Tokenهای موجود.
TokensPerPeriod: تعداد Tokenهایی که در هر دوره دوباره افزوده میشوند.
ReplenishmentPeriod: طول دورهٔ بازافزایی Tokenها.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = (int)HttpStatusCode.TooManyRequests;
options.OnRejected = async (context, token) =>
{
await context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.");
};
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var priceTierService = httpContext.RequestServices.GetRequiredService<IPricingTierService>();
var ip = httpContext.Connection.RemoteIpAddress.ToString();
var priceTier = priceTierService.GetPricingTier(ip);
return priceTier switch
{
PricingTier.Paid => RateLimitPartition.GetTokenBucketLimiter(
ip,
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 50,
TokensPerPeriod = 25,
ReplenishmentPeriod = TimeSpan.FromSeconds(15)
}),
PricingTier.Free => RateLimitPartition.GetTokenBucketLimiter(
ip,
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 10,
TokensPerPeriod = 5,
ReplenishmentPeriod = TimeSpan.FromSeconds(15)
})
};
});
});
Listing 5-30 — Global Limiter با مدل Token Bucket
GetTokenBucketLimiter و TokenBucketRateLimiterOptions جای نسخههای Fixed Window را میگیرند. این مدل از دیگر مدلها سختگیرانهتر است، زیرا وقتی Tokenی وجود ندارد درخواست را در Queue نگه نمیدارد.
مدل Concurrency
Concurrency سادهترین Limiter است و تنها QueueLimit و PermitLimit را نیاز دارد.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = (int)HttpStatusCode.TooManyRequests;
options.OnRejected = async (context, token) =>
{
await context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.");
};
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var priceTierService = httpContext.RequestServices.GetRequiredService<IPricingTierService>();
var ip = httpContext.Connection.RemoteIpAddress.ToString();
var priceTier = priceTierService.GetPricingTier(ip);
return priceTier switch
{
PricingTier.Paid => RateLimitPartition.GetConcurrencyLimiter(
ip,
_ => new ConcurrencyLimiterOptions
{
QueueLimit = 10,
PermitLimit = 50
}),
PricingTier.Free => RateLimitPartition.GetConcurrencyLimiter(
ip,
_ => new ConcurrencyLimiterOptions
{
QueueLimit = 0,
PermitLimit = 10
})
};
});
});
Listing 5-31 — Global Limiter با مدل Concurrency
در اینجا GetConcurrencyLimiter و ConcurrencyLimiterOptions جای تنظیمات مدل Fixed Window را گرفتهاند. Rate Limiting در ASP.NET Core 8 بسیار قابل سفارشیسازی است و نویسنده استفاده از آن را قویاً توصیه میکند.
مدیریت سراسری خطاها
مدیریت کارآمد خطا برای برنامهای که به منابع خارجی مانند فایل، پایگاه داده یا سرویسهای دیگر وابسته است ضروری است. هدف این است که وقوع خطا و نوع آن بهشکل روشن و استاندارد به مصرفکنندهٔ API اعلام شود. در ASP.NET Core 8 میتوان این کار را بهصورت سراسری، تمیز و بدون تکرار منطق انجام داد.
در این بخش از ProblemDetails استفاده میشود؛ ساختاری استاندارد که در فصل ۱ معرفی شد و پاسخ خطا را با قالبی قابل انتظار برای Client تولید میکند.
برای مدیریت سراسری Exception، کلاسی باید رابط IExceptionHandler را پیادهسازی کند.
public interface IExceptionHandler
{
ValueTask<bool> TryHandleAsync(HttpContext httpContext,
Exception exception, CancellationToken cancellationToken);
}
Listing 5-32 — رابط IExceptionHandler
TryHandleAsync مقدار Boolean برمیگرداند. مقدار true یعنی Exception مدیریت شده و زنجیرهٔ Handlerها پایان یابد؛ مقدار false اجازه میدهد Handler بعدی بررسی شود.
DefaultExceptionHandler
Handler زیر هر Exception عمومی را به یک ProblemDetails با وضعیت ۵۰۰ تبدیل میکند و پاسخ را به JSON مینویسد.
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using System.Net;
namespace AspNetCore8MinimalApis.ExceptionHandlers;
public class DefaultExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext,
Exception exception, CancellationToken cancellationToken)
{
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = (int)HttpStatusCode.InternalServerError,
Type = exception.GetType().Name,
Title = "An unexpected error occurred",
Detail = exception.Message,
Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}"
});
return true;
}
}
Listing 5-33 — کلاس DefaultExceptionHandler
نویسنده JSON را بهصورت صریح انتخاب میکند، زیرا در APIهای مدرن تقریباً همیشه قالب مورد انتظار Client است. Handler با AddExceptionHandler<T> ثبت و با UseExceptionHandler فعال میشود.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddExceptionHandler<DefaultExceptionHandler>();
var app = builder.Build();
// Your ASP.NET Core pipeline
app.UseExceptionHandler(opt => { });
// Your ASP.NET Core pipeline
app.Run();
Listing 5-34 — فعالسازی DefaultExceptionHandler در Pipeline
Delegate تنظیمات UseExceptionHandler الزامی است، اما در این نمونه خالی میماند. خطای عمومی با 500 Internal Server Error برگردانده میشود.
app.MapGet("/exception", () => {
throw new Exception();
});
Listing 5-35 — Endpoint تولیدکنندهٔ Exception
شکل ۵-۱۵ — پاسخ GET /exception در Postman
زنجیرهکردن Exception Handlerها
ASP.NET Core 8 اجازه میدهد چند Handler بهترتیب ثبت شوند. هر Handler نوع Exception را بررسی میکند و اگر آن را مدیریت نکرد مقدار false برمیگرداند تا Handler بعدی فرصت اجرا داشته باشد.
برای Timeout، نویسنده معتقد است پاسخ 408 برای این سناریوی Server-side مناسب نیست، زیرا خانوادهٔ 4xx معمولاً خطای Client را نشان میدهد. برای Timeout ناشی از Server یا منبع خارجی، 503 Service Unavailable انتخاب مناسبتری در این مثال است.
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using System.Net;
namespace AspNetCore8MinimalApis.ExceptionHandlers;
public class TimeOutExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext,
Exception exception, CancellationToken cancellationToken)
{
if (exception is TimeoutException)
{
httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = (int)HttpStatusCode.ServiceUnavailable,
Type = exception.GetType().Name,
Title = "A timeout occurred",
Detail = exception.Message,
Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}"
});
return true;
}
return false;
}
}
Listing 5-36 — کلاس TimeOutExceptionHandler
ترتیب ثبت Handlerها اهمیت دارد. Handler اختصاصی Timeout باید پیش از Handler پیشفرض ثبت شود. اگر Timeout را مدیریت کند و true برگرداند، Default Handler دیگر اجرا نمیشود.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddExceptionHandler<TimeOutExceptionHandler>();
builder.Services.AddExceptionHandler<DefaultExceptionHandler>();
var app = builder.Build();
Listing 5-37 — ثبت TimeOutExceptionHandler پیش از DefaultExceptionHandler
Endpoint زیر برای آزمایش یک TimeoutException ایجاد میکند.
app.MapGet("/timeout", () => {
throw new TimeoutException();
});
Listing 5-38 — GET /timeout و ایجاد TimeoutException
شکل ۵-۱۶ — پاسخ GET /timeout در Postman پس از مدیریت خطا
با همین الگو میتوان برای انواع مختلف Exception، Handlerهای اختصاصی و استاندارد ساخت.
جمعبندی فصل
این فصل قابلیتهایی را معرفی کرد که بدون آنها هم میتوان API ساخت، اما استفاده از آنها ساختار و نگهداری برنامه را بهتر میکند: کپسولهسازی Endpointها، Binding سفارشی، Middlewareها، Endpoint Filterها، Rate Limiting و مدیریت سراسری Exception. فصل بعد از سطح API عبور میکند و به دسترسی به داده از منابع مختلف و ساختاردهی لایههای مربوط به منابع خارجی میپردازد.
تصاویر منبع مرتبط با این بخش