在Entity Framework Core中实现软删除与审计字段自动填充技术插图

在Entity Framework Core中实现软删除与审计字段自动填充技术:从基础配置到高级拦截

大家好,今天我想和大家深入聊聊在Entity Framework Core(以下简称EF Core)项目中,两个非常实用且能极大提升代码健壮性的功能:软删除(Soft Delete)和审计字段(Audit Fields)的自动填充。在实际开发中,我们常常遇到“删除数据不能真删”和“谁在什么时候改了数据需要留痕”的需求。手动处理这些字段不仅繁琐,还容易遗漏。经过多个项目的实践和踩坑,我总结了一套相对优雅的自动化方案,核心思路是利用EF Core的拦截器(Interceptors)全局查询过滤器(Global Query Filters)。下面,我将一步步带你实现。

第一步:定义包含软删除与审计字段的基类

首先,我们需要一个所有实体类都可以继承的基类。这个基类将包含我们需要的公共字段。这是实现统一管理的基础。我通常会创建两个基类:一个包含完整的审计字段,另一个可能只包含软删除字段,视项目复杂度而定。

// 基础审计实体抽象类
public abstract class AuditableEntity
{
    // 软删除标志
    public bool IsDeleted { get; set; } = false;

    // 创建审计
    public DateTime CreatedAt { get; set; }
    public string? CreatedBy { get; set; } // 可存储用户名或用户ID

    // 更新审计
    public DateTime? LastModifiedAt { get; set; }
    public string? LastModifiedBy { get; set; }
}

// 示例实体继承
public class BlogPost : AuditableEntity
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
}

踩坑提示:字段如`CreatedBy`最好定义为可空(`string?`),因为在一些后台作业或系统初始化场景下,可能没有明确的“操作人”。

第二步:配置全局查询过滤器,自动过滤已删除数据

软删除的核心是,当我们查询数据时,EF Core应该自动忽略那些被标记为`IsDeleted == true`的记录。我们可以在`DbContext`的`OnModelCreating`方法中,为所有继承自`AuditableEntity`的实体配置全局查询过滤器。

public class ApplicationDbContext : DbContext
{
    public DbSet BlogPosts { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // 为所有继承自AuditableEntity的实体自动应用软删除过滤
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (typeof(AuditableEntity).IsAssignableFrom(entityType.ClrType))
            {
                // 添加查询过滤器,自动过滤已删除数据
                modelBuilder.Entity(entityType.ClrType)
                    .HasQueryFilter(e => !EF.Property(e, nameof(AuditableEntity.IsDeleted)));
            }
        }
    }
}

配置后,像`_context.BlogPosts.ToList()`这样的查询,将永远不会返回已删除的博文。如果需要查询包含已删除的数据,可以使用`_context.BlogPosts.IgnoreQueryFilters()`来临时忽略这个过滤器。

实战经验:这个过滤器非常有效,但要注意,它可能会影响一些复杂的查询,比如涉及`Include`或子查询时。务必在复杂场景下测试其行为。

第三步:创建拦截器,实现审计字段自动填充

这是实现自动化的关键。我们需要创建一个继承自`SaveChangesInterceptor`的拦截器,在数据保存之前(`SavingChanges`事件中),遍历所有变更的实体,并为其审计字段赋值。

public class AuditableEntityInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUserService; // 假设此服务能获取当前用户信息

    public AuditableEntityInterceptor(ICurrentUserService currentUserService)
    {
        _currentUserService = currentUserService;
    }

    public override InterceptionResult SavingChanges(
        DbContextEventData eventData,
        InterceptionResult result)
    {
        UpdateAuditableEntities(eventData.Context);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
    {
        UpdateAuditableEntities(eventData.Context);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

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

        var currentUserId = _currentUserService.GetCurrentUserId(); // 获取当前用户ID
        var utcNow = DateTime.UtcNow; // 建议使用UTC时间

        foreach (var entry in context.ChangeTracker.Entries())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Entity.CreatedAt = utcNow;
                    entry.Entity.CreatedBy = currentUserId;
                    entry.Entity.LastModifiedAt = utcNow;
                    entry.Entity.LastModifiedBy = currentUserId;
                    entry.Entity.IsDeleted = false; // 确保新增时未被删除
                    break;

                case EntityState.Modified:
                    entry.Entity.LastModifiedAt = utcNow;
                    entry.Entity.LastModifiedBy = currentUserId;
                    // 注意:不要在这里修改CreatedAt和CreatedBy
                    break;

                case EntityState.Deleted:
                    // 实现软删除:将删除操作改为更新操作
                    entry.State = EntityState.Modified;
                    entry.Entity.IsDeleted = true;
                    entry.Entity.LastModifiedAt = utcNow;
                    entry.Entity.LastModifiedBy = currentUserId;
                    break;
            }
        }
    }
}

核心逻辑解析
1. 新增(Added):设置创建时间和修改时间,以及操作人。
2. 修改(Modified):只更新最后修改时间和修改人,保护原始创建信息。
3. 删除(Deleted):这是实现软删除的魔法步骤!我们将实体的状态从`Deleted`改为`Modified`,然后设置`IsDeleted = true`。这样EF Core就会执行更新操作而非删除操作。

第四步:在DbContext中注册并使用拦截器

最后一步,我们需要在配置DbContext服务时,将这个拦截器注册进去。通常在`Program.cs`或`Startup.cs`中完成。

// 在依赖注入容器中注册
builder.Services.AddScoped(); // 你的用户服务
builder.Services.AddDbContext((sp, options) =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
    // 添加拦截器
    options.AddInterceptors(sp.GetRequiredService());
});

// 注册拦截器本身
builder.Services.AddScoped();

高级技巧与注意事项

1. 处理批量删除:`DbContext`的`RemoveRange`方法会触发拦截器,实现软删除。但如果你直接执行原始SQL删除(如`context.Database.ExecuteSqlRaw`),拦截器将无法捕获,会进行物理删除。此时,要么避免使用原始删除,要么在SQL中自己实现软删除逻辑。

2. 时间与时区:强烈建议在数据库和代码中统一使用UTC时间(`DateTime.UtcNow`),在前端显示时再根据用户时区转换。这能避免时区混乱带来的噩梦。

3. 性能考量:拦截器会在每次`SaveChanges`时运行,遍历所有变更条目。在数据变更量极大的场景下,需留意其开销,但对于大多数应用来说,这个开销可以忽略不计。

4. 测试:务必为你的拦截器编写单元测试和集成测试,模拟不同的实体状态(Added, Modified, Deleted),确保字段填充逻辑正确,特别是软删除逻辑。

总结一下,通过“基类定义字段 + 全局查询过滤器自动过滤 + 拦截器自动赋值”这套组合拳,我们成功地将软删除和审计追踪这些横切关注点(Cross-Cutting Concerns)从业务逻辑中解耦出来。代码变得更干净,维护性更高,也从根本上减少了因忘记手动设置这些字段而导致的Bug。希望这篇教程能对你的项目有所帮助!如果你有更巧妙的实现方式,也欢迎一起探讨。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。