فصل ۵ — گامهای پیشرفتهتر برای REST APIهای تمیز
تبریک! فصل ۴، با حجم زیادی از مطالب پایهای که ممکن است در طول مسیر حرفهای خود در یک API پیادهسازی کنید، به پایان رسید. در این فصل یک گام فراتر میرویم و قابلیتهای پیشرفتهتر ASP.NET Core 8 را بررسی میکنیم. همهٔ مطالب فصل قبل همچنان معتبرند، اما ویژگیهای این فصل کمک میکنند API ساختارمندتر، شکیلتر، قابلتوسعهتر و ایمنتر شود و تجربهٔ بهتری برای مصرفکنندگان فراهم کند.
موضوعات این فصل عبارتاند از:
- کپسولهسازی پیادهسازی Endpointهای Minimal API
- پیادهسازی Custom Parameter Binding (اتصال سفارشی پارامتر)
- استفاده از Middlewareها
- استفاده از Action Filterها/Endpoint Filterها
- Rate Limiting (محدودسازی نرخ)
- مدیریت سراسری خطاها
کپسولهسازی پیادهسازی Minimal Endpoint
یکی از نخستین کارهایی که نویسنده در یک Minimal API ــ یا هر برنامهٔ دیگری ــ انجام میدهد، ساختاربندی کد است. تا اینجا Lambdaهایی را دیدیم که مستقیماً داخل MapGet، MapPost و سایر متدهای نگاشت Endpoint اجرا میشدند.
برای ساختار بهتر، میتوان منطق Endpoint را به متدهای static در کلاسهای static منتقل کرد. در نتیجه کد خواناتر میشود و بخش وابسته به API، یعنی متدهای MapXXX، از اجرای منطق برنامه جدا میشود. این کار دوباره اصل Separation of Concerns (تفکیک مسئولیتها) را بهکار میگیرد و آزمونپذیری نرمافزار را نیز افزایش میدهد.
Endpoint زیر همان نمونهٔ POST /countries فصل قبل است.
app.MapPost("/countries", (
[FromBody] Country country,
IValidator<Country> validator,
ICountryMapper mapper,
ICountryService countryService) => {
var validationResult = validator.Validate(country);
if (validationResult.IsValid)
{
var countryDto = mapper.Map(country);
return Results.CreatedAtRoute(
"countryById",
new {
Id = countryService.CreateOrUpdate(countryDto)
});
}
return Results.ValidationProblem(
validationResult.ToDictionary()
);
});
Listing 5-1 — مرور Endpoint ایجاد کشور
بهجای نگهداشتن این Lambda در Program.cs، یک پوشهٔ Endpoints و کلاس CountryEndpoints در پروژهٔ API ساخته میشود؛ ساختاری شبیه Controllerها، اما مخصوص Minimal API. انتقال این کد به لایهای دیگر ضروری نیست، زیرا همچنان به وب وابسته است؛ برای نمونه Attribute FromBody به اسمبلی Microsoft.AspNetCore.Mvc تعلق دارد.
شکل ۵-۱ — ساختار API پس از ایجاد پوشهٔ اختصاصی برای توابع Endpoint
متد PostCountry همان ورودیها و رفتار Lambda قبلی را در یک تابع مستقل قرار میدهد. نامگذاری متد از ترکیب فعل HTTP و Route پیروی میکند.
using AspNetCore8MinimalApis.Mapping.Interfaces;
using AspNetCore8MinimalApis.Models;
using Domain.Services;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
namespace AspNetCore8MinimalApis.Endpoints;
public static class CountryEndpoints
{
public static IResult PostCountry(
[FromBody] Country country,
IValidator<Country> validator,
ICountryMapper mapper,
ICountryService countryService)
{
var validationResult = validator.Validate(country);
if (validationResult.IsValid)
{
var countryDto = mapper.Map(country);
return Results.CreatedAtRoute(
"countryById",
new {
Id = countryService.CreateOrUpdate(countryDto)
});
}
return Results.ValidationProblem(
validationResult.ToDictionary()
);
}
}
Listing 5-2 — کلاس CountryEndpoints و متد PostCountry
اکنون Program.cs بهجای Lambda مستقیماً متد CountryEndpoints.PostCountry را به Route متصل میکند.
using AspNetCore8MinimalApis.Endpoints;
using AspNetCore8MinimalApis.Mapping;
using AspNetCore8MinimalApis.Mapping.Interfaces;
using BLL.Services;
using Domain.Services;
using FluentValidation;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped<ICountryMapper, CountryMapper>();
builder.Services.AddScoped<ICountryService, CountryService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1.0",
new OpenApiInfo {
Title = "ASP.NET Core 8 minimal APIs"
});
});
var app = builder.Build();
app.UseSwagger().UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1.0/swagger.json",
"Version 1.0");
});
app.MapPost("/countries", CountryEndpoints.PostCountry);
app.Run();
Listing 5-3 — Program.cs پس از جایگزینی Lambda با متد static
میتوان این ساختار را با Route Group ترکیب کرد. کلاس CountryGroup همهٔ Endpointهای مرتبط با کشور را ثبت میکند.
using AspNetCore8MinimalApis.Endpoints;
namespace AspNetCore8MinimalApis.RouteGroups;
public static class CountryGroup
{
public static void AddCountryEndpoints(
this WebApplication app)
{
var group = app.MapGroup("/countries");
group.MapPost("/", CountryEndpoints.PostCountry);
// Other endpoints in the same group
}
}
Listing 5-4 — کلاس static با نام CountryGroup
متد Extension با نام AddCountryEndpoints ثبت Endpointها در Program.cs را باز هم سادهتر میکند.
... Code
app.AddCountryEndpoints();
app.Run();
Listing 5-5 — Program.cs سادهشده با AddCountryEndpoints
همین الگو را میتوان برای بخشهای دیگر API نیز تکرار کرد: یک کلاس برای Route Group و یک کلاس برای توابع static هر حوزه.
Custom Parameter Binding (اتصال سفارشی پارامتر)
در عمل ممکن است مصرفکنندهای داده را با قالبی غیرمعمول ارسال کند؛ بهویژه وقتی با سامانههای Legacy قدیمی سروکار دارد. در چنین شرایطی Custom Parameter Binding اجازه میدهد قالب ورودی مشتری را بپذیرید و آن را به دادهای قابلاستفاده برای برنامه تبدیل کنید.
ASP.NET Core 8 دو نوع اصلی اتصال سفارشی را پشتیبانی میکند:
- دادهٔ Header، Query String یا Route.
- دادهٔ Body و Form Data.
نمونهٔ اتصال سفارشی از Header
فرض کنید مشتری در یک درخواست GET فهرست شناسههای کشور را در Header بهشکل 1-2-3 میفرستد. کلاس CountryIds یک Property از نوع List<int> دارد و متد static با نام TryParse را پیادهسازی میکند. ASP.NET Core این امضا را میشناسد و برای Binding فراخوانی میکند.
namespace AspNetCore8MinimalApis.Models;
public class CountryIds
{
public List<int> Ids { get; set; }
public static bool TryParse(
string? value,
IFormatProvider? provider,
out CountryIds countryIds)
{
countryIds = new CountryIds();
countryIds.Ids = new List<int>();
try
{
if (value is not null && value.Contains("-"))
{
countryIds.Ids = value.Split('-').Select(int.Parse).ToList();
return true;
}
return false;
}
catch
{
return false;
}
}
}
Listing 5-6 — کلاس CountryIds
Endpoint زیر CountryIds را از Header دریافت میکند. چون خود TryParse اطلاعی از محل ورودی ندارد، پارامتر با [FromHeader] مشخص شده است.
app.MapGet("/countries/ids", ([FromHeader] CountryIds ids) =>
{
Results.NoContent();
});
Listing 5-7 — GET /countries/ids با CountryIds بهعنوان پارامتر
شکل ۵-۲ — درخواست GET /countries/ids در Postman
با قرار دادن Breakpoint پس از Binding، فهرستی از اعداد صحیح مشاهده میشود که از رشتهٔ Header با موفقیت ساخته شده است.
شکل ۵-۳ — اجرای GET /countries/ids و نتیجهٔ Binding
نمونهٔ اتصال سفارشی از Form Data
اتصال سفارشی دادههای Body و Form Data کمی متفاوت است. بهجای TryParse از متد static با نام BindAsync استفاده میشود که HttpContext را دریافت میکند. از طریق Context میتوان مستقیم به دادههای Form دسترسی داشت و در نتیجه Attributeهایی مانند FromForm و FromBody روی پارامترهای Endpoint لازم نیستند.
فرض کنید مشتری فایلی را همراه با Metadata میفرستد، اما شیء Country را بهجای Propertyهای جداگانه، در یک فیلد Form با نام Country و مقدار JSON مانند {"Id":1,"Description":"Canada","FlagUri":"","Name":""} قرار میدهد.
کلاس Country در BindAsync مقدار فیلد Form را میخواند و با System.Text.Json Deserialize میکند.
using System.Reflection;
using System.Text.Json;
namespace AspNetCore8MinimalApis.Models;
public class Country
{
/// <summary>
/// The country Id
/// </summary>
public int? Id { get; set; }
/// <summary>
/// The country name
/// </summary>
public string Name { get; set; }
/// <summary>
/// The country description
/// </summary>
public string Description { get; set; }
/// <summary>
/// The country flag URI
/// </summary>
public string FlagUri { get; set; }
public static ValueTask<Country> BindAsync(HttpContext context,
ParameterInfo parameter)
{
var countryFromValue = context.Request.Form["Country"];
var result = JsonSerializer.Deserialize<Country>(countryFromValue);
return ValueTask.FromResult(result);
}
}
Listing 5-8 — کلاس Country با BindAsync سفارشی
Endpoint بارگذاری اکنون IFormFile و Country را بدون Attributeهای Binding دریافت میکند.
app.MapPost("/countries/upload", (
IFormFile file,
Country country) =>
{
Results.NoContent();
});
Listing 5-9 — POST /countries/upload
شکل ۵-۴ — درخواست POST /countries/upload در Postman
شکل ۵-۵ — اجرای Endpoint و نتیجهٔ Custom Binding
این تکنیک برای زمانی مفید است که ناچارید دادههای ارسالی مشتری را با قالبهای غیرمتعارف دریافت کنید.
استفاده از Middlewareها
Middleware در فصل ۲ معرفی شد؛ اینجا رفتار و پیادهسازی آن با جزئیات بیشتری بررسی میشود. برخی Middlewareها مانند MapGet، UseSwagger و Run از قبل دیده شدهاند، و برخی دیگر را میتوان با منطق دلخواه خود برنامهنویسی کرد.
انواع قابل سفارشیسازی که در این بخش بررسی میشوند عبارتاند از:
MapMapWhenRunUseUseWhenUseMiddleware<T>
این Middlewareها را میتوان به سه دسته تقسیم کرد:
- ایجاد شاخهٔ جدید و Short-Circuit کردن Pipeline اصلی:
Map و MapWhen. این شاخهها در صورت نیاز قابل تودرتو شدن هستند.
- اجرای میزبان برنامه تا پایان: مسئولیت
Run است. وجود Run برای راهاندازی نهایی Pipeline ضروری است.
- اجرا در شاخهٔ فعلی بدون ساخت شاخهٔ جدید:
Use، UseWhen و UseMiddleware<T>. Use کد Inline اجرا میکند و UseMiddleware<T> همان منطق را در یک کلاس مستقل کپسوله میکند. برای ادامهٔ Pipeline باید Delegate بعدی فراخوانی شود.
میتوان انواع مختلف را با هم ترکیب کرد. اگر در UseWhen یک Run تودرتو قرار دهید، Pipeline اصلی Short-Circuit میشود. همچنین MapGet و MapPost با Middleware Map یکسان نیستند و شاخهٔ جدیدی مانند آن ایجاد نمیکنند. نسخههای دارای پسوند When تنها وقتی اجرا میشوند که شرط تعریفشده برقرار باشد. Middleware Map نیز وقتی Route مشخصشده Match شود یک شاخهٔ جدید آغاز میکند.
شکل ۵-۶ — خلاصهٔ رفتار Middlewareها
MapWhen
در نمونهٔ بعد، Endpoint GET /test در Pipeline اصلی قرار دارد. اگر Query String دارای پارامتر q باشد، MapWhen شاخهٔ دیگری شامل Use و Run ایجاد میکند. چون شاخهٔ جدید به Run ختم میشود، اجرای Pipeline اصلی ادامه پیدا نمیکند.
app.MapGet("/test", () =>
{
return Results.Ok("Test endpoint has been executed");
});
app.MapWhen(ctx => !string.IsNullOrEmpty(ctx.Request.Query["q"].ToString()),
builder => {
builder.Use(async (context, next) =>
{
app.Logger.LogInformation("New middleware pipeline has been invoked");
await next();
});
builder.Run(async context =>
{
app.Logger.LogInformation("New pipeline initiated will end here");
await Task.CompletedTask;
});
});
app.Run();
Listing 5-10 — GET /test پیش از MapWhen
Logging کنسول در این مثالها فقط برای مشاهدهٔ ترتیب اجرا استفاده میشود و Logging بهصورت مفصل در فصل ۸ توضیح داده خواهد شد.
شکل ۵-۷ — فراخوانی GET /test بدون q؛ فقط Pipeline اصلی اجرا میشود
شکل ۵-۸ — فراخوانی GET /test با q؛ شاخهٔ MapWhen اجرا و Pipeline اصلی متوقف میشود
Map
Map نیز با Match شدن Route یک شاخهٔ جدید ایجاد میکند. در نمونهٔ زیر Route /test بدون توجه به فعل HTTP Match میشود.
app.Map(new PathString("/test"),
builder =>
{
builder.Use(async (context, next) =>
{
app.Logger.LogInformation("New middleware pipeline branch has been initiated");
await next();
});
builder.Run(async context =>
{
app.Logger.LogInformation("New middleware pipeline will end here");
await Task.CompletedTask;
});
});
Listing 5-11 — Middleware نوع Map
Use و UseWhen
نمونهٔ بعد یک Use پیش از Endpoint، یک Use پس از آن و سپس یک UseWhen شرطی را نشان میدهد. شاخهٔ شرطی حاوی یک Run است.
app.Use(async (context, next) =>
{
app.Logger.LogInformation("Middleware 1 executed");
await next();
});
app.MapGet("/test", () =>
{
app.Logger.LogInformation("Endpoint GET /test has been invoked");
return Results.Ok();
});
app.Use(async (context, next) =>
{
app.Logger.LogInformation("Middleware 2 executed");
await next();
});
app.UseWhen(ctx => !string.IsNullOrEmpty(ctx.Request.Query["p"].ToString()),
builder => {
builder.Use(async (context, next) =>
{
app.Logger.LogInformation("Nested middleware executed");
await next();
});
builder.Run(async (context) =>
{
app.Logger.LogInformation("End of the pipeline end");
await Task.CompletedTask;
});
});
app.Run();
Listing 5-12 — GET /test میان Useها و پیش از UseWhen
اگر p وجود نداشته باشد، UseWhen اجرا نمیشود و Endpoint اصلی قابل دسترسی است. اگر p ارسال شود، UseWhen اجرا شده و Run تودرتو Pipeline را پیش از Endpoint خاتمه میدهد.
شکل ۵-۹ — اجرای GET /test بدون p
شکل ۵-۱۰ — اجرا نشدن GET /test پس از فعال شدن UseWhen و Run تودرتو
اگر Run تودرتو حذف شود، UseWhen منطق شرطی خود را اجرا میکند ولی Pipeline را ادامه میدهد و Endpoint فراخوانی میشود.
app.UseWhen(ctx => !string.IsNullOrEmpty(ctx.Request.Query["p"].ToString()),
builder => {
builder.Use(async (context, next) =>
{
app.Logger.LogInformation("Nested middleware executed");
await next();
});
});
Listing 5-13 — UseWhen بدون Run تودرتو
شکل ۵-۱۱ — اجرای GET /test با p پس از حذف Run تودرتو
UseMiddleware<T>
UseMiddleware<T> از نظر رفتار مشابه Use است، اما منطق Middleware را در یک کلاس مستقل قرار میدهد. نویسنده این روش را تمیزتر میداند.
namespace AspNetCore8MinimalApis.Middlewares;
public class LoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<LoggingMiddleware> _logger;
public LoggingMiddleware(RequestDelegate next,
ILogger<LoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task Invoke(HttpContext context)
{
_logger.LogInformation("LoggingMiddleware executed");
await _next(context);
}
}
Listing 5-14 — کلاس LoggingMiddleware
متد Invoke منطق Middleware را اجرا میکند و سپس RequestDelegate بعدی را صدا میزند تا Pipeline ادامه پیدا کند.
app.Use(async (context, next) =>
{
app.Logger.LogInformation("Middleware 1 executed");
await next();
});
app.MapGet("/test", () =>
{
app.Logger.LogInformation("Endpoint GET /test has been invoked");
return Results.Ok();
});
app.UseMiddleware<LoggingMiddleware>();
app.Run();
Listing 5-15 — ثبت LoggingMiddleware در Pipeline
شکل ۵-۱۲ — ترتیب اجرای Use، UseMiddleware و GET /test
Middlewareها انعطاف بسیار زیادی برای اجرای کد در Pipeline فراهم میکنند. Logging فقط یک نمونهٔ ساده است؛ همین سازوکار برای رفتارهای دیگر، از جمله گردآوری Metrics، نیز قابل استفاده است.
Endpoint Filters (فیلترهای Endpoint)
ASP.NET Core 8 اجازه میدهد برای یک Endpoint خاص، منطق قبل و بعد از اجرای آن قرار گیرد. این قابلیت برای سنجش زمان اجرای یک Endpoint، بررسی Performance (کارایی) آن یا اعتبارسنجی ورودیها بهشکل تمیزتر مفید است.
از نظر الگوی اجرا، Endpoint Filter شبیه Middleware نوع Use است: هر دو یک Delegate به نام next دارند و میتوانند قبل و بعد از آن کد اجرا کنند.
نمونهٔ زیر Endpoint با نام GET /longrunning را پنج ثانیه بهصورت مصنوعی متوقف میکند و زمان اجرا را با Stopwatch اندازه میگیرد.
app.MapGet("/longrunning", async () =>
{
await Task.Delay(5000);
return Results.Ok();
}).AddEndpointFilter(async (filterContext, next) =>
{
long startTime = Stopwatch.GetTimestamp();
var result = await next(filterContext);
TimeSpan elapsedTime = Stopwatch.GetElapsedTime(startTime);
app.Logger.LogInformation($"GET /longrunning endpoint took {elapsedTime.TotalSeconds} to execute");
return result;
});
Listing 5-16 — اندازهگیری زمان Endpoint با Filter درونخطی
شکل ۵-۱۳ — خروجی کنسول پس از اجرای Endpoint و ثبت زمان
برای کد تمیزتر، Filter را میتوان در کلاسی مستقل که IEndpointFilter را پیادهسازی میکند قرار داد.
using System.Diagnostics;
namespace AspNetCore8MinimalApis.EndpointFilters;
public class LogPerformanceFilter : IEndpointFilter
{
private readonly ILogger<LogPerformanceFilter> _logger;
public LogPerformanceFilter(ILogger<LogPerformanceFilter> logger)
{
_logger = logger;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
_logger.LogInformation("GET /longrunning endpoint getting executed");
long startTime = Stopwatch.GetTimestamp();
var result = await next(context);
TimeSpan elapsedTime = Stopwatch.GetElapsedTime(startTime);
_logger.LogInformation($"GET /longrunning endpoint took {elapsedTime.TotalSeconds} to execute");
return result;
}
}
Listing 5-17 — کلاس LogPerformanceFilter
این Filter سپس با Overload جنریک AddEndpointFilter<T> متصل میشود.
app.MapGet("/longrunning", async () =>
{
await Task.Delay(5000);
return Results.Ok();
}).AddEndpointFilter<LogPerformanceFilter>();
Listing 5-18 — اتصال LogPerformanceFilter به Endpoint
ترکیب FluentValidation با Endpoint Filter
یکی از کاربردهای مفید Endpoint Filter، ساخت یک Filter جنریک برای اعتبارسنجی ورودی است. کلاس InputValidatorFilter<T> Validator مناسب را از DI دریافت میکند، آرگومان اول Endpoint را با GetArgument<T>(0) میگیرد و آن را بهصورت ناهمگام اعتبارسنجی میکند. اگر نتیجه نامعتبر باشد ValidationProblem برگردانده میشود؛ در غیر این صورت اجرای Endpoint ادامه مییابد.
using FluentValidation;
namespace AspNetCore8MinimalApis.EndpointFilters;
public class InputValidatorFilter<T> : IEndpointFilter
{
private readonly IValidator<T> _validator;
public InputValidatorFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
T? inputData = context.GetArgument<T>(0);
if (inputData is not null)
{
var validationResult = await _validator.ValidateAsync(inputData);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(
validationResult.ToDictionary()
);
}
}
return await next.Invoke(context);
}
}
Listing 5-19 — کلاس جنریک InputValidatorFilter<T>
تصاویر منبع مرتبط با این بخش