عنوان:

‫چالش‌های الگوی Service Locator و پیاده‌سازی Scoped Interceptors در EF Core


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۴/۲۰ ۰۸:۱۵
آدرس: www.dntips.ir
در معماری برنامه‌های مبتنی بر ASP.NET Core، سیستم تزریق وابستگی (Dependency Injection یا به اختصار DI) نقشی حیاتی در مدیریت چرخه عمر سرویس‌ها (Service Lifetimes) ایفا می‌کند. فریم‌ورک سرویس‌ها را در سه لایه کلی Transient ،Scoped و Singleton مدیریت می‌کند. با این حال، در سناریوهای پیچیده توسعه نرم‌افزار، توسعه‌دهندگان اغلب با چالش "تداخل چرخه عمر سرویس‌ها" (Capturing a Scoped Service inside a Singleton) مواجه می‌شوند؛ وضعیتی که در آن یک سرویس با طول عمر طولانی‌تر (مانند یک Singleton Middleware یا یک EF Core Interceptor) نیاز به دسترسی به یک سرویس با طول عمر کوتاه‌تر (مانند DbContext یا اطلاعات کاربر جاری) دارد.
یک راهکار رایج اما چالش‌برانگیز برای حل این مشکل، رجوع به الگوی Service Locator از طریق HttpContext.RequestServices است. این مقاله به تحلیل عمیق سازوکار این الگو، مخاطرات و بن‌بست‌های فنی آن پرداخته و در نهایت، الگوهای جایگزین و استانداردی مانند Scoped Interceptors را در محیط‌های مجهز به مکانیزم Pool (مانند AddDbContextPool) تبیین می‌کند.

۱. سازوکار و چالش‌های الگوی Service Locator در ASP.NET Core
هنگامی که درون یک سرویس Singleton یا مکانیزم‌های میانی (Middleware)، به یک سرویس Scoped نیاز داریم، ساده‌ترین رویکرد، دسترسی مستقیم به کانتینر خدمات درخواست جاری از طریق خصوصیت RequestServices در اکوسیستم HttpContext است.

مکانیزم عملکرد (Core Mechanism)
  • هم‌راستایی محدوده (Scope Alignment): محیط HttpContext.RequestServices به طور مستقیم به کانتینر Scoped ایجاد شده برای آن درخواست HTTP خاص اشاره دارد.
  • تطابق طول عمر (Lifetime Match): هر سرویسی که از این طریق Resolve یا نمونه‌سازی شود، دقیقاً تا پایان همان درخواست وب زنده خواهد ماند.
  • آزادسازی خودکار (Automatic Disposal): فریم‌ورک ASP.NET Core در پایان چرخه عمر درخواست وب، به طور خودکار متد Dispose را روی سرویس‌های نمونه‌سازی شده فراخوانی می‌کند.

نمونه کد: استفاده در کدهای میان‌افزار (Middleware)
public class CustomMiddleware
{
    private readonly RequestDelegate _next;

    // سرویس‌های Singleton تنها می‌توانند سایر Singletonها یا Transientها را از طریق سازنده دریافت کنند.
    public CustomMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // نمونه‌سازی امن یک سرویس Scoped به ازای هر درخواست وب
        var dbContext = context.RequestServices.GetRequiredService<MyDbContext>();
        
        await _next(context);
    }
}

مخاطرات جدی این الگو (Critical Pitfalls)
اگرچه این روش مشکل تداخل چالش‌های طول عمر را حل می‌کند، اما در ابعاد کلان خطرات معماری زیر را به همراه دارد:
  • پنهان‌سازی وابستگی‌ها (Hidden Dependencies): این الگو وابستگی‌های کلاس را از امضای سازنده (Constructor) حذف و آن‌ها را پنهان می‌کند. این امر فرآیند نوشتن آزمون‌های واحد (Unit Testing) را به شدت دشوار می‌سازد.
  • ضد الگوی Service Locator: استفاده مکرر از این ساختار، کد را به سمت اتصال ناخواسته (Coupling) سوق داده و اصل وارونگی وابستگی (Dependency Inversion Principle) را نقض می‌کند.
  • عدم ایمنی نخ‌ها (Thread Safety): شیء HttpContext به هیچ وجه Thread-safe نیست. استفاده از RequestServices درون کارهای موازی (مانند Task.WhenAll) که خارج از نخ اصلی درخواست اجرا می‌شوند، کانتینر را دچار فساد داده‌ای (Scope Corruption) می‌کند.

۲. بررسی مورد کاوی: انتزاع اطلاعات کاربر در EF Core Interceptors
یکی از ملموس‌ترین سناریوهای تداخل چرخه عمر، استفاده از SaveChangesInterceptor در EF Core برای ثبت اطلاعات حسابرسی (Audit Logs) است. اینترسپتورها به طور پیش‌فرض و سنتی به صورت Singleton ثبت می‌شوند، اما سرویسی که اطلاعات کاربر جاری را نگهداری می‌کند (ICurrentUserService) باید Scoped باشد تا به درخواست وب جاری متصل گردد.

راهکار اولیه با IHttpContextAccessor (و چالش‌های آن)
یک پیاده‌سازی متداول، تزریق IHttpContextAccessor به درون اینترسپتور Singleton و دسترسی به RequestServices است:
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;

public class AuditInterceptor : SaveChangesInterceptor
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public AuditInterceptor(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData, InterceptionResult<int> result)
    {
        UpdateAuditProperties(eventData.Context);
        return base.SavingChanges(eventData, result);
    }

    private void UpdateAuditProperties(DbContext? context)
    {
        if (context == null) return;

        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext == null) return; // مواقعی که درخواست وب وجود ندارد (مانند Migrationها یا Background Tasks)

        // نمونه‌سازی سرویس Scoped از دل کانتینر درخواست وب جاری
        var currentUserService = httpContext.RequestServices.GetRequiredService<ICurrentUserService>();
        var currentUserId = currentUserService.UserId;

        foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.CreatedBy = currentUserId;
            }
        }
    }
}

خطرات این الگو در EF Core
  • نخ‌های پس‌زمینه و محتوای تهی (Null Contexts): اگر متد SaveChanges از طریق یک Background Worker، یک Job در Hangfire یا ابزار CLI مربوط به EF Migrations صدا زده شود، مقدار HttpContext تهی (null) خواهد بود و خطر خطای NullReferenceException وجود دارد.
  • حلقه وابستگی متقابل (Stack Overflow Error): اگر سرویس ICurrentUserService خود برای یافتن اطلاعات کاربر نیاز به کوئری زدن روی همان DbContext داشته باشد، یک حلقه بی‌انتها شکل می‌گیرد:
  • SaveChanges -> Interceptor -> RequestServices -> CurrentUserService -> DbContext Query -> SaveChanges
  • این روند در نهایت منجر به کرش برنامه به دلیل خطای خطای سرریز پشته می‌شود.

۳. پارادایم جایگزین: الگو و فلسفه Scoped Interceptor
یک سوال بنیادین در جامعه توسعه‌دهندگان دات‌نت این است: "آیا ثبت Interceptor به صورت Singleton الزامی است؟"
پاسخ قطعاً خیر است. ثبت یک اینترسپتور به صورت Singleton صرفاً یک توصیه بهینه‌سازی برای کلاس‌های Stateless (بدون وضعیت) است؛ مانند اینترسپتوری که فقط وظیفه لاگ‌گیری ساده دستورات SQL را دارد و به هیچ سرویس لایه درخواستی وابسته نیست. اما زمانی که اینترسپتور دارای وضعیت (State) یا وابسته به سرویس‌های Scoped است، طبیعی‌ترین و استانداردترین حالت، ثبت آن به صورت Scoped است.
در این حالت، تمامی اجزا هم‌گام با یکدیگر در یک Scope مشترک متولد شده و از بین می‌روند:
Request Scope -> DbContext (Scoped) , ICurrentUserService (Scoped) , AuditInterceptor (Scoped)

چالش بزرگ: مکانیزم DbContext Pooling
اگر برنامه شما از AddDbContextPool برای بالا بردن کارایی استفاده کند، یک چالش فنی جدید پدید می‌آید. در فرآیند Pooling، کدهای بخش کانفیگ دات‌نت (options lambda) تنها یک‌بار در زمان استارت‌آپ برنامه کامپایل و کش می‌شوند. بنابراین اگر کدی مانند زیر بنویسید:
// ❌ خطای منطقی در کدهای با معماری Pool
builder.Services.AddDbContextPool<MyDbContext>((sp, options) => {
    var interceptor = sp.GetRequiredService<AuditInterceptor>(); // سرویس در استارت‌آپ قفل می‌شود!
    options.AddInterceptors(interceptor);
});
این کار باعث می‌شود اینترسپتور شما عملاً رفتار Singleton به خود بگیرد و سرویس‌های درونی آن ارتقای چرخه عمر ناخواسته (Capturing) پیدا کنند.

راه‌حل بهینه: تزریق دینامیک در متد OnConfiguring
برای حل تعارضِ هم‌زمانِ AddDbContextPool و سرویس‌های Scoped، باید فرآیند نمونه‌سازی اینترسپتور را به زمانِ "اجاره داده شدنِ" کانتکست از Pool منتقل کنیم. این کار با اورراید کردن متد OnConfiguring درون خود کلاس DbContext محقق می‌شود:

گام ۱: تعریف کدهای پاک با تزریق مستقیم در سازنده اینترسپتور
public class AuditInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUserService;

    // تزریق کاملاً استاندارد و بدون نیاز به HttpContextAccessor
    public AuditInterceptor(ICurrentUserService currentUserService)
    {
        _currentUserService = currentUserService;
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData, InterceptionResult<int> result)
    {
        // منطق حسابرسی با استفاده از _currentUserService
        return base.SavingChanges(eventData, result);
    }

}

گام ۲: پیکربندی کانتکست از طریق IServiceProvider
public class ApplicationDbContext : DbContext
{
    private readonly IServiceProvider _serviceProvider;

    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options, 
        IServiceProvider serviceProvider) 
        : base(options)
    {
        _serviceProvider = serviceProvider;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // حل دینامیک اینترسپتور از Scope درخواست جاری در هر بار استفاده از پول
        var scopedInterceptor = _serviceProvider.GetRequiredService<AuditInterceptor>();
        optionsBuilder.AddInterceptors(scopedInterceptor);

        base.OnConfiguring(optionsBuilder);
    }

}
گام ۳: ثبت روابط در Program.cs
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<AuditInterceptor>(); // ثبت به صورت Scoped

// ثبت به صورت Pool بدون دغدغه تداخل چرخه عمر سرویس‌ها
builder.Services.AddDbContextPool<ApplicationDbContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
});

این سازوکار در پشت صحنه چگونه کار می‌کند؟
  • چرخه درخواست (Request Cycle): هسته EF Core یک نمونه از ApplicationDbContext را از Pool اجاره می‌کند.
  • جابجایی محدوده (Scope Swap): فریم‌ورک EF Core به صورت داخلی زیرساخت‌های متصل به _serviceProvider کانتکست را با Scope درخواست HTTP جاری هماهنگ و به‌روزرسانی می‌کند.
  • نمونه‌سازی دقیق (Resolution): متد OnConfiguring اجرا شده و دقیقاً نمونه AuditInterceptor مربوط به همان Request جاری را دریافت می‌کند. این رویکرد حتی در کارهای پس‌زمینه (Background Services) نیز در صورت ساخت دستی Scope به درستی کار خواهد کرد.

نتیجه‌گیری
الگوی Service Locator و رجوع به HttpContext.RequestServices اگرچه در نگاه اول راهکاری سریع برای فرار از خطاهای محدوده سرویس‌ها (DI Lifetimes) به نظر می‌رسد، اما به دلیل سست کردن پایه‌های تست‌پذیری، پنهان کردن وابستگی‌ها و عدم ایمنی در پردازش‌های چندنخی، انتخابی ایده‌آل برای پروژه‌های سازمانی نیست.
بررسی فلسفه چرخه عمر معماری دات‌نت نشان می‌دهد که تغییر رویکرد به سمت Scoped Interceptors پایداری سیستم را به شدت افزایش می‌دهد. با استفاده از روش اورراید متد OnConfiguring و تزریق غیرمستقیم کانتینر سرویس‌ها به DbContext، می‌توان حتی در ساختارهای بهینه‌سازی‌شده‌ای مانند AddDbContextPool نیز به طراحی کاملاً تمیز، مستقل از محیط وب (HttpContext) و سازگار با اصول برنامه‌نویسی شیءگرا دست یافت.

نظرات

  • میلاد احمدی مقدم در ۱۴۰۵/۰۴/۲۰ ۱۲:۳۹
    دقیقا همین کد رو پیاده سازی کردم ولی داخل متد OnConfiguring هنگام گرفتن Interceptor خطا داد:
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // حل دینامیک اینترسپتور از Scope درخواست جاری در هر بار استفاده از پول
        var scopedInterceptor = serviceProvider.GetRequiredService<AuditInterceptor>();
        optionsBuilder.AddInterceptors(scopedInterceptor);
    
        base.OnConfiguring(optionsBuilder);
    }

    Runtime error: System.InvalidOperationException: 'Cannot resolve scoped service 'Kardani.Persistence.Interceptors.UpdateAuditableEntitiesInterceptor' from root provider.'
    برای تست اینکه شاید مشکل از سایر بخش ها باشه یه پروژه خام درست کردم و کد های شما رو قرار دادم؛ مجددا به این خطا برخورد کردم. به محض اینکه کدم رو از AddDbContextPool به AddDbContext تغییر دادم بدون مشکل کار کرد.
    • وحید نصیری در ۱۴۰۵/۰۴/۲۰ ۱۲:۵۵
      این خطا دقیقاً به این دلیل رخ می‌دهد که نمونه‌ی IServiceProvider که به کانتکست شما پاس داده شده‌است، Root Provider (کانتینر اصلی برنامه در زمان استارت‌آپ) است، نه کانتینر مربوط به Scope درخواست جاری. هنگامی که از AddDbContextPool استفاده می‌کنید، فرآیند ساخت و مدیریت کانتکست کمی متفاوت است. برای حل این مشکل، شما باید به جای تزریق مستقیم IServiceProvider معمولی، از سرویسی به نام ICurrentDbContextDependencies یا روش اصولی‌ترِ تنظیم کانیتنر داخلی EF Core استفاده کنید تا مطمئن شوید کانتکست به Scope درخواست دسترسی دارد. از آنجایی که مشکل اصلی، عدم دسترسی OnConfiguring به کانتینر دامنه‌ی درخواست (Request Scope) در ساختار Pool است، فریم‌ورک EF Core کانیتنری به نام ApplicationServiceProvider را درون خودِ optionsBuilder قرار می‌دهد که دقیقاً به Scope جاری اشاره می‌کند. کد متد OnConfiguring خود را به شکل زیر تغییر دهید:
      protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
      {
          // ۱. دسترسی به کانتینر درونی و اختصاصی لایه درخواست جاری
          var internalServiceProvider = optionsBuilder.Options.FindExtension<CoreOptionsExtension>()
                                        ?.ApplicationServiceProvider;
      
          if (internalServiceProvider != null)
          {
              // ۲. حل مشکل: حالا اینترسپتور از Scope جاری گرفته می‌شود، نه از Root!
              var scopedInterceptor = internalServiceProvider.GetRequiredService<UpdateAuditableEntitiesInterceptor>();
              optionsBuilder.AddInterceptors(scopedInterceptor);
          }
      
          base.OnConfiguring(optionsBuilder);
      }
      برای استفاده از CoreOptionsExtension نیاز است این فضای نام را بالای کانتکست اضافه کنید: using Microsoft.EntityFrameworkCore.Infrastructure;

      این روش‌های تکمیلی هم وجود دارند:
      اگر روش اول برای شما به دلیل ساختارهای پیچیده نسخه‌های قدیمی‌تر در دسترس نبود، می‌توانید به جای تزریق مستقیم IServiceProvider به سازنده کانتکست، IHttpContextAccessor را تزریق کنید. با این کار به راحتی به کانتینرِ نخِ جاری (RequestServices) دسترسی خواهید داشت که کاملاً درون Scope است.
      ۱. کدهای کلاس DbContext را به این شکل تغییر دهید:
      using Microsoft.AspNetCore.Http;
      using Microsoft.EntityFrameworkCore;
      
      public class ApplicationDbContext : DbContext
      {
          private readonly IHttpContextAccessor _httpContextAccessor;
      
          public ApplicationDbContext(
              DbContextOptions<ApplicationDbContext> options, 
              IHttpContextAccessor httpContextAccessor) // تزریق اکسسور به جای سرویس پرووایدر
              : base(options)
          {
              _httpContextAccessor = httpContextAccessor;
          }
      
          protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
          {
              // دسترسی امن به RequestServices که همان Scoped Provider درخواست وب جاری است
              var scopedProvider = _httpContextAccessor.HttpContext?.RequestServices;
      
              if (scopedProvider != null)
              {
                  var scopedInterceptor = scopedProvider.GetRequiredService<UpdateAuditableEntitiesInterceptor>();
                  optionsBuilder.AddInterceptors(scopedInterceptor);
              }
      
              base.OnConfiguring(optionsBuilder);
          }
      }
      ۲. مطمئن شوید در فایل Program.cs خط زیر فعال است:
      builder.Services.AddHttpContextAccessor(); // حتماً باید ثبت شده باشد

      و یا استفاده از AddDbContext معمولی به‌جای AddDbContextPool (اگر اجباری به استفاده از Pool ندارید)
      اگر حجم درخواست‌های سمت دیتابیس شما در ثانیه به شدت بالا نیست (مثلاً کمتر از چند هزار درخواست در ثانیه)، استفاده از AddDbContextPool مزیت چشمگیری نسبت به AddDbContext معمولی ندارد. با تغییر به AddDbContext معمولی، مشکل ارجاع به Root Provider به طور کامل و خودکار توسط خودِ فریم‌ورک حل می‌شود. در فایل Program.cs کانفیگ را به شکل زیر تغییر دهید و دیگر نیازی به نوشتن متد OnConfiguring درون کانتکست نخواهید داشت:
      builder.Services.AddScoped<UpdateAuditableEntitiesInterceptor>();
      
      // استفاده از حالت Scoped استاندارد بدون Pooling
      builder.Services.AddDbContext<ApplicationDbContext>((sp, options) =>
      {
          options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
          
          // در این حالت sp دقیقاً همان Scoped Provider است و خطایی رخ نمی‌دهد
          var interceptor = sp.GetRequiredService<UpdateAuditableEntitiesInterceptor>();
          options.AddInterceptors(interceptor);
      });