
Minimal APIs در ذهن خیلیها هنوز ابزار نمونهسازی است. اولین دمویی که همه میبینند یک Program.cs تنها با پنج لامبدا است و نتیجهگیری طبیعی این است که برای پروژهٔ جدی مناسب نیست. اینطور نیست. لامبداها اختیاریاند و تقریباً هر چیزی که یک سرویس مبتنی بر کنترلر لازم دارد اینجا هم هست: گروهبندی، فیلتر، نتیجههای نوعدار، فرادادهٔ OpenAPI و از .NET 10 به بعد، اعتبارسنجی داخلی. چیزی که Minimal APIs به شما نمیدهد ساختار است؛ آن را باید خودتان انتخاب کنید.
در NovaTech توسعهٔ میکروسرویسهای .NET 6/8 را رهبری میکنم و استانداردهای بازبینی کد را برای قابلیتهای پرترافیک تعیین میکنم. در بازبینی، سؤالی که برایم مهم است این است که آیا پیدا کردن، تست کردن و تغییر دادن اندپوینت پنجاهم به همان راحتیِ اولی است. این یادداشت ساختاری است که برای رسیدن به آن به کار میبرم، بهاضافهٔ مسیر ارتقا برای سرویسهایی که هنوز روی .NET 6 یا .NET 8 ماندهاند.
پوشه بر اساس قابلیت، نه بر اساس لایه
کد را بر اساس کاری که برای کاربر انجام میدهد مرتب کنید. هر چیزی که به آزمونها مربوط است در یک پوشه است: اندپوینتها، نوعهای درخواست و پاسخ، سرویس و کوئریهایش. برنامهنویس تازهواردی که دنبال «ساختن آزمون چطور کار میکند» میگردد، بهجای پنج پوشه فقط یکی را باز میکند.
src/Exams.Api/
Program.cs
Features/
Catalog/
CatalogEndpoints.cs
CatalogQueries.cs
Exams/
ExamEndpoints.cs
CreateExamRequest.cs
ExamService.cs
ExamsFeature.cs // AddExamsFeature(): registers this feature's services
Infrastructure/
ValidationFilter.cs
RedisHealthCheck.cs
Program.cs به فهرست مطالب تبدیل میشود: سرویسها و میانافزارهای مشترک را ثبت و پیکربندی میکند و بعد برای هر قابلیت یک متد MapXxxEndpoints() را صدا میزند:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);
builder.Services.AddAuthentication().AddJwtBearer(); // settings come from configuration
builder.Services.AddAuthorizationBuilder()
.AddPolicy("ExamAuthor", p => p.RequireRole("author"));
builder.Services.AddOutputCache(o =>
o.AddPolicy("Catalog", b => b.Expire(TimeSpan.FromMinutes(5)).Tag("catalog")));
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.AddPolicy("per-client", ctx => RateLimitPartition.GetTokenBucketLimiter(
ctx.User.Identity?.Name ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "anonymous",
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 20,
TokensPerPeriod = 10,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 0,
}));
});
builder.Services.AddHealthChecks()
.AddCheck<RedisHealthCheck>("redis", tags: new[] { "ready" });
builder.Services.AddCatalogFeature();
builder.Services.AddExamsFeature();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseOutputCache();
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
app.MapCatalogEndpoints();
app.MapExamEndpoints();
app.Run();
public partial class Program { } // lets WebApplicationFactory<Program> find the entry point
گروههای مسیر و نتیجههای نوعدار
هر قابلیت یک متد الحاقی دارد که یک گروه مسیر میسازد. گروه هر چیزی را که اندپوینتهایش مشترک دارند حمل میکند: پیشوند آدرس، برچسب OpenAPI، سیاست مجوزدهی و محدودیت نرخ. هر اندپوینت فقط چیزی را اعلام میکند که مخصوص خودش است. گروهها تودرتو هم میشوند: یک گروه فرزند برای /api/exams/{id}/sections پیشوند و سیاستهای گروه والد را به ارث میبرد و منابع تودرتو لازم نیست آنها را تکرار کنند.
public static class ExamEndpoints
{
public static IEndpointRouteBuilder MapExamEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/exams")
.WithTags("Exams")
.RequireAuthorization("ExamAuthor")
.RequireRateLimiting("per-client");
group.MapGet("/{id:int}", GetById);
group.MapPost("/", Create).AddEndpointFilter<ValidationFilter<CreateExamRequest>>();
return app;
}
private static async Task<Results<Ok<ExamDto>, NotFound>> GetById(
int id, IExamService exams, CancellationToken ct) =>
await exams.FindAsync(id, ct) is { } exam ? TypedResults.Ok(exam) : TypedResults.NotFound();
private static async Task<Created<ExamDto>> Create(
CreateExamRequest request, IExamService exams, IOutputCacheStore cache, CancellationToken ct)
{
var exam = await exams.CreateAsync(request, ct);
await cache.EvictByTagAsync("catalog", ct);
return TypedResults.Created($"/api/exams/{exam.Id}", exam);
}
}
هندلرها متدهای استاتیک نامدارند، نه لامبدا. در stack trace درست دیده میشوند، مستقیم قابل تست واحدند و فایل مثل یک فهرست خوانده میشود. برگرداندن TypedResults با امضای Results<Ok<ExamDto>, NotFound> دو کار میکند: کامپایلر نمیگذارد کد وضعیتی برگردانید که اعلامش نکردهاید، و سند OpenAPI همهٔ پاسخهای ممکن را بدون هیچ attribute اضافهای یاد میگیرد.
اعتبارسنجی
در .NET 10 خود فریمورک پارامترهای Minimal API را اعتبارسنجی میکند. builder.Services.AddValidation() را صدا بزنید، attributeهای DataAnnotations را روی نوعهای درخواست بگذارید، و درخواست نامعتبر پیش از رسیدن به هندلر با پاسخ ۴۰۰ و فهرست خطاها برمیگردد.
در .NET 6 و .NET 8 این کار با خودتان است و تمیزترین جایش فیلتر اندپوینت است. (فیلترها از .NET 7 آمدند، پس در .NET 6 بررسی باید ابتدای هندلر انجام شود.) این فیلتر عمومی از DataAnnotations استفاده میکند، پس مهاجرت بعدی به AddValidation() یعنی حذف فیلتر، در حالی که قاعدهها سر جایشان میمانند:
public sealed class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var arg = ctx.Arguments.OfType<T>().FirstOrDefault();
if (arg is null)
return TypedResults.Problem("Request body is missing.", statusCode: StatusCodes.Status400BadRequest);
var results = new List<ValidationResult>();
if (Validator.TryValidateObject(arg, new ValidationContext(arg), results, validateAllProperties: true))
return await next(ctx);
var errors = results
.SelectMany(r => r.MemberNames.DefaultIfEmpty(""), (r, member) => (member, r.ErrorMessage))
.GroupBy(x => x.member)
.ToDictionary(g => g.Key, g => g.Select(x => x.ErrorMessage ?? "Invalid value.").ToArray());
return TypedResults.ValidationProblem(errors);
}
}
public sealed record CreateExamRequest
{
[Required, StringLength(200)] public string Title { get; init; } = "";
[Range(1, 600)] public int DurationMinutes { get; init; }
}
یک دام هست: برای Validator.TryValidateObject، attributeهایی که روی پارامترهای یک positional record گذاشته میشوند، مثل record CreateExamRequest([Required] string Title)، به پارامتر سازنده میچسبند نه به property، و validator اصلاً آنها را نمیبیند. یا مثل نمونهٔ بالا از property استفاده کنید یا هدف را صریحاً با [property: Required] مشخص کنید. اعتبارسنجیای هم که به پایگاهداده نیاز دارد (مثلاً یکتا بودن عنوان) جایش در هندلر یا سرویس است، نه در attribute.
خطاها در قالب ProblemDetails
AddProblemDetails() در کنار UseExceptionHandler() و UseStatusCodePages() استثناهای مدیریتنشده و پاسخهای خطای بدون بدنه را به قالب problem details (استاندارد RFC 9457، جانشین RFC 7807) تبدیل میکند، همراه با یک traceId که با لاگها جور است. کلاینت همهجا یک قالب خطا میبیند، حتی برای پاسخهای خالیای که میانافزارهایی مثل محدودکنندهٔ نرخ تولید میکنند. برای نگاشت استثناهای خودتان به کدهای وضعیت مشخص، .NET 8 رابط IExceptionHandler را اضافه کرد که با AddExceptionHandler<T>() ثبت میشود. در محیط عملیاتی هیچوقت پیام استثنا یا stack trace را در پاسخ نگذارید؛ traceId برای پیدا کردنشان در لاگ کافی است.
کش خروجی
کش خروجی (از .NET 7 به بعد) کل پاسخ را روی سرور نگه میدارد و برای اندپوینتهای عمومی و پرخواننده، مثل فهرست آزمونهای منتشرشده، مناسب است. سیاستهای نامدار قاعدهها را یکجا نگه میدارند و برچسبها اجازه میدهند هر عملیات نوشتن دقیقاً همان چیزی را که تغییر داده باطل کند؛ همان کاری که Create در کد بالا با EvictByTagAsync("catalog") میکند.
پیش از تکیه بر آن، پیشفرضهایش را بشناسید. فقط درخواستهای GET و HEAD با پاسخ ۲۰۰ کش میشوند؛ پاسخهایی که کوکی تنظیم میکنند کش نمیشوند و درخواستهای احراز هویتشده هم همینطور. قاعدهٔ آخر یک ویژگی ایمنی است و به همین دلیل در این ساختار، فهرست آزمونها یک گروه عمومی جداست و اندپوینتهای تألیف اصلاً کش نمیشوند. مخزن پیشفرض در حافظهٔ هر نمونه است؛ اگر چند نمونه باید پاسخهای کششده و باطلسازیها را با هم شریک شوند، .NET 8 یک مخزن مبتنی بر Redis اضافه کرده است.
محدودسازی نرخ
میانافزار محدودسازی نرخ ASP.NET Core (از .NET 7 به بعد) که بر پایهٔ محدودکنندههای System.Threading.RateLimiting ساخته شده، پنجرهٔ ثابت، پنجرهٔ لغزان، سطل توکن و محدودکنندهٔ همزمانی دارد که با نام سیاست به اندپوینت یا گروه وصل میشوند. سیاست بخشبندیشدهٔ بالا به هر کاربر، یا برای درخواستهای ناشناس به هر آدرس IP، یک سطل توکن جدا میدهد. عددهای نمونه فقط جاینگهدارند؛ عدد واقعی را از ترافیک اندازهگیریشده دربیاورید.
یادتان باشد این محدودیتها جداگانه درون هر پروسه اعمال میشوند. با چهار نمونه پشت متعادلکنندهٔ بار، یک کلاینت تقریباً چهار برابر نرخ تنظیمشده سهم میگیرد. برای محافظت از هر نمونه در برابر بار اضافه همین کافی است؛ اگر سقف سراسری سختگیرانه میخواهید، آن را در gateway یا با یک مخزن مشترک اعمال کنید.
بررسی سلامت
دو اندپوینت با دو معنای متفاوت بسازید. Liveness میپرسد «پروسه زنده است؟» و هیچ وابستگیای را بررسی نمیکند؛ کاری که Predicate = _ => false انجام میدهد. Readiness میپرسد «این نمونه باید ترافیک بگیرد؟» و بررسیهایی را اجرا میکند که برچسب ready دارند. پایگاهداده را از liveness بیرون نگه دارید: اگر SQL Server یک دقیقهٔ بد داشت، میخواهید نمونهها از چرخه بیرون بروند، نه اینکه همهٔ کانتینرها همزمان ریاستارت شوند.
public sealed class RedisHealthCheck(IConnectionMultiplexer redis) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
var latency = await redis.GetDatabase().PingAsync();
return HealthCheckResult.Healthy($"PING {latency.TotalMilliseconds:F0} ms");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Redis is unreachable", ex);
}
}
}
مسیر ارتقا از .NET 6 و .NET 8
پشتیبانی .NET 6 مدتهاست تمام شده و پشتیبانی .NET 8 هم آبان ۱۴۰۵ (نوامبر ۲۰۲۶) به پایان میرسد، برای همین خیلی از سرویسها همین حالا درگیر این مهاجرتاند. هر نسخه بهطور خلاصه چه چیزی به Minimal APIs اضافه کرد:
- .NET 7: گروههای مسیر، فیلترهای اندپوینت،
TypedResultsوResults<…>، کش خروجی، محدودسازی نرخ و[AsParameters]. - .NET 8:
IExceptionHandler، اتصال فرم با محافظت antiforgery، پشتیبانی Native AOT برای Minimal APIs و سرویسهای کلیددار. - .NET 9: تولید داخلی سند OpenAPI با
AddOpenApi()وMapOpenApi()؛HybridCacheهم بعداً بهصورت بستهای جداگانه آمد و از مارس ۲۰۲۵ پایدار است. - .NET 10: اعتبارسنجی داخلی با
AddValidation()و پشتیبانی از OpenAPI 3.1.
ترتیبی که خودم پیش میگیرم: اول تغییر فریمورک هدف و بهروزرسانی بستههای Microsoft، همراه با خواندن فهرست تغییرات ناسازگار هر نسخهای که از رویش میپرید. بعد جایگزین کردن توابع کمکیِ دستساز برای پیشوند مسیر با MapGroup و منتقل کردن دغدغههای مشترک به گروهها. بعد تبدیل Results.X به TypedResults، اندپوینت به اندپوینت. در آخر هم جایگزین کردن فیلتر اعتبارسنجی با AddValidation() هر جا که DataAnnotations کافی است، و نگه داشتن اعتبارسنجی سفارشی جایی که به سرویس نیاز دارد. بعد از هر قدم، تستهای یکپارچگی را اجرا کنید.
تست با WebApplicationFactory
همین تستهای یکپارچگیاند که ارتقا و هر بازآرایی دیگری را بیدردسر میکنند. WebApplicationFactory<Program> از بستهٔ Microsoft.AspNetCore.Mvc.Testing برنامهٔ واقعی را در حافظه اجرا میکند، با همان زنجیرهٔ میانافزار، مسیریابی، سریالسازی و مدیریت خطا، و اجازه میدهد سرویسها را تکتک عوض کنید:
public sealed class CatalogApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Unknown_exam_returns_problem_details()
{
var client = factory.WithWebHostBuilder(b => b.ConfigureTestServices(services =>
{
services.RemoveAll<IExamCatalog>();
services.AddSingleton<IExamCatalog, InMemoryExamCatalog>();
})).CreateClient();
var response = await client.GetAsync("/api/catalog/999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
}
}
همین یک تست مسیریابی، هندلر، میانافزار کد وضعیت و تنظیمات ProblemDetails را با هم میسنجد؛ کاری که تست واحد هندلر بهتنهایی نمیکند. برای اندپوینتهای احراز هویتشده، یک هندلر احراز هویت آزمایشی در ConfigureTestServices ثبت کنید. برای دسترسی به داده هم یک SQL Server واقعی در کانتینر (Testcontainers این کار را ساده میکند) مشکلات کوئری و migration را پیدا میکند که یک جایگزین درونحافظهای پنهانشان میکند.
هیچکدام از این تکهها بهتنهایی تازه نیست. سودش از اینجا میآید که هر دغدغه دقیقاً یک خانه دارد و بازبین همیشه میداند کجا را نگاه کند. اگر چنین مهاجرتی در برنامه دارید، این بخشی از کارهای بکاند من است. یادداشت بعدی از .NET به پایتون میرود: تبدیل انبوهی از PDFهای آزمون به یک بانک سؤال ساختیافته، با یک مدل زبانی در چرخه.
این یادداشت به انگلیسی هم منتشر شده است: نسخهٔ انگلیسی.
