عنوان:

‫نحوه‌ی صحیح استفاده از سرویس‌های دامنه‌دار در سرویس‌های پس‌زمینه در دات‌نت


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۷/۲۸ ۰۹:۴۵
آدرس: www.dntips.ir
گاهی نیاز داریم فرآیندهای پس‌زمینه‌ای را اجرا کنیم که مستقل از درخواست‌های کاربران عمل کنند. مثلاً ارسال ایمیل‌های دوره‌ای، همگام‌سازی داده‌ها با سیستم‌های خارجی، یا پاک‌سازی رکوردهای قدیمی از پایگاه داده. راه‌حل پیشنهادی دات‌نت برای چنین کارهای پس‌زمینه‌ای، استفاده از سرویس‌های میزبانی‌شده (Hosted Services) است. اما یک چالش رایج که توسعه‌دهندگان با آن روبرو می‌شوند، استفاده از سرویس‌های دامنه‌دار (Scoped Services) درون این سرویس‌های میزبانی‌شده است. در این مقاله، به بررسی این مسئله می‌پردازیم و راه‌حلی حرفه‌ای و قابل اعتماد ارائه می‌دهیم تا بتوانید کارهای پس‌زمینه‌ای تمیز و پایدار بسازید.

توضیح مسئله
برای درک مشکل، ابتدا باید با مفهوم طول عمر سرویس‌ها در سیستم تزریق وابستگی (Dependency Injection یا DI) دات‌نت آشنا شویم. هر سرویسی که در کانتینر DI ثبت می‌شود، یک طول عمر مشخص دارد. سه نوع رایج عبارتند از:
  • سینگلتون (Singleton): تنها یک بار ایجاد می‌شود و تا پایان عمر برنامه زنده می‌ماند. مثال‌هایی مانند سرویس‌های پیکربندی یا خود سرویس‌های میزبانی‌شده.
  • دامنه‌دار (Scoped): برای هر درخواست یا دامنه‌ی خاص، یک بار ایجاد می‌شود. در برنامه‌های وب، هر درخواست HTTP یک دامنه‌ی جدید دارد؛ مانند زمینه‌ی پایگاه داده (DbContext) یا کلاس‌های مخزن (Repository).
  • گذرا (Transient): هر بار که درخواست می‌شود، یک نمونه جدید ایجاد می‌گردد. معمولاً برای سرویس‌های سبک و بدون حالت استفاده می‌شود.
سرویس‌های میزبانی‌شده به طور پیش‌فرض به عنوان سینگلتون ثبت می‌شوند، یعنی هنگام راه‌اندازی برنامه ایجاد شده و تا خاموش شدن آن باقی می‌مانند. اما سرویس‌هایی مانند DbContext یا مخزن‌های شما، دامنه‌دار هستند و تنها برای یک درخواست یا دامنه‌ی دستی طراحی شده‌اند.
این تفاوت باعث تعارض می‌شود. سیستم DI اجازه نمی‌دهد یک سرویس دامنه‌دار را مستقیماً به یک سرویس سینگلتون تزریق کنید، زیرا سینگلتون ممکن است سعی کند از سرویسی استفاده کند که قبلاً dispose شده است، که منجر به رفتارهای نامعلوم یا خطاهای زمان اجرا می‌شود. اگر سعی کنید این کار را انجام دهید، با خطایی مانند این مواجه خواهید شد:
InvalidOperationException: Cannot consume scoped service 'AppDbContext' from singleton 'MyBackgroundService'
این خطا نشان‌دهنده‌ی ناسازگاری طول عمرها است و نیاز به مدیریت دستی دامنه‌ها را برجسته می‌کند.

راه‌حل نادرست
در کدهای اشتباه رایج، بعضی توسعه‌دهندگان سعی می‌کنند سرویس‌های دامنه‌دار مانند DbContext یا مخزن را مستقیماً به سازنده‌ی سرویس میزبانی‌شده تزریق کنند. برای مثال:
public class InactiveUserNotifierService : BackgroundService
{
    private readonly IUserRepository _userRepository;  // تزریق مستقیم سرویس دامنه‌دار

    public InactiveUserNotifierService(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // استفاده از _userRepository در حلقه‌ی پس‌زمینه
    }
}
این رویکرد اشتباه است زیرا سرویس سینگلتون یک نمونه‌ی دامنه‌دار را نگه می‌دارد که ممکن است dispose شود یا منابع آن (مانند اتصال پایگاه داده) به درستی مدیریت نشود. نتیجه؟ خطاهای زمان اجرا، نشت حافظه، یا رفتارهای غیرقابل پیش‌بینی. علاوه بر این، اگر سرویس پس‌زمینه طولانی‌مدت باشد، نگه داشتن دامنه برای مدت طولانی می‌تواند منابع سیستم را هدر دهد.

راه‌حل صحیح
راه‌حل صحیح استفاده از رابط IServiceScopeFactory است. این کارخانه به شما اجازه می‌دهد دامنه‌های جدید را به صورت دستی ایجاد کنید و درون آن‌ها سرویس‌های دامنه‌دار را resolve کنید. وقتی دامنه تمام شود، سرویس‌ها به طور خودکار dispose می‌شوند. این روش تضمین می‌کند که هر بار اجرای منطق پس‌زمینه، از یک نمونه‌ی تازه و مدیریت‌شده استفاده شود.
بیایید با یک مثال کامل پیش برویم. فرض کنید یک کلاس مخزن داریم که با Entity Framework Core کار می‌کند و به AppDbContext (که دامنه‌دار است) وابسته است:
public interface IUserRepository
{
    Task<List<User>> GetInactiveUsersAsync();
    Task MarkAsNotifiedAsync(User user);
}

public class UserRepository : IUserRepository
{
    private readonly AppDbContext _context;
    public UserRepository(AppDbContext context)
    {
        _context = context;
    }
    public async Task<List<User>> GetInactiveUsersAsync()
    {
        return await _context.Users
            .Where(u => !u.IsActive)
            .ToListAsync();
    }
    public async Task MarkAsNotifiedAsync(User user)
    {
        user.IsNotified = true;
        await _context.SaveChangesAsync();
    }
}
حالا سرویس‌ها را در Program.cs یا Startup.cs ثبت می‌کنیم:
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddHostedService<InactiveUserNotifierService>();
سرویس میزبانی‌شده را پیاده‌سازی می‌کنیم:
public class InactiveUserNotifierService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<InactiveUserNotifierService> _logger;

    public InactiveUserNotifierService(
        IServiceScopeFactory scopeFactory,
        ILogger<InactiveUserNotifierService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("InactiveUserNotifierService started.");
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using (var scope = _scopeFactory.CreateScope())
                {
                    var userRepository = scope.ServiceProvider.GetRequiredService<IUserRepository>();
                    var inactiveUsers = await userRepository.GetInactiveUsersAsync();
                    foreach (var user in inactiveUsers)
                    {
                        _logger.LogInformation($"Sending reminder email to {user.Email}");
                        await userRepository.MarkAsNotifiedAsync(user);
                    }
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error while notifying inactive users.");
            }
            await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
        }
        _logger.LogInformation("InactiveUserNotifierService stopped.");
    }
}
در این کد، به جای تزریق مستقیم مخزن، کارخانه‌ی دامنه را تزریق می‌کنیم و هر بار یک دامنه جدید ایجاد می‌کنیم. برای کد تمیزتر، می‌توانید یک متد کمکی بسازید:
private async Task RunScopedAsync(Func<IServiceProvider, Task> action)
{
    using var scope = _scopeFactory.CreateScope();
    await action(scope.ServiceProvider);
}
سپس در ExecuteAsync از آن استفاده کنید:
await RunScopedAsync(async services =>
{
    var userRepository = services.GetRequiredService<IUserRepository>();
    // منطق کار با کاربران غیرفعال
});
این روش کد را خواناتر و قابل استفاده مجدد می‌کند. همچنین، همیشه از توکن لغو (CancellationToken) برای توقف graceful استفاده کنید و عملیات طولانی را asynchronous نگه دارید تا کارایی حفظ شود.

نتیجه‌گیری
سرویس‌های میزبانی‌شده در دات‌نت، سینگلتون هستند و نمی‌توان سرویس‌های دامنه‌دار را مستقیماً به آن‌ها تزریق کرد. راه‌حل، استفاده از IServiceScopeFactory برای ایجاد دامنه‌های موقت است که سرویس‌ها را به درستی مدیریت می‌کند. با پیروی از این الگو، می‌توانید سرویس‌های پس‌زمینه‌ای قابل اعتماد، تمیز و قابل نگهداری بسازید.