مدیریت دغدغههای عرضی در Entity Framework Core با استفاده از Interceptorها
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۵/۰۷ ۰۹:۵۰
آدرس: www.dntips.ir
چکیده: در توسعه سامانههای مبتنی بر داتنت (NET Framework / .NET Core.)، یکپارچهسازی و پیادهسازی متمرکز منطقهای زیرساختی و عرضی (Cross-Cutting Concerns) از اهمیت بالایی برخوردار است. تکرار کدهایی نظیر ثبت تاریخچه تغییرات (Audit Logging)، حذف نرم (Soft Delete) و درج پیامهای الگوی Outbox در دستورات مختلف، علاوه بر افزایش خطای انسانی و نگهداریپذیری پایین، منجر به آلودگی کدهای حوزه کسبوکار (Domain Logic) میشود. قابلیت Interceptor در Entity Framework Core (بهاختصار EF Core) ابزاری قدرتمند جهت فراتابی (Interception) و دستکاری عملیات پایگاهداده به صورت خودکار فراهم میسازد. این مقاله با بررسی ساختار SaveChangesInterceptor و پیادهسازی متمرکز الگوی Outbox، نشان میدهد که چگونه میتوان با جداسازی کامل دغدغههای زیرساختی، کد بدنه برنامه را ساده، امن و عاری از پیچیدگی غیرضروری نگه داشت.DbContext) ایفا میکنند. به کمک Interceptorها میتوان پیش از اجرا یا پس از اجرای عملیات پایگاهداده (مانند اجرای پرسوجوها، باز یا بستهشدن اتصالات، یا فرآیند ذخیرهسازی تغییرات)، به این فرآیندها متصل شد و رفتار آنها را متوقف، بازرسی یا تغییر داد.[ Domain Handler ] ──> context.SaveChangesAsync()
│
▼
┌───────────────────────┐
│ OutboxInterceptor │ ──> (Inspect ChangeTracker & Insert Outbox Messages)
└───────────────────────┘
│
▼
[ Execute DB Transaction ]SaveChangesAsyncSaveChangesAsync در کلاس DbContext بود. با اینکه این روش همچنان کارآمد است، استفاده از SaveChangesInterceptor دو مزیت کلیدی دارد:DbContext اصلی، هر دغدغه زیرساختی (Outbox، Auditing و ...) در یک کلاس کاملاً مستقل و تستپذیر پیادهسازی میشود.public async Task Handle(DeleteUserCommand command, CancellationToken ct)
{
var user = await _context.Users.FindAsync(command.Id, ct);
user.Delete();
var domainEvent = new UserDeletedEvent(user.Id);
var outboxMessage = new OutboxMessage
{
Id = Guid.NewGuid(),
Type = domainEvent.GetType().AssemblyQualifiedName!,
Payload = JsonSerializer.Serialize(domainEvent, domainEvent.GetType()),
CreatedAt = DateTime.UtcNow
};
_context.Set<OutboxMessage>().Add(outboxMessage);
await _context.SaveChangesAsync(ct);
}OutboxMessage را فراموش کند، هیچ خطایی صادر نمیشود اما سایر سیستمها از حذف این کاربر مطلع نخواهند شد و دادهها دچار عدم همگامسازی (Data Drift) میشوند.public interface IDomainEvent { }
public abstract class Entity
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void Raise(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}OutboxInterceptor را پیادهسازی میکنیم. این کلاس پیش از ثبت نهایی تغییرات در پایگاهداده، موجودیتهای تغییریافته را از طریق ChangeTracker بازرسی کرده، رویدادها را استخراج، به OutboxMessage تبدیل و به Context اضافه میکند:using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Text.Json;
public class OutboxInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is null)
return base.SavingChangesAsync(eventData, result, cancellationToken);
var context = eventData.Context;
// ۱. استخراج تمامی رویدادهای موجودیتهای متصل به ChangeTracker
var domainEvents = context.ChangeTracker
.Entries<Entity>()
.Select(entry => entry.Entity)
.SelectMany(entity =>
{
var events = entity.DomainEvents.ToList();
entity.ClearDomainEvents(); // پاکسازی جهت جلوگیری از پردازش تکراری
return events;
})
.ToList();
// ۲. تبدیل رویدادها به پیامهای Outbox
var outboxMessages = domainEvents.Select(domainEvent => new OutboxMessage
{
Id = Guid.NewGuid(),
Type = domainEvent.GetType().AssemblyQualifiedName!,
Payload = JsonSerializer.Serialize(domainEvent, domainEvent.GetType()),
CreatedAt = DateTime.UtcNow
}).ToList();
// ۳. افزودن پیامها به پایگاهداده در همان تراکنش
if (outboxMessages.Count > 0)
{
context.Set<OutboxMessage>().AddRange(outboxMessages);
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}public async Task Handle(DeleteUserCommand command, CancellationToken ct)
{
var user = await _context.Users.FindAsync(command.Id, ct);
user.Delete(); // متد Delete درون خود یک UserDeletedEvent ثبت میکند
await _context.SaveChangesAsync(ct);
}| مواردی که باید در Interceptor پیادهسازی شوند | مواردی که نباید در Interceptor پیادهسازی شوند |
ثبت خودکار Audit Trail (مانند CreatedAt, ModifiedBy) | اعتبارسنجی منطق کسبوکار (مانند: "عنوان مقاله نباید خالی باشد") |
| درج خودکار پیامهای Outbox | قواعد دامنه (مانند: "ارسال ایمیل خوشآمدگویی پس از ثبتنام") |
| اعمال فیلتر حذف نرم (Soft Delete) | رفتارهای مشروط بر اساس سناریوهای خاص کاربر |
قاعده طلایی:
اگر مجبور هستید عملکرد یا علت اجرای یک کد درون Interceptor را برای مدیر محصول (Product Manager) توضیح دهید، آن کد متعلق به Interceptor نیست و باید درون Domain / Application Handler قرار گیرد.
IHttpContextAccessor)، باید Interceptor نیز بهصورت Scoped ثبت شده و یا از طریق IServiceProvider به Context اضافه شود:services.AddScoped<OutboxInterceptor>();
services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
var interceptor = sp.GetRequiredService<OutboxInterceptor>();
options.UseSqlServer(connectionString)
.AddInterceptors(interceptor);
});AddInterceptors اهمیت دارد. فرآیندهایی که موجودیتها را تغییر میدهند (مانند Soft Delete) باید قبل از Interceptor ثبت Outbox اجرا شوند تا رویدادهای تولیدشده احتمالی نیز توسط Outbox دریافت گردند.IHostedService) میتوانند پیامها را خوانده و با تضمین حداقل یکبار تحویل (At-least-once delivery) پردازش کنند.SaveChangesInterceptor در EF Core رویکردی مدرن، تمیز و مطمئن جهت مدیریت دغدغههای عرضی زیرساخت ارائه میدهد. این ابزار با خارجکردن کدهای تکراری از گردانندهها و متمرکزساختن آنها در لایه زیرساخت، احتمال بروز خطاهای خاموش را به صفر رسانده و خوانایی، تستپذیری و نگهداریپذیری سیستم را به شکل چشمگیری افزایش میدهد.