اگه با ASP.NET Core کار کرده باشید، احتمالاً خیلی از اوقات کدها رو نوشتید و لذت بردید، ولی شاید کمتر به این فکر کردید که زیرِ پوستِ این فریم‌ورک دوست‌داشتنی چه خبره؟ چطوری یک درخواست (Request) از لحظه‌ای که از سمت مرورگر شما میاد، مراحل رو یکی یکی طی می‌کنه، وسط راه توسط کلی قانون و سرویس بررسی می‌شه، و در نهایت تبدیل به یک پاسخ (Response) تمیز می‌شه و برمی‌گرده؟

به زبان خودمون، ASP.NET Core شبیه یک خط تولید خیلی پیشرفته می‌مونه. هر درخواست که میاد، باید از چندتا ایستگاه بازرسی و عملیاتی رد بشه تا به مقصد برسه. اینجاست که پای دو تا مفهوم خیلی مهم به وسط کشیده می‌شه: Middleware (میان‌افزار) و Dependency Injection (تزریق وابستگی). بیاید با هم ببینیم این دوتا ستون فقراتِ فریم‌ورک چطور کار می‌کنن، و یه سر هم به ماجرای Service Lifetimes بزنیم که چطور ممکنه سر ما رو کلاه بذارن! 😉

بنیان‌های نظری: pipeline درخواست و Middleware 

قلب تپنده ASP.NET Core، همون مدل واگذاری درخواست (Request Delegation Model) هست که همه چیز رو شکل می‌ده. این مدل یه جور لوله‌کشی (Pipeline) از قطعات کوچیک به اسم Middleware می‌سازه.

Middleware چیست؟

اجازه بدید یه تعریف تمیز و رسمی از Middleware داشته باشیم.

تعریف رسمی: «میان‌افزار (Middleware) نرم‌افزاری است که در pipeline پردازش درخواست برنامه ASP.NET Core، درخواست‌های HTTP را به صورت متوالی مدیریت می‌کند. هر کامپوننت Middleware می‌تواند درخواست را پیش از رسیدن به مقصد نهایی تغییر دهد یا پاسخی تولید کند.» (برگرفته از مستندات مایکروسافت)

نحوه کار: هر Middleware یک کار مشخص رو انجام می‌ده (مثلاً لاگ‌کردن، اعتبارسنجی کاربر، فشرده‌سازی پاسخ) و تصمیم می‌گیره که درخواست رو به Middleware بعدی پاس بده یا همین‌جا پروسه رو قطع کنه. این سیستم، برنامه رو فوق‌العاده ماژولار می‌کنه.

ساخت Middleware اختصاصی (Custom Middleware)

بهترین راه برای درک Middleware، ساختنِشه. قبلاً باید Invoke یا InvokeAsync می‌نوشتیم، اما تو ASP.NET Core مدرن، معمولاً با یه Useساده کار راه می‌افته.

برای اضافه کردن یه Middleware ساده که فقط زمان پردازش درخواست رو لاگ کنه، کدی شبیه این می‌نویسیم:

// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Middleware سفارشی برای اندازه‌گیری زمان
app.Use(async (context, next) =>
{
    var startTime = DateTime.Now;
    Console.WriteLine($"[START] Request path: {context.Request.Path}");

    // پاس دادن درخواست به کامپوننت بعدی در pipeline
    await next();

    var endTime = DateTime.Now;
    var duration = endTime - startTime;
    Console.WriteLine($"[END] Request processed in: {duration.TotalMilliseconds}ms");
});

app.MapGet("/", () => "Hello World!");

app.Run();

نکته: دقت کنید که await next() چقدر مهم هست. این خط، مسئول پاس دادن درخواست به کامپوننت بعدی در pipelineه. اگه نباشه، درخواست همین‌جا متوقف می‌شه!

هسته کاربردی: مسیریابی و Minimal APIs 

 

Endpoint Routing

در نسخه‌های قدیمی‌تر، مسیریابی کمی آشفته بود. اما با معرفی Endpoint Routing، کارها خیلی منظم‌تر شد. Endpoint Routing در واقع یک Middleware هست که:

  1. Routing Middleware: بر اساس URL، مقصد نهایی درخواست (Controller یا Minimal API) رو مشخص می‌کنه.
  2. Endpoint Middleware: درخواست رو به اون مقصد نهایی اجرا می‌کنه.

این جدایی وظایف باعث شده که فریم‌ورک بتونه خیلی هوشمندتر عمل کنه. مثلاً می‌تونه قبل از اجرای کد شما، تصمیم بگیره که آیا کاربر اصلاً اجازه دسترسی به این مسیر رو داره یا نه.

Minimal APIs

یکی از بزرگ‌ترین تغییرات اخیر، Minimal APIs بود که کار رو برای نوشتن APIهای ساده و سریع، فوق‌العاده راحت کرد. دیگه نیازی به Controllerهای کلاسیک با هزار خط کد boilerplate نیست.

مثال:

// برنامه ما با استفاده از Minimal API
app.MapGet("/users/{id}", (int id) => 
{
    // اینجا از DI استفاده می‌شه برای دسترسی به سرویس
    return Results.Ok($"Fetching user with ID: {id}");
});

می‌بینید؟ با یه خط کد app.MapGet هم مسیردهی انجام شد، هم پارامتر id از URL خونده شد، و هم پاسخ برگشت. خیلی تمیز و شیک!

تزریق وابستگی (DI) و معمای Service Lifetimes 

ستون فقرات دوم ASP.NET Core، سیستم تزریق وابستگی (Dependency Injection) هست. DI تضمین می‌کنه که سرویس‌ها (کلاس‌هایی که کارهای مشخصی رو انجام می‌دن) بدون اینکه به هم وابسته باشن، بتونن با هم کار کنن. هسته این ماجرا، Service Lifetimes هستن.

سه عمر سرویس

  1. Singleton: یک بار در کل عمر برنامه ساخته می‌شه و همون نمونه به همه داده می‌شه. (تک‌همسر)
  2. Scoped: یک بار به ازای هر درخواست HTTP ساخته می‌شه. (عمری به اندازه درخواست)
  3. Transient: هر بار که درخواست بشه، یک نمونه جدید ساخته می‌شه. (دم‌به‌ساعت)

تجربه شخصی: وقتی Singleton و Scoped با هم قاطی می‌شن!

بذارید براتون یه ماجرای واقعی تعریف کنم که چطور همین Lifetimes ساده، منو تا مرز جنون برد!

داستان از این قرار بود که یک سرویس UserService داشتم که اطلاعات کاربر رو از دیتابیس می‌گرفت. چون توی این سرویس نیاز بود که شناسه کاربر فعلی (که از HttpContext می‌اومد) رو داشته باشم، تصمیم گرفتم این سرویس رو با AddScoped رجیستر کنم (چون HttpContext هم به ازای هر درخواست ایجاد می‌شه و Scoped هست).

بعد، یه سرویس دیگه LoggerService داشتم که کارش لاگ کردن بود. چون لاگر کلاً یه چیز کلی و بدون وضعیت (Stateless) بود، با خودم گفتم: چه کاریه؟ بذار این رو با AddSingleton ثبت کنم تا الکی هی نمونه جدید ساخته نشه و منابع سیستمی رو هدر نده.

// تنظیمات اولیه
builder.Services.AddSingleton<LoggerService>(); // Singleton
builder.Services.AddScoped<UserService>();    // Scoped

حالا، توی LoggerService نیاز به UserService داشتم تا اطلاعات کاربر رو هم توی لاگ‌ها ثبت کنم. وای که همین‌جا گند زدم!

وقتی برنامه رو اجرا کردم، توی اولین درخواست، LoggerService (که Singleton بود) ساخته شد و برای ساختنش، از سیستم DI درخواست UserService (که Scoped بود) کرد. سیستم DI هم طبق قانون، یک نمونه جدید و Scoped از UserService ساخت و به LoggerService داد.

توی درخواست‌های بعدی چه اتفاقی افتاد؟

  • LoggerService: چون Singleton بود، دیگه ساخته نشد و از همون نمونه اولیه استفاده کرد.
  • UserService درون LoggerService: این نمونه، همون نمونه Scoped از درخواست اول بود که داخل Singleton گیر کرده بود!
  • مشکل: اگر UserService چیزی رو در حافظه نگه می‌داشت (مثلاً یه Cash قدیمی یا ID کاربر درخواست اول)، این اطلاعات غلط به همه درخواست‌های بعدی که از طریق LoggerService صدا می‌شدن، داده می‌شد.

نتیجه؟ توی لاگ‌ها اطلاعات کاربر اول، برای بقیه کاربرها هم ثبت می‌شد! یک باگ خیلی خطرناک و نادرست که پیدا کردنش حسابی وقتم رو گرفت.

قانون طلایی DI: هرگز نباید یک سرویس با عمر کوتاه‌تر (مثل Scoped یا Transient) رو داخل یک سرویس با عمر طولانی‌تر (مثل Singleton) تزریق کنید. همیشه باید سرویس‌های فرزند، عمری برابر یا طولانی‌تر از والد داشته باشن.

خلاصه و کدهای اجرایی

برای اینکه همه این مفاهیم رو توی یه پروژه تمیز ببینیم، کد زیر رو در نظر بگیرید که هر دو بخش DI و Middleware رو با هم ترکیب می‌کنه:

// 1. تعریف سرویس‌ها
public interface IMessageService
{
    string GetMessage();
}

// این سرویس Scoped خواهد بود، یعنی به ازای هر درخواست جدید ساخته می‌شود
public class RequestMessageService : IMessageService
{
    private readonly Guid _id = Guid.NewGuid();
    public string GetMessage() => $"Message ID: {_id} - Created for this request.";
}

var builder = WebApplication.CreateBuilder(args);

// 2. رجیستر کردن سرویس به صورت Scoped
builder.Services.AddScoped<IMessageService, RequestMessageService>();

var app = builder.Build();

// 3. Middleware برای لاگ کردن
app.Use(async (context, next) =>
{
    Console.WriteLine($"\n--- START Request: {context.Request.Path} ---");
    await next();
    Console.WriteLine("--- END Request ---\n");
});

// 4. Minimal API که از سرویس تزریق شده استفاده می‌کند
app.MapGet("/test-di", (IMessageService service) => 
{
    // فریم‌ورک به طور خودکار RequestMessageService جدید (Scoped) رو تزریق می‌کنه
    return Results.Ok(service.GetMessage()); 
});

app.Run();

اگه چندین بار /test-di رو صدا بزنید، می‌بینید که Message ID همیشه تغییر می‌کنه، چون هر بار برای درخواست جدید، یک نمونه RequestMessageService جدید (Scoped) ساخته شده. این یعنی DI داره کارش رو درست انجام می‌ده.

نتیجه‌گیری: قدرت معماری در سادگی است

ASP.NET Core با جدا کردن وظایف (Middleware برای pipeline و DI برای مدیریت وابستگی‌ها) به ما اجازه می‌ده برنامه‌هایی بنویسیم که:

  1. ماژولار هستن: هر بخش از کار در یک Middleware یا سرویس مجزا انجام می‌شه.
  2. قابل تست هستن: به خاطر DI، می‌تونیم به راحتی وابستگی‌ها رو برای تست‌های یونیت Mock کنیم.
  3. سریع و بهینه هستن: Endpoint Routing و Minimal APIs فرآیند پردازش درخواست رو بهینه‌تر از همیشه کردن.

فقط حواستون به داستان Service Lifetimes باشه که مثل من به مشکل برنخورید. تزریق وابستگی عالیه، به شرطی که قواعدش رو درست بازی کنید! 😊