چالشهای الگوی Service Locator و پیادهسازی Scoped Interceptors در EF Core
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۴/۲۰ ۰۸:۱۵
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
Transient ،Scoped و Singleton مدیریت میکند. با این حال، در سناریوهای پیچیده توسعه نرمافزار، توسعهدهندگان اغلب با چالش "تداخل چرخه عمر سرویسها" (Capturing a Scoped Service inside a Singleton) مواجه میشوند؛ وضعیتی که در آن یک سرویس با طول عمر طولانیتر (مانند یک Singleton Middleware یا یک EF Core Interceptor) نیاز به دسترسی به یک سرویس با طول عمر کوتاهتر (مانند DbContext یا اطلاعات کاربر جاری) دارد.HttpContext.RequestServices است. این مقاله به تحلیل عمیق سازوکار این الگو، مخاطرات و بنبستهای فنی آن پرداخته و در نهایت، الگوهای جایگزین و استانداردی مانند Scoped Interceptors را در محیطهای مجهز به مکانیزم Pool (مانند AddDbContextPool) تبیین میکند.RequestServices در اکوسیستم HttpContext است.HttpContext.RequestServices به طور مستقیم به کانتینر Scoped ایجاد شده برای آن درخواست HTTP خاص اشاره دارد.Dispose را روی سرویسهای نمونهسازی شده فراخوانی میکند.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);
}
}HttpContext به هیچ وجه Thread-safe نیست. استفاده از RequestServices درون کارهای موازی (مانند Task.WhenAll) که خارج از نخ اصلی درخواست اجرا میشوند، کانتینر را دچار فساد دادهای (Scope Corruption) میکند.SaveChangesInterceptor در EF Core برای ثبت اطلاعات حسابرسی (Audit Logs) است. اینترسپتورها به طور پیشفرض و سنتی به صورت Singleton ثبت میشوند، اما سرویسی که اطلاعات کاربر جاری را نگهداری میکند (ICurrentUserService) باید Scoped باشد تا به درخواست وب جاری متصل گردد.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;
}
}
}
}SaveChanges از طریق یک Background Worker، یک Job در Hangfire یا ابزار CLI مربوط به EF Migrations صدا زده شود، مقدار HttpContext تهی (null) خواهد بود و خطر خطای NullReferenceException وجود دارد.ICurrentUserService خود برای یافتن اطلاعات کاربر نیاز به کوئری زدن روی همان DbContext داشته باشد، یک حلقه بیانتها شکل میگیرد:SaveChanges -> Interceptor -> RequestServices -> CurrentUserService -> DbContext Query -> SaveChangesRequest Scope -> DbContext (Scoped) , ICurrentUserService (Scoped) , AuditInterceptor (Scoped)AddDbContextPool برای بالا بردن کارایی استفاده کند، یک چالش فنی جدید پدید میآید. در فرآیند Pooling، کدهای بخش کانفیگ داتنت (options lambda) تنها یکبار در زمان استارتآپ برنامه کامپایل و کش میشوند. بنابراین اگر کدی مانند زیر بنویسید:// ❌ خطای منطقی در کدهای با معماری Pool
builder.Services.AddDbContextPool<MyDbContext>((sp, options) => {
var interceptor = sp.GetRequiredService<AuditInterceptor>(); // سرویس در استارتآپ قفل میشود!
options.AddInterceptors(interceptor);
});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);
}
}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);
}
}builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<AuditInterceptor>(); // ثبت به صورت Scoped
// ثبت به صورت Pool بدون دغدغه تداخل چرخه عمر سرویسها
builder.Services.AddDbContextPool<ApplicationDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
});ApplicationDbContext را از Pool اجاره میکند._serviceProvider کانتکست را با Scope درخواست HTTP جاری هماهنگ و بهروزرسانی میکند.OnConfiguring اجرا شده و دقیقاً نمونه AuditInterceptor مربوط به همان Request جاری را دریافت میکند. این رویکرد حتی در کارهای پسزمینه (Background Services) نیز در صورت ساخت دستی Scope به درستی کار خواهد کرد.HttpContext.RequestServices اگرچه در نگاه اول راهکاری سریع برای فرار از خطاهای محدوده سرویسها (DI Lifetimes) به نظر میرسد، اما به دلیل سست کردن پایههای تستپذیری، پنهان کردن وابستگیها و عدم ایمنی در پردازشهای چندنخی، انتخابی ایدهآل برای پروژههای سازمانی نیست.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 است.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);
});