Middleware و Pipeline در ASP.NET Core
هر درخواست HTTP از یک صف از Middleware ها عبور میکند. هر Middleware میتواند کاری قبل و بعد از بقیه انجام دهد، یا درخواست را همانجا تمام کند. ترتیب این صف، رفتار برنامه را تعیین میکند.
نویسنده: bezzad
مشکل: کارهای تکراری در هر endpoint
فروشگاه ما یک API دارد. چند endpoint دارد: لیست محصولات، سبد خرید، ثبت سفارش. هر درخواست، جدا از کار اصلیاش، چند کار مشترک لازم دارد:
- ثبت لاگ. چه کسی، کدام آدرس، چقدر طول کشید؟
- مدیریت خطا. اگر خطایی پیش آمد، جواب مرتب 500 برگردد، نه متن خطای داخلی.
- شناختن کاربر. توکن را بخوان و بفهم کاربر کیست.
- چک کردن اجازه. آیا این کاربر اجازه ثبت سفارش دارد؟
اگر این کدها را در هر endpoint تکرار کنیم، دیر یا زود یکی را فراموش میکنیم. پس این کارها را یک بار و بیرون از endpoint مینویسیم. به هر کدام از این قطعهها Middleware میگوییم. به صف آنها Pipeline میگوییم.
ایده: یک صف از لایهها
هر Middleware یک تابع است که دو چیز میگیرد:
- درخواست جاری. یعنی شیء HttpContext که همه چیز درخواست و جواب داخل آن است.
- قدم بعدی. یعنی یک تابع به اسم next که Middleware بعدی را صدا میزند.
پس هر Middleware سه انتخاب دارد:
- کاری قبل از next انجام دهد. مثلاً زمان شروع را بردارد.
- متد next را صدا بزند. درخواست به لایه بعدی میرود. وقتی برگشت، کاری بعد از آن انجام دهد. مثلاً زمان را حساب کند.
- متد next را صدا نزند. درخواست همینجا تمام میشود و به لایههای بعد نمیرسد. به این کار Short-circuit میگوییم.
مثال زنده
یک درخواست انتخاب کن و ببین از کدام لایهها عبور میکند و کجا تمام میشود:
ترتیب مهم است
ترتیب Middleware ها همان ترتیبی است که در فایل Program.cs آنها را اضافه میکنی. فریمورک این ترتیب را عوض نمیکند. این ترتیب پیشنهادی برای API فروشگاه است:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseExceptionHandler(); // 1. outermost: catches errors from all inner layers
app.UseHttpsRedirection(); // 2. send http to https
app.UseStaticFiles(); // 3. images and css end here (short-circuit)
app.UseRouting(); // 4. find which endpoint matches the URL
app.UseAuthentication(); // 5. who is the user?
app.UseAuthorization(); // 6. is the user allowed to call this endpoint?
app.MapGet("/products", (ShopDb db, CancellationToken ct) =>
db.Products.ToListAsync(ct));
app.MapPost("/orders", (PlaceOrder cmd, OrderService orders, CancellationToken ct) =>
orders.PlaceAsync(cmd, ct))
.RequireAuthorization();
app.Run();
چرا این ترتیب؟ قدم به قدم:
- مدیریت خطا اول است. چون فقط خطای لایههای داخلیتر از خودش را میگیرد.
- فایلهای استاتیک قبل از احراز هویت است. عکس محصول به توکن نیاز ندارد. پس زودتر جواب میدهیم و کار اضافه نمیکنیم.
- مسیریابی (Routing) قبل از مجوز است. مجوز باید بداند کدام endpoint انتخاب شده تا قانون همان endpoint را بخواند.
- احراز هویت قبل از مجوز است. اول باید بدانیم کاربر کیست، بعد بپرسیم اجازه دارد یا نه.
نوشتن یک Middleware
روش کوتاه: یک تابع
برای زمانسنجی هر درخواست، یک تابع کوتاه کافی است:
app.Use(async (context, next) =>
{
var start = Stopwatch.GetTimestamp();
await next(context); // run the rest of the pipeline
var elapsed = Stopwatch.GetElapsedTime(start);
app.Logger.LogInformation("{Path} took {Ms} ms",
context.Request.Path, elapsed.TotalMilliseconds);
});
کد قبل از next روی مسیر رفت اجرا میشود. کد بعد از next روی مسیر برگشت اجرا میشود.
روش کامل: یک کلاس
وقتی Middleware بزرگتر است یا وابستگی دارد، آن را کلاس میکنیم. این Middleware به هر درخواست یک شناسه پیگیری (Correlation Id) میدهد تا لاگهای یک سفارش را راحت پیدا کنیم:
public sealed class CorrelationIdMiddleware(RequestDelegate next)
{
private const string Header = "X-Correlation-Id";
// Scoped services come in as parameters of InvokeAsync, not the constructor.
public async Task InvokeAsync(HttpContext context, ILogger<CorrelationIdMiddleware> logger)
{
var id = context.Request.Headers[Header].FirstOrDefault()
?? Guid.NewGuid().ToString();
// Set headers before calling next. After the body starts, headers are locked.
context.Response.Headers[Header] = id;
using (logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = id }))
{
await next(context);
}
}
}
// Program.cs
app.UseMiddleware<CorrelationIdMiddleware>();
طول عمر Middleware: یک نسخه برای همه
این نکته در مصاحبه زیاد پرسیده میشود. کلاس Middleware فقط یک بار ساخته میشود. همان یک نمونه به همه درخواستها جواب میدهد. پس رفتارش مثل Singleton است.
چرا گرفتن DbContext در سازنده خطرناک است؟
- سازنده یک بار اجرا میشود. پس فقط یک DbContext ساخته میشود.
- همه درخواستها همان را استفاده میکنند. ولی DbContext برای کار همزمان چند Thread ساخته نشده است.
- نتیجه: خطای همزمانی، داده قدیمی و حافظهای که مدام بزرگ میشود.
لغو درخواست: وقتی کاربر رفته است
مشتری گزارش فروش را باز میکند و قبل از تمام شدن، صفحه را میبندد. فریمورک این را میفهمد و توکن RequestAborted روی HttpContext را لغو میکند. ولی این فقط یک سیگنال است:
- اگر توکن را پاس ندهی، کوئری دیتابیس تا آخر اجرا میشود و نتیجهاش دور ریخته میشود.
- اگر یک پارامتر از نوع CancellationToken بگیری، فریمورک همین توکن را به تو میدهد. آن را به EF Core و HttpClient پاس بده تا کار واقعاً متوقف شود.
- خطای OperationCanceledException در این حالت طبیعی است. آن را به عنوان خطای جدی لاگ نکن.
در کد بالا، endpoint محصولات همین کار را میکند و توکن را به متد ToListAsync میدهد.
Middleware یا Filter؟
هر دو کد مشترک را اجرا میکنند، ولی در دو جای متفاوت:
Middleware
- برای همه درخواستها اجرا میشود، حتی فایل استاتیک.
- فقط HttpContext را میبیند.
- مدل ورودی endpoint را نمیشناسد.
- مناسب برای لاگ، خطا، HTTPS، فشردهسازی، احراز هویت.
Filter
- فقط برای endpoint ها اجرا میشود.
- آرگومانهای endpoint را میبیند (مثلاً مدل سفارش).
- میتواند روی یک endpoint یا یک گروه باشد.
- مناسب برای اعتبارسنجی مدل یا قانونی که به ورودی endpoint وابسته است.
قانونهای مهم
- ترتیب را آگاهانه بچین. مدیریت خطا اول، بعد مسیریابی، بعد احراز هویت، بعد مجوز، بعد endpoint.
- قبل از نوشتن بدنه جواب، هدرها را تنظیم کن. وقتی بدنه شروع به ارسال شد، هدرها قفل میشوند. برای چک کردن، ویژگی HasStarted روی Response را ببین.
- سرویس Scoped را در سازنده Middleware نگیر. آن را پارامتر متد InvokeAsync کن.
- اگر next را صدا نمیزنی، خودت جواب بده. مثلاً کد وضعیت را بگذار. وگرنه کاربر یک جواب خالی میگیرد.
- کار سنگین را در Middleware انجام نده. این کد برای همه درخواستها اجرا میشود. یک کار ۵۰ میلیثانیهای، همه API را کند میکند.
- توکن لغو را پاس بده. از Middleware تا دیتابیس.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| مجوز قبل از احراز هویت | کاربر با توکن درست، ناشناس دیده میشود و رد میشود. | اول احراز هویت، بعد مجوز. |
| مدیریت خطا وسط صف | خطای لایههای بیرونیتر گرفته نمیشود و متن خطای داخلی به کاربر میرسد. | مدیریت خطا اولین Middleware باشد. |
| گرفتن DbContext در سازنده Middleware | یک DbContext بین همه درخواستها مشترک میشود. | پارامتر متد InvokeAsync. |
| تغییر هدر بعد از next | خطا میدهد، چون جواب قبلاً شروع شده است. | هدر را قبل از next تنظیم کن، یا از متد OnStarting روی Response استفاده کن. |
| صدا نزدن next بدون جواب | درخواست بیصدا تمام میشود و کاربر جواب خالی میگیرد. | یا next را صدا بزن، یا کد وضعیت و جواب بنویس. |
| پاس ندادن CancellationToken | کوئریهای سنگین بعد از رفتن کاربر هم اجرا میشوند. | پارامتر CancellationToken و پاس دادن آن تا پایین. |
چه وقت Middleware بنویسیم؟
مناسب
- کاری که برای همه یا بیشتر درخواستها لازم است.
- کاری که به HTTP مربوط است: هدر، لاگ، شناسه پیگیری، زمانسنجی.
- کاری که باید قبل از رسیدن به endpoint، درخواست را رد کند.
نامناسب
- قانون کسبوکار، مثل «سقف مبلغ سفارش». جای آن در دامنه است.
- کاری که فقط برای یک endpoint است. یک Filter کافی است.
- چیزی که فریمورک آماده دارد، مثل احراز هویت، CORS یا Rate Limiting. خودت دوباره ننویس.
خلاصه در شش خط
- هر درخواست از یک صف از Middleware ها عبور میکند. جواب از همان صف برمیگردد.
- هر Middleware میتواند قبل و بعد از next کار کند، یا درخواست را همانجا تمام کند.
- ترتیب همان ترتیب Program.cs است: خطا، مسیریابی، احراز هویت، مجوز، endpoint.
- کلاس Middleware یک بار ساخته میشود. سرویس Scoped را در متد InvokeAsync بگیر.
- هدرها را قبل از شروع بدنه جواب تنظیم کن.
- توکن لغو درخواست را تا دیتابیس پاس بده.