Levelwise
فارسی
پایه‌های C# و .NET

Dependency Injection و طول عمر سرویس‌ها

کلاس وابستگی‌هایش را خودش نمی‌سازد. آن‌ها را از سازنده می‌گیرد و یک container آن‌ها را می‌سازد. مهم‌ترین نکته طول عمر است. هر سرویس فقط به سرویسی وابسته شود که عمرش مساوی یا بیشتر از خودش است.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۴ دقیقهمثال فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: کلاسی که همه چیز را خودش می‌سازد

در فروشگاه اینترنتی ما، سرویس پرداخت سفارش این‌طور نوشته شده است:

public sealed class CheckoutService
{
    private readonly ShopDb _db = new(/* connection string? */);
    private readonly CardGateway _gateway = new("api-key-123");

    public async Task PayAsync(Guid orderId, CancellationToken ct) { /* ... */ }
}

این کد سه مشکل دارد:

  1. تست سخت است. در تست واحد نمی‌توانیم درگاه پرداخت واقعی را با یک نسخه جعلی عوض کنیم. هر تست واقعاً پول کم می‌کند.
  2. تغییر سخت است. اگر فردا درگاه پرداخت عوض شود، باید این کلاس و هر کلاس مشابه را تغییر دهیم.
  3. کسی مسئول عمر اشیا نیست. چه کسی ShopDb را dispose می‌کند؟ چه وقت؟

ایده: وابستگی را بگیر، نساز

کلاس فقط می‌گوید به چه چیزی نیاز دارد. وابستگی‌ها را از سازنده می‌گیرد. یک بخش دیگر به اسم Container آن‌ها را می‌سازد و به کلاس می‌دهد.

public sealed class CheckoutService(ShopDb db, IPaymentGateway gateway, TimeProvider clock)
{
    public async Task PayAsync(Guid orderId, CancellationToken ct)
    {
        var order = await db.Orders.SingleAsync(o => o.Id == orderId, ct);
        await gateway.ChargeAsync(order.Id, order.Total, ct);
        order.MarkPaid(clock.GetUtcNow());
        await db.SaveChangesAsync(ct);
    }
}

حالا در تست، یک درگاه جعلی و یک ساعت ثابت به کلاس می‌دهیم. کد کلاس هیچ تغییری نمی‌کند.

ثبت سرویس‌ها در فایل Program انجام می‌شود:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ShopDb>(o => o.UseSqlServer(connectionString)); // Scoped
builder.Services.AddScoped<CheckoutService>();
builder.Services.AddScoped<IPaymentGateway, CardGateway>();
builder.Services.AddTransient<PriceCalculator>();
builder.Services.AddSingleton<CatalogCache>();
builder.Services.AddSingleton(TimeProvider.System);

سه نوع طول عمر

سیستم Container برای هر سرویس باید بداند: چند وقت یک بار یک نمونه تازه بسازد؟

CatalogCache #1یک نمونه، مشترک بین همه درخواست‌هادرخواست اولدرخواست دومShopDb #1ShopDb #2Price #1Price #2Price #3Price #4دو بار خواسته شد، دو نمونهدو بار خواسته شد، دو نمونهSingletonهمیشه همانScopedیکی برای هر درخواستTransientتازه برای هر بار
نمونه Singleton بین همه مشترک است. نمونه Scoped در هر درخواست یکی است. نمونه Transient هر بار تازه است.
نوع چند نمونه؟ چه وقت dispose می‌شود؟ مثال مناسب در فروشگاه
Singleton یکی برای کل برنامه وقتی برنامه بسته شود کش کاتالوگ، تنظیمات، TimeProvider
Scoped یکی برای هر scope (در وب، هر درخواست) آخر همان درخواست ShopDb، سرویس سبد خرید
Transient هر بار که خواسته شود، یکی تازه آخر scope ای که از آن گرفته شده محاسبه‌گر سبک و بدون state
یک سؤال ساده برای انتخاب: آیا این سرویس state دارد؟ اگر state ندارد و thread-safe است، Singleton معمولاً بهترین است. اگر state مخصوص یک درخواست دارد (مثل DbContext)، Scoped. اگر سبک است و نمی‌خواهی بین دو استفاده چیزی مشترک باشد، Transient.

مثال زنده

چند درخواست بفرست. بعد گزینه «کد بد» را روشن کن و دوباره درخواست بفرست:

مثال زنده: container چه نمونه‌هایی می‌سازد؟

در هر درخواست، سرویس سبد خرید و سرویس پرداخت هر دو یک PriceCalculator می‌خواهند. هر دو از ShopDb هم استفاده می‌کنند.

درخواستSingletonScopedTransient
هنوز درخواستی نیامده است.

    وابستگی زندانی (Captive Dependency)

    مهم‌ترین اشتباه DI این است: یک سرویس بلندعمر به یک سرویس کوتاه‌عمر وابسته شود.

    builder.Services.AddSingleton<CatalogCache>();
    
    public sealed class CatalogCache(ShopDb db) // ShopDb is Scoped!
    {
        public Task<List<Product>> LoadAsync(CancellationToken ct)
            => db.Products.ToListAsync(ct);
    }
    درخواست ۱درخواست ۲درخواست ۳CatalogCacheتا آخر عمر برنامه زندهShopDbزندانی شدههیچ وقت آزاد نمی‌شوددو کوئری همزمان، خطاداده قدیمی در حافظهقانون: هر سرویس فقط به سرویسی وابسته شود که عمرش مساوی یا بیشتر است
    سرویس Singleton فقط یک بار ساخته می‌شود. پس ShopDb داخلش هم برای همیشه همان یکی است.

    چه اتفاقی می‌افتد؟ قدم به قدم:

    1. سیستم Container فقط یک بار CatalogCache را می‌سازد. همان لحظه یک ShopDb هم می‌سازد و به آن می‌دهد.
    2. سرویس CatalogCache تا آخر عمر برنامه زنده است. پس آن ShopDb هم هیچ وقت dispose نمی‌شود.
    3. همه درخواست‌ها حالا یک ShopDb مشترک دارند.
    4. نوع DbContext برای استفاده همزمان ساخته نشده است. وقتی دو درخواست با هم کوئری بزنند، خطا می‌گیریم.
    5. هر چیزی که این DbContext بخواند، در Change Tracker آن می‌ماند. حافظه کم‌کم بالا می‌رود و کاربر گاهی داده قدیمی می‌بیند.

    قانون ساده: هر سرویس فقط به سرویسی وابسته شود که عمرش مساوی یا بیشتر از خودش است.

    سرویس می‌تواند وابسته شود به
    Singleton فقط Singleton
    Scoped Singleton و Scoped
    Transient هر سه، ولی خودش در Singleton زندانی نشود

    چرا در Development خطا می‌گیریم و در Production نه؟

    فریم‌ورک ASP.NET Core در محیط Development دو چک را روشن می‌کند. اسم آن‌ها ValidateScopes و ValidateOnBuild است. این چک‌ها همین اشتباه را هنگام ساخت سرویس پیدا می‌کنند. در محیط‌های دیگر به طور پیش‌فرض خاموش هستند. اگر بخواهی همیشه روشن باشند:

    builder.Host.UseDefaultServiceProvider(options =>
    {
        options.ValidateScopes = true;
        options.ValidateOnBuild = true;
    });

    راه درست در Singleton

    اگر سرویس Singleton واقعاً به دیتابیس نیاز دارد، هر بار یک DbContext کوتاه‌عمر بسازد و بعد آن را dispose کند:

    builder.Services.AddDbContextFactory<ShopDb>(o => o.UseSqlServer(connectionString));
    
    public sealed class CatalogCache(IDbContextFactory<ShopDb> dbFactory)
    {
        public async Task<List<Product>> LoadAsync(CancellationToken ct)
        {
            await using var db = await dbFactory.CreateDbContextAsync(ct);
            return await db.Products.AsNoTracking().ToListAsync(ct);
        }
    }

    سرویس پس‌زمینه و Scope

    یک BackgroundService هم مثل Singleton تا آخر عمر برنامه زنده است. پس نمی‌تواند ShopDb را از سازنده بگیرد. راه درست: در هر دور کار، یک scope تازه بساز.

    OrderCleanupWorkerتا آخر عمر برنامه زندهدور اولCreateAsyncScopeShopDb #1آخر دور: آزاد می‌شوددور دومCreateAsyncScopeShopDb #2آخر دور: آزاد می‌شوددور سومCreateAsyncScopeShopDb #3آخر دور: آزاد می‌شودهر دور با یک نمونه تازه و خالی شروع می‌شود
    هر دور یک scope و یک ShopDb تازه دارد. در پایان دور، هر دو dispose می‌شوند.
    public sealed class OrderCleanupWorker(IServiceScopeFactory scopes) : BackgroundService
    {
        protected override async Task ExecuteAsync(CancellationToken ct)
        {
            while (!ct.IsCancellationRequested)
            {
                await using (var scope = scopes.CreateAsyncScope())
                {
                    var db = scope.ServiceProvider.GetRequiredService<ShopDb>();
                    await db.Orders
                        .Where(o => o.Status == OrderStatus.Draft)
                        .Where(o => o.CreatedAt < DateTime.UtcNow.AddDays(-7))
                        .ExecuteDeleteAsync(ct);
                } // the scope and ShopDb are disposed here
    
                await Task.Delay(TimeSpan.FromHours(1), ct);
            }
        }
    }
    یک scope برای همیشه هم اشتباه است. اگر scope را فقط یک بار، بیرون از حلقه بسازی، همان مشکل Captive برمی‌گردد. DbContext تا آخر عمر برنامه زنده می‌ماند و حافظه بالا می‌رود.

    چند ابزار مفید

    چند پیاده‌سازی برای یک اینترفیس

    فروشگاه دو درگاه پرداخت دارد: کارت بانکی و کیف پول. از .NET 8 می‌شود هر کدام را با یک کلید ثبت کرد. به این Keyed Services می‌گوییم:

    builder.Services.AddKeyedScoped<IPaymentGateway, CardGateway>("card");
    builder.Services.AddKeyedScoped<IPaymentGateway, WalletGateway>("wallet");
    
    public sealed class RefundService([FromKeyedServices("card")] IPaymentGateway gateway)
    {
        // ...
    }

    کلاس HttpClient

    هیچ وقت برای هر درخواست یک HttpClient تازه با new نساز. اتصال‌ها زیاد باز می‌شوند و port های سیستم تمام می‌شوند. آن را با متد AddHttpClient ثبت کن. این متد اتصال‌ها را مدیریت می‌کند و هر چند وقت یک بار تازه می‌کند:

    builder.Services.AddHttpClient<PriceClient>(c =>
        c.BaseAddress = new Uri("https://prices.shop.local/"));

    قانون‌های مهم

    1. وابستگی‌ها را از سازنده بگیر. وقتی همه وابستگی‌ها در سازنده هستند، با یک نگاه معلوم است کلاس به چه چیزی نیاز دارد.
    2. عمر وابستگی مساوی یا بیشتر از عمر خودت باشد. این مهم‌ترین قانون DI است.
    3. همه جا IServiceProvider تزریق نکن. به این Service Locator می‌گویند. وابستگی‌ها پنهان می‌شوند و خطا فقط در زمان اجرا معلوم می‌شود. فقط در جاهای مرزی مثل BackgroundService از scope factory استفاده کن.
    4. سرویس Disposable را از container اصلی نگیر. اگر یک سرویس Transient که IDisposable است را مستقیم از container ریشه بگیری، container آن را تا آخر عمر برنامه نگه می‌دارد تا آخر کار dispose کند. این یعنی نشت حافظه.
    5. سرویس Singleton باید thread-safe باشد. همه درخواست‌ها با هم از آن استفاده می‌کنند.
    6. سازنده با ده وابستگی، یک بوی بد است. معمولاً یعنی کلاس کار زیادی انجام می‌دهد. آن را کوچک‌تر کن.

    اشتباه‌های رایج

    اشتباه نتیجه راه درست
    تزریق DbContext به Singleton خطای همزمانی، داده قدیمی، نشت حافظه. استفاده از IDbContextFactory.
    ساختن scope فقط یک بار در BackgroundService DbContext برای همیشه زنده می‌ماند. یک scope تازه در هر دور.
    سرویس Singleton با state معمولی (مثل List) دو درخواست همزمان state را خراب می‌کنند. نوع thread-safe یا عمر Scoped.
    تزریق IServiceProvider در همه کلاس‌ها وابستگی‌ها پنهان می‌شوند. گرفتن وابستگی از سازنده.
    ساختن HttpClient با new برای هر درخواست تمام شدن port ها (Socket Exhaustion). متد AddHttpClient.
    خاموش بودن چک‌ها در همه محیط‌ها اشتباه طول عمر تا Production دیده نمی‌شود. روشن کردن ValidateScopes.

    خلاصه در شش خط

    1. کلاس وابستگی‌هایش را نمی‌سازد. آن‌ها را از سازنده می‌گیرد.
    2. سرویس Singleton یکی برای کل برنامه است. Scoped یکی برای هر درخواست است. Transient هر بار تازه است.
    3. هر سرویس فقط به سرویسی وابسته شود که عمرش مساوی یا بیشتر است.
    4. سرویس Scoped داخل Singleton زندانی می‌شود. به این Captive Dependency می‌گوییم.
    5. در Singleton و BackgroundService، برای هر کار یک scope یا DbContext تازه بساز.
    6. چک ValidateScopes این اشتباه را زود پیدا می‌کند. فقط در Development روشن است، مگر خودت روشنش کنی.