在基于EF Core的项目里,我们经常会遇到一类需求:每当数据被保存时,自动记录谁在什么时间改了哪些字段,或者把删除操作改成软删除,又或者在保存前统一校验数据。这些逻辑如果散落在各个业务方法里,维护起来非常痛苦。EF Core提供的ISaveChangesInterceptor接口正是为了解决这类问题而生,它让你可以在SaveChanges调用链的关键节点上挂载自定义逻辑,实现真正的横切关注点分离。

一、为什么用拦截器而不是重写SaveChanges
在ISaveChangesInterceptor出现之前,常见的做法是自定义一个DbContext基类,重写它的SaveChanges和SaveChangesAsync方法,在里面遍历ChangeTracker.Entries()来处理审计字段。这种方式能用,但缺点也很明显:重写方法时需要自己处理异步版本、异常传播和返回值,代码重复度高;而且重写后的逻辑和EF Core内部执行流程耦合在一起,一旦需要多个横切逻辑(比如既要审计又要软删除),重写方法会越堆越长。
拦截器机制把这些逻辑拆成了独立的类,每个拦截器只关心自己的职责。EF Core在执行SaveChanges的各个阶段会回调拦截器对应的方法,你还可以在一个DbContext上注册多个拦截器,它们会按注册顺序依次执行,和ASP.NET Core中间件的设计思路非常相似。此外,拦截器通过依赖注入容器创建,可以方便地注入日志、当前用户服务等外部依赖,这是重写SaveChanges很难做到的。
简单总结一下:如果你的项目只是简单填充一两个字段,重写SaveChanges勉强够用;但凡涉及审计、软删除、多租户这类需要复用、需要测试、需要依赖外部服务的逻辑,拦截器都是更干净的选择。
二、ISaveChangesInterceptor核心方法与执行时机
ISaveChangesInterceptor接口包含两个同步方法和两个异步方法,两两对应。理解它们的执行时机是用好拦截器的关键。
第一个是SavingChanges和对应的SavingChangesAsync,它在EF Core开始向数据库提交更改之前被调用。此时所有实体的状态追踪信息已经完整,是最适合做审计数据收集、自动填充时间戳、软删除改写的地方。事件参数类型是SaveChangesInterceptorEventData,从中可以拿到DbContext实例和Entries集合。
第二个是SavedChanges和SavedChangesAsync,它在保存成功之后被调用,参数里的RowsAffected属性告诉你这次实际影响了多少行。适合做保存后的缓存失效、消息通知等操作。
第三个是SaveChangesFailed和SaveChangesFailedAsync,当保存过程中抛出异常时触发,参数中的Exception属性携带了原始异常,你可以在这里记录失败日志或者对特定异常做转换处理。
注意一点:如果你不需要处理全部阶段,不要直接实现ISaveChangesInterceptor接口,而是继承SaveChangesInterceptor这个抽象基类。它已经用虚方法实现了接口的所有成员,你只重写自己关心的方法即可,代码会简洁很多。
三、实战:实现一个自动审计日志拦截器
下面通过一个完整例子演示拦截器的用法。假设我们有一个AuditLog实体,需要在每次保存时把新增、修改、删除的实体变更记录到审计表中,同时自动填充创建时间和更新时间。
先定义审计实体和基础接口:
public interface IAuditable
{
DateTime CreatedAt { get; set; }
DateTime? UpdatedAt { get; set; }
}
public class AuditLog
{
public long Id { get; set; }
public string TableName { get; set; }
public string Action { get; set; } // Added / Modified / Deleted
public string? OldValues { get; set; } // 变更前的JSON
public string? NewValues { get; set; } // 变更后的JSON
public string? ChangedBy { get; set; }
public DateTime CreatedAt { get; set; }
}然后编写拦截器本身。这里继承SaveChangesInterceptor基类,只重写同步版本的SavingChanges方法,异步场景下EF Core会自动走异步重载,所以实际项目中建议同步异步都重写,或者抽一个公共的私有方法供两者调用:
public class AuditInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUserService _currentUser;
public AuditInterceptor(ICurrentUserService currentUser)
{
_currentUser = currentUser;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
ApplyAudit(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
ApplyAudit(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void ApplyAudit(DbContext? context)
{
if (context is null) return;
var user = _currentUser.UserName ?? "System";
var now = DateTime.Now;
foreach (var entry in context.ChangeTracker.Entries().ToList())
{
// 自动填充审计时间字段
if (entry.Entity is IAuditable auditable)
{
switch (entry.State)
{
case EntityState.Added:
auditable.CreatedAt = now;
break;
case EntityState.Modified:
auditable.UpdatedAt = now;
break;
}
}
// 写入审计日志(跳过审计实体自身,避免递归)
if (entry.Entity is AuditLog) continue;
if (entry.State == EntityState.Unchanged) continue;
var log = new AuditLog
{
TableName = entry.Metadata.GetTableName() ?? entry.Entity.GetType().Name,
Action = entry.State.ToString(),
NewValues = entry.State == EntityState.Deleted
? null
: System.Text.Json.JsonSerializer.Serialize(entry.CurrentValues.ToObject()),
OldValues = entry.State == EntityState.Added
? null
: System.Text.Json.JsonSerializer.Serialize(entry.OriginalValues.ToObject()),
ChangedBy = user,
CreatedAt = now
};
context.Set<AuditLog>().Add(log);
}
}
}这里有几个细节值得注意。第一,遍历ChangeTracker.Entries()时用了ToList(),因为循环体内调用Set<AuditLog>().Add()会修改追踪集合,不复制快照会抛出集合被修改的异常。第二,代码里跳过了AuditLog实体本身,否则每写一条审计日志又会触发新的变更,形成无限循环。第三,获取旧值用的是entry.OriginalValues,它在EF Core尚未保存时保留了数据库中的原始数据,非常适合做变更前后的对比。
四、拦截器的注册方式与常见坑
拦截器有两种注册途径。第一种是全局注册,在AddDbContext时通过AddInterceptors传入,所有DbContext实例都会生效,这是最常用的方式。由于拦截器构造函数依赖了ICurrentUserService,我们需要先把拦截器本身注册到DI容器,再用工厂模式接入:
builder.Services.AddScoped<AuditInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>());
});第二种方式是在DbContext的OnConfiguring方法里调用optionsBuilder.AddInterceptors(new AuditInterceptor(...)),适合控制台小工具或单元测试,但手动new出来的拦截器无法享受依赖注入,正式项目不推荐。
关于生命周期有一个很容易踩的坑:如果拦截器里注入了Scoped服务(比如当前用户服务),拦截器本身也必须注册为Scoped,而DbContext默认也是Scoped,两者生命周期匹配就没有问题。但如果你在AddDbContext之外又通过AddDbContextPool使用上下文池,池化的上下文会复用配置,此时拦截器实例也会被复用,务必确保拦截器是无状态或线程安全的。
另一个坑是在SavingChanges中修改实体状态。比如实现软删除时,你会把entry.State = EntityState.Deleted改成先设置IsDeleted标志再标记为Modified,这个操作是允许的,但要放在遍历快照之后进行,避免状态变化影响同一次遍历的逻辑判断。另外,如果在拦截器中抛出异常,SaveChanges会整体失败并回滚,这其实可以当作一种统一数据校验的手段,但要注意异常消息对调用方是否友好。
最后提醒一下异步重载的问题。如果你的业务代码大量使用SaveChangesAsync,却只重写了同步的SavingChanges,你的逻辑将完全不会执行,因为EF Core对同步和异步路径的分发是独立的。稳妥的做法是像上面示例那样,同步异步各写一个重写方法,把真正的逻辑抽到私有方法中共享,这样无论调用方用哪种方式保存都能正确触发拦截逻辑。
EF CoreISaveChangesInterceptorSaveChanges拦截器修改时间:2026-09-06 14:38:51