یادداشت‌های بک‌اند ۳: Minimal APIs برای یک سرویس واقعی .NET

یادداشت‌های بک‌اند ۳: Minimal APIs برای یک سرویس واقعی .NET

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های آزمون به یک بانک سؤال ساخت‌یافته،‏ با یک مدل زبانی در چرخه.

این یادداشت به انگلیسی هم منتشر شده است: نسخهٔ انگلیسی.

بازگشت به همهٔ یادداشت‌ها