عنوان:

‫درج زمان مشترک برای فیلد های Audit در رکورد های درج و ویرایش


نویسنده: میلاد احمدی مقدم
تاریخ: ۱۴۰۵/۰۴/۱۹ ۱۷:۴۳
آدرس: www.dntips.ir
من میخوام تمامی رکورد هایی که در یک عملیات Scoped (طی یک درخواست) در دیتابیس درج یا ویرایش میشن برای فیلد های Audit یک زمان درج بشه.
الان اگر 2 رکورد رو در یک درخواست درج کنم، زمان درج به این صورت با یک اختلاف جزیی برای هر رکورد ثبت میشه
CreatedAt
2026-07-10 13:57:49.8865870
2026-07-10 13:57:49.8865976
الان من این واسط رو تعریف کرده ام
public interface IApplicationDateTimeService
{
    DateTime Now();
}

public sealed class ApplicationDateTimeService : IApplicationDateTimeService
{
    public DateTime Now() => DateTime.Now;
}
و الان از این واسط در UpdateAuditableEntitiesInterceptor استفاده میکنم و چون از DbContextPool استفاده میکنم و ApplicationDateTimeService به صورت Scoped ثبت شده
services.AddSingleton<UpdateAuditableEntitiesInterceptor>();
services.AddScoped<IApplicationDateTimeService, ApplicationDateTimeService>();
هنگام اجرا خطا میده و میگه نمیشه از یک سرویس Scoped داخل Singleton استفاده کنی؛ چون DbContextPool به صورت Singleton هست و سرویس ApplicationDateTimeService به صورت Scoped رجیستر شده.
از هوش مصنوعی این مشکل رو پرسیدم بهم گفت اگه اصرار داری از DbContextPool استفاده کنی میتونی از AsyncLock استفاده کنی و داخل یک Middleware هر بار زمان رو مجدد ست کنی و در طول اون درخواست تمامی تاریخ هایی که از IApplicationDateTimeSerivce میگیری یک زمان رو بهت برمیگردونن.
و سرویس تاریخ رو هم به صورت Singleton ثبت کردم:
services.AddSingleton<IApplicationDateTimeService, ApplicationDateTimeService>();

public interface IApplicationDateTimeService
{
    DateTime Now();

    void SetCurrentTimeForRequest(DateTime time);
}
====
public sealed class ApplicationDateTimeService : IApplicationDateTimeService
{
    private readonly AsyncLocal<DateTime?> _currentTime = new();

    public DateTime Now()
    {
        return _currentTime.Value
               ?? throw new InvalidOperationException(
                   "Application time has not been initialized.");
    }

    public void SetCurrentTimeForRequest(DateTime time)
    {
        _currentTime.Value = time;
    }
}

app.UseHttpsRedirection();

app.Use(async (context, next) =>
{
    var dateService = context.RequestServices
        .GetRequiredService<IApplicationDateTimeService>();

    dateService.SetCurrentTimeForRequest(DateTime.Now);

    await next();
});

...
...

await app.RunAsync();

public sealed class UpdateAuditableEntitiesInterceptor(
    IHttpContextAccessor httpContextAccessor,
    IApplicationDateTimeService appDateTimeService) : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(eventData);

        var db = eventData.Context;
        if (db is null)
        {
            return base.SavingChangesAsync(eventData, result, cancellationToken);
        }

        var now = appDateTimeService.Now();
        var userId = TryGetUserId();

        foreach (var entry in db.ChangeTracker.Entries<IAuditable>()
                     .Where(e => e.State is EntityState.Added or EntityState.Modified))
        {
            if (entry.State == EntityState.Added)
            {
                entry.Property(AuditShadowPropertyNames.CreatedAt).CurrentValue = now;
                Set(entry, AuditShadowPropertyNames.CreatedBy, userId);
            }
            else if (entry.State == EntityState.Modified && HasAnyChanges(entry))
            {
                entry.Property(AuditShadowPropertyNames.UpdatedAt).CurrentValue = now;
                Set(entry, AuditShadowPropertyNames.UpdatedBy, userId);
            }
        }

        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private static bool HasAnyChanges(EntityEntry entry)
    {
        return entry.Properties.Any(p => p.IsModified);
    }

    private static void Set(EntityEntry e, string name, object? value)
    {
        var p = e.Properties.FirstOrDefault(x => x.Metadata.Name.Equals(name, StringComparison.Ordinal));
        if (p is null)
            return;

        var t = p.Metadata.ClrType;

        if (t == typeof(Guid?) || t == typeof(Guid))
        {
            p.CurrentValue = value;
        }
        else if ((t == typeof(UserId?) || t == typeof(UserId)) && value is Guid g)
        {
            p.CurrentValue = new UserId(g);
        }
    }

    private Guid? TryGetUserId()
    {
        var user = httpContextAccessor.HttpContext?.User;
        var s = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        return Guid.TryParse(s, out var g) ? g : null;
    }
}
آیا استفاده از AsyncLock برای این سناریو کار خوبیه یا روش بهتری برای حل این مسئله وجود داره ؟

نظرات

  • وحید نصیری در ۱۴۰۵/۰۴/۱۹ ۲۲:۴۹
    در جائیکه دسترسی به HttpContext وجود دارد، روش دیگر دسترسی به سرویس‌ها به صورت زیر است:
    var serviceT = httpContextAccessor.HttpContext.RequestServices.GetRequiredService<T>();
    البته یکسری مشکلاتی را ممکن است تجربه کنید. برای مثال اگر SaveChanges در یک background worker فراخوانی شود، httpContext فوق، نال خواهد بود و کار نمی‌کند.

    روش بهتر این است که طول عمر Interceptor را به صورت Scoped معرفی کنید تا نیازی به استفاده از روش فوق برای دسترسی به سرویس‌ها نباشد و تزریق وابستگی متداول در سازنده همانند قبل کار کند:
    builder.Services.AddScoped<UpdateAuditableEntitiesInterceptor>(); // Register as Scoped
    
    builder.Services.AddDbContext<MyDbContext>((sp, options) =>
    {
        var interceptor = sp.GetRequiredService<UpdateAuditableEntitiesInterceptor>();
        options.UseSqlServer(connectionString)
               .AddInterceptors(interceptor);
    });

    البته اگر از AddDbContextPool استفاده می‌کنید، روش کار به صورت زیر است؛ چون اگر از روش قبل برای دسترسی به سرویس Interceptor در حین معرفی AddDbContextPool استفاده شود، این سرویس کش خواهد شد و طول عمر آن Singleton می‌شود. بنابراین باید نمونه صحیح از سرویس، در متد OnConfiguring دریافت شود:
    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)
        {
            // Resolve the scoped interceptor from the current scope
            var scopedInterceptor = _serviceProvider.GetRequiredService<UpdateAuditableEntitiesInterceptor>();
            optionsBuilder.AddInterceptors(scopedInterceptor);
    
            base.OnConfiguring(optionsBuilder);
        }
    }
    به همراه تنظیمات اولیه زیر:
    builder.Services.AddScoped<UpdateAuditableEntitiesInterceptor>();
    
    builder.Services.AddDbContextPool<ApplicationDbContext>(options =>
    {
        options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
    });
    • میلاد احمدی مقدم در ۱۴۰۵/۰۴/۱۹ ۲۳:۰۸
      ممنون از جوابتون؛ من خودم فکر کردم که با رجیستر کردن UpdateAuditableEntitiesInterceptor به صورت Scopedمشکلم حل میشه ولی چون آموزش های زیادی رو دیده بودم که همگی تمامی Interceptor ها را به صورت Singleton رجیستر میکردن من فکر میکردم که این یک قانونه که بهتره همیشه Interceptor ها به صورت Singleton رجیستر بشن که الان هم شما گفتید مشکل نداره به صورت Scoped ثبت بشه هم از ChatGPT پرسیدم و در جواب اینو گفت:
      خیر، Interceptor ذاتاً Singleton نیست.
      بله، می‌تواند Scoped باشد.
      اینکه در خیلی از نمونه‌ها Singleton ثبت می‌شود، به این دلیل است که آن Interceptor state ندارد و فقط روی پارامترهای ورودی کار می‌کند، نه اینکه EF Core الزام کرده باشد Singleton باشد (^)