مقالات › ASP.NET Core

میان‌افزار (Middleware) در ASP.NET Core؛ Pipeline درخواست‌ها واقعاً چطور کار می‌کند

Pipeline درخواست در ASP.NET Core را عمیق بشناسید: اجرای میان‌افزارها، اهمیت ترتیب، تفاوت Use و Run و Map، نوشتن Middleware سفارشی و مثال‌های واقعی.

هر درخواست HTTP که به یک برنامه‌ی ASP.NET Core می‌رسد، از یک Pipeline متشکل از اجزایی به نام میان‌افزار (Middleware) عبور می‌کند. احراز هویت، CORS، فایل‌های استاتیک، مسیریابی و مدیریت خطا هرکدام یک میان‌افزار هستند. وقتی بفهمید این Pipeline چطور کار می‌کند، رفتارهای عجیب را سریع دیباگ می‌کنید و قابلیت‌های سراسری خودتان را تمیز اضافه می‌کنید.

Pipeline مثل یک زنجیر

میان‌افزارها به هم زنجیر شده‌اند. هرکدام HttpContext و ارجاعی به میان‌افزار بعدی دریافت می‌کند و می‌تواند:

  1. قبل از صدا زدن بعدی کاری انجام دهد،
  2. میان‌افزار بعدی را صدا بزند (یا تصمیم بگیرد صدا نزند — یعنی اتصال کوتاه)،
  3. بعد از برگشت آن کاری انجام دهد.
Request  →  [Exception Handler] → [HTTPS] → [Static Files] → [Routing] → [Auth] → [Endpoint]
Response ←  [Exception Handler] ← [HTTPS] ← [Static Files] ← [Routing] ← [Auth] ← [Endpoint]

درخواست‌ها به داخل جریان پیدا می‌کنند و پاسخ‌ها از همان اجزا با ترتیب معکوس برمی‌گردند؛ مثل لایه‌های پیاز.

تفاوت Use، Run و Map

var app = builder.Build();

// Use: کاری انجام بده و بعد میان‌افزار بعدی را صدا بزن
app.Use(async (context, next) =>
{
    Console.WriteLine($"→ {context.Request.Method} {context.Request.Path}");
    await next(context);
    Console.WriteLine($"← {context.Response.StatusCode}");
});

// Map: شاخه‌بندی Pipeline بر اساس مسیر
app.Map("/health-raw", branch =>
{
    branch.Run(async context => await context.Response.WriteAsync("OK"));
});

// Run: میان‌افزار پایانی — هرگز next را صدا نمی‌زند
app.Run(async context => await context.Response.WriteAsync("Hello"));
  • Use — میان‌افزاری اضافه می‌کند که می‌تواند next را صدا بزند.
  • Run — میان‌افزار پایانی اضافه می‌کند؛ هر چیزی بعد از آن ثبت شود هرگز اجرا نمی‌شود.
  • Map / MapWhen — برای درخواست‌های منطبق یک شاخه‌ی جدا می‌سازد.
  • UseWhen — میان‌افزار را به‌صورت شرطی اضافه می‌کند اما دوباره به Pipeline اصلی برمی‌گردد.

ترتیب — خیلی — مهم است

میان‌افزارها به همان ترتیبی که ثبت می‌شوند اجرا می‌شوند. ترتیب پیشنهادی برای یک API معمولی:

app.UseExceptionHandler();      // ۱. هر خطایی در ادامه را بگیرد
app.UseHsts();                  // ۲. هدرهای امنیتی (Production)
app.UseHttpsRedirection();      // ۳.
app.UseStaticFiles();           // ۴. برای فایل‌ها اتصال کوتاه می‌زند
app.UseRouting();               // ۵. انتخاب Endpoint
app.UseCors();                  // ۶. بعد از Routing، قبل از Auth
app.UseAuthentication();        // ۷. تو کی هستی؟
app.UseAuthorization();         // ۸. اجازه داری؟
app.UseRateLimiter();           // ۹.
app.MapControllers();           // ۱۰. اجرای Endpoint

باگ‌های رایج ناشی از ترتیب اشتباه:

اشتباه علامت
UseAuthorization قبل از UseAuthentication همه‌ی Endpointهای [Authorize] خطای 401 می‌دهند
UseCors بعد از Endpoint یا Auth خطای CORS مرورگر روی درخواست‌های Preflight
ثبت دیرهنگام Exception Handler خطاهای میان‌افزارهای قبلی مدیریت نمی‌شوند
فایل‌های استاتیک بعد از Auth فایل‌های عمومی به‌طور غیرمنتظره ورود لازم دارند

نکته: در ‎.NET 6 به بعد، WebApplication به‌طور ضمنی UseRouting و اجرای Endpoint را اضافه می‌کند، اما فراخوانی صریح همچنان رعایت می‌شود. وقتی ترتیب مهم است (CORS، Auth) صریح بنویسید.

نوشتن میان‌افزار سفارشی

میان‌افزار مبتنی بر قرارداد

public class RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        context.Response.OnStarting(() =>
        {
            context.Response.Headers["X-Response-Time-ms"] = stopwatch.ElapsedMilliseconds.ToString();
            return Task.CompletedTask;
        });

        await next(context);

        logger.LogInformation("{Method} {Path} → {StatusCode} in {Elapsed} ms",
            context.Request.Method, context.Request.Path,
            context.Response.StatusCode, stopwatch.ElapsedMilliseconds);
    }
}

// ثبت
app.UseMiddleware<RequestTimingMiddleware>();

میان‌افزار مبتنی بر قرارداد یک بار ساخته می‌شود (شبیه Singleton). سرویس‌های Scoped را به‌عنوان پارامتر InvokeAsync تزریق کنید، نه در سازنده:

public async Task InvokeAsync(HttpContext context, AppDbContext db) { ... }

میان‌افزار مبتنی بر Factory با IMiddleware

public class TenantMiddleware(ITenantStore tenants) : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var tenantId = context.Request.Headers["X-Tenant"].ToString();
        if (string.IsNullOrEmpty(tenantId) || !await tenants.ExistsAsync(tenantId))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new { message = "Unknown tenant" });
            return; // اتصال کوتاه
        }

        context.Items["TenantId"] = tenantId;
        await next(context);
    }
}

builder.Services.AddScoped<TenantMiddleware>();
app.UseMiddleware<TenantMiddleware>();

IMiddleware برای هر درخواست از DI گرفته می‌شود، پس سازنده‌اش می‌تواند سرویس‌های Scoped بپذیرد.

یک Extension Method مرتب

public static class RequestTimingExtensions
{
    public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app) =>
        app.UseMiddleware<RequestTimingMiddleware>();
}

app.UseRequestTiming();

ایده‌های واقعی برای میان‌افزار

شناسه‌ی همبستگی (Correlation ID)

به هر درخواست یک شناسه بدهید تا لاگ‌های سرویس‌های مختلف به هم وصل شوند:

app.Use(async (context, next) =>
{
    var id = context.Request.Headers["X-Correlation-ID"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
    context.Response.Headers["X-Correlation-ID"] = id;

    using (logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = id }))
    {
        await next(context);
    }
});

هدرهای امنیتی

app.Use(async (context, next) =>
{
    var h = context.Response.Headers;
    h["X-Content-Type-Options"] = "nosniff";
    h["X-Frame-Options"] = "DENY";
    h["Referrer-Policy"] = "strict-origin-when-cross-origin";
    await next(context);
});

حالت تعمیر و نگهداری

app.UseWhen(_ => maintenance.IsEnabled, branch =>
    branch.Run(async ctx =>
    {
        ctx.Response.StatusCode = 503;
        await ctx.Response.WriteAsync("We'll be back soon.");
    }));

میان‌افزار یا فیلتر؟

ASP.NET Core برای MVC فیلتر هم دارد (Action Filter، Exception Filter و...).

میان‌افزار فیلتر
برای چه اجرا می‌شود همه‌ی درخواست‌ها (حتی فایل استاتیک و Minimal API) اکشن‌های MVC/Razor (و Endpoint Filter برای Minimal API)
از چه خبر دارد HttpContext آرگومان‌های اکشن، ModelState، نتیجه‌ی اکشن
مناسب دغدغه‌های سراسری HTTP: لاگ، هدر، Auth، CORS دغدغه‌های مخصوص اکشن: اعتبارسنجی، ممیزی یک اکشن خاص

دام‌های رایج

  1. نوشتن در پاسخ بعد از اینکه next() آن را شروع کرده — بعد از شروع بدنه، هدرها قابل تغییر نیستند. از Response.OnStarting استفاده کنید.
  2. فراموش کردن صدا زدن next — درخواست بی‌صدا متوقف می‌شود.
  3. خواندن دوباره‌ی بدنه‌ی درخواست — اول context.Request.EnableBuffering() را صدا بزنید و موقعیت Stream را برگردانید.
  4. کار سنگین در میان‌افزار برای هر درخواست — برای همه‌چیز اجرا می‌شود، حتی فایل‌های استاتیک و Health Check.

جمع‌بندی

Pipeline در ASP.NET Core زنجیره‌ای از میان‌افزارهاست که هرکدام درخواست را در مسیر ورود و پاسخ را در مسیر خروج پردازش می‌کنند. میان‌افزارها را به ترتیب درست ثبت کنید، Use/Run/Map را آگاهانه استفاده کنید و برای دغدغه‌های سراسری مثل زمان‌سنجی، Correlation ID و هدرها میان‌افزار سفارشی بنویسید. تسلط بر این Pipeline کنترل دقیقی روی هر درخواست برنامه به شما می‌دهد.

پرسش‌های متداول

آیا میان‌افزار برای هر درخواست اجرا می‌شود؟

بله، مگر اینکه روی یک شاخه (Map، MapWhen) باشد یا میان‌افزار قبلی Pipeline را کوتاه کند.

مدیریت سراسری خطا کجا قرار بگیرد؟

در ابتدای Pipeline تا همه‌ی میان‌افزارها و Endpointها را پوشش دهد.

آیا میان‌افزار به Route Values دسترسی دارد؟

بعد از UseRouting() بله: context.GetRouteValue("id") و context.GetEndpoint() در دسترس هستند.