Dependency Injection و طول عمر سرویسها
کلاس وابستگیهایش را خودش نمیسازد. آنها را از سازنده میگیرد و یک container آنها را میسازد. مهمترین نکته طول عمر است. هر سرویس فقط به سرویسی وابسته شود که عمرش مساوی یا بیشتر از خودش است.
نویسنده: 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) { /* ... */ }
}
این کد سه مشکل دارد:
- تست سخت است. در تست واحد نمیتوانیم درگاه پرداخت واقعی را با یک نسخه جعلی عوض کنیم. هر تست واقعاً پول کم میکند.
- تغییر سخت است. اگر فردا درگاه پرداخت عوض شود، باید این کلاس و هر کلاس مشابه را تغییر دهیم.
- کسی مسئول عمر اشیا نیست. چه کسی 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 برای هر سرویس باید بداند: چند وقت یک بار یک نمونه تازه بسازد؟
| نوع | چند نمونه؟ | چه وقت dispose میشود؟ | مثال مناسب در فروشگاه |
|---|---|---|---|
| Singleton | یکی برای کل برنامه | وقتی برنامه بسته شود | کش کاتالوگ، تنظیمات، TimeProvider |
| Scoped | یکی برای هر scope (در وب، هر درخواست) | آخر همان درخواست | ShopDb، سرویس سبد خرید |
| Transient | هر بار که خواسته شود، یکی تازه | آخر scope ای که از آن گرفته شده | محاسبهگر سبک و بدون state |
مثال زنده
چند درخواست بفرست. بعد گزینه «کد بد» را روشن کن و دوباره درخواست بفرست:
در هر درخواست، سرویس سبد خرید و سرویس پرداخت هر دو یک PriceCalculator میخواهند. هر دو از ShopDb هم استفاده میکنند.
| درخواست | Singleton | Scoped | Transient |
|---|---|---|---|
| هنوز درخواستی نیامده است. | |||
وابستگی زندانی (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);
}
چه اتفاقی میافتد؟ قدم به قدم:
- سیستم Container فقط یک بار CatalogCache را میسازد. همان لحظه یک ShopDb هم میسازد و به آن میدهد.
- سرویس CatalogCache تا آخر عمر برنامه زنده است. پس آن ShopDb هم هیچ وقت dispose نمیشود.
- همه درخواستها حالا یک ShopDb مشترک دارند.
- نوع DbContext برای استفاده همزمان ساخته نشده است. وقتی دو درخواست با هم کوئری بزنند، خطا میگیریم.
- هر چیزی که این 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 تازه بساز.
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);
}
}
}
چند ابزار مفید
چند پیادهسازی برای یک اینترفیس
فروشگاه دو درگاه پرداخت دارد: کارت بانکی و کیف پول. از .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/"));
قانونهای مهم
- وابستگیها را از سازنده بگیر. وقتی همه وابستگیها در سازنده هستند، با یک نگاه معلوم است کلاس به چه چیزی نیاز دارد.
- عمر وابستگی مساوی یا بیشتر از عمر خودت باشد. این مهمترین قانون DI است.
- همه جا IServiceProvider تزریق نکن. به این Service Locator میگویند. وابستگیها پنهان میشوند و خطا فقط در زمان اجرا معلوم میشود. فقط در جاهای مرزی مثل BackgroundService از scope factory استفاده کن.
- سرویس Disposable را از container اصلی نگیر. اگر یک سرویس Transient که IDisposable است را مستقیم از container ریشه بگیری، container آن را تا آخر عمر برنامه نگه میدارد تا آخر کار dispose کند. این یعنی نشت حافظه.
- سرویس Singleton باید thread-safe باشد. همه درخواستها با هم از آن استفاده میکنند.
- سازنده با ده وابستگی، یک بوی بد است. معمولاً یعنی کلاس کار زیادی انجام میدهد. آن را کوچکتر کن.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| تزریق DbContext به Singleton | خطای همزمانی، داده قدیمی، نشت حافظه. | استفاده از IDbContextFactory. |
| ساختن scope فقط یک بار در BackgroundService | DbContext برای همیشه زنده میماند. | یک scope تازه در هر دور. |
| سرویس Singleton با state معمولی (مثل List) | دو درخواست همزمان state را خراب میکنند. | نوع thread-safe یا عمر Scoped. |
| تزریق IServiceProvider در همه کلاسها | وابستگیها پنهان میشوند. | گرفتن وابستگی از سازنده. |
| ساختن HttpClient با new برای هر درخواست | تمام شدن port ها (Socket Exhaustion). | متد AddHttpClient. |
| خاموش بودن چکها در همه محیطها | اشتباه طول عمر تا Production دیده نمیشود. | روشن کردن ValidateScopes. |
خلاصه در شش خط
- کلاس وابستگیهایش را نمیسازد. آنها را از سازنده میگیرد.
- سرویس Singleton یکی برای کل برنامه است. Scoped یکی برای هر درخواست است. Transient هر بار تازه است.
- هر سرویس فقط به سرویسی وابسته شود که عمرش مساوی یا بیشتر است.
- سرویس Scoped داخل Singleton زندانی میشود. به این Captive Dependency میگوییم.
- در Singleton و BackgroundService، برای هر کار یک scope یا DbContext تازه بساز.
- چک ValidateScopes این اشتباه را زود پیدا میکند. فقط در Development روشن است، مگر خودت روشنش کنی.