在EF Core项目中,每张表往往都需要记录数据的创建时间和最后修改时间。如果让业务代码每次赋值,不仅冗余,还可能在某次更新中被遗忘。利用SaveChanges拦截器,我们可以把这件事完全收口到数据层。

为什么需要SaveChanges拦截器
传统做法是在实体类里声明CreatedAt和UpdatedAt属性,然后在调用SaveChanges之前遍历ChangeTracker里的实体手动赋值。这种方式要求每个开发人员都记得处理,且代码分散在多个地方,难以统一规范。
EF Core从3.0开始提供了IDbContextInterceptor接口以及更具体的ISaveChangesInterceptor。拦截器在DbContext执行保存逻辑的前后触发,可以无侵入地修改即将写入数据库的实体状态,非常适合做审计字段、软删除标记等通用处理。
定义审计接口与实体
首先定义一个标记接口,用来表示哪些实体需要自动维护时间字段。使用接口而不是基类,可以避免限制实体的继承体系。
public interface IAuditable
{
DateTime CreatedAt { get; set; }
DateTime UpdatedAt { get; set; }
}
public class Article : IAuditable
{
public int Id { get; set; }
public string Title { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
上面的IAuditable接口只规定了两个DateTime属性。任何需要实现自动时间的实体直接实现该接口即可,不需要关心赋值逻辑。
为了兼容更多类型,也可以把时间字段定义为DateTimeOffset,这样能保留时区信息。但在大多数内部系统里,统一使用UTC的DateTime已经足够。
实现SaveChanges拦截器
接下来创建一个实现ISaveChangesInterceptor的类。我们在SavingChanges方法里检查所有被跟踪的实体,对新增和修改的IAuditable实体分别赋值。
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore;
using System.Linq;
public class AuditSaveChangesInterceptor : ISaveChangesInterceptor
{
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
UpdateTimestamps(eventData.Context);
return result;
}
public ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
UpdateTimestamps(eventData.Context);
return ValueTask.FromResult(result);
}
private void UpdateTimestamps(DbContext? context)
{
if (context == null)
{
return;
}
var now = DateTime.UtcNow;
var entries = context.ChangeTracker.Entries<IAuditable>();
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
}
在SavingChanges中,我们通过eventData.Context拿到当前DbContext实例,再使用ChangeTracker.Entries<IAuditable>()筛选出实现了审计接口的实体。注意这里泛型方法会自动过滤未实现接口的实体,避免额外的类型判断。
对于Added状态的实体,同时设置创建和修改时间;对于Modified状态,只更新修改时间。这样保证创建时间一旦写入就不会被后续更新覆盖。如果实体只是被查询而未改动,则不会进入这两个分支。
注册拦截器到DbContext
拦截器需要在配置DbContext时通过AddInterceptors方法注册。在ASP.NET Core里通常在Program.cs中完成。
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer("Server=.;Database=Demo;Trusted_Connection=True;");
options.AddInterceptors(new AuditSaveChangesInterceptor());
});
var app = builder.Build();
上述代码把拦截器实例直接传入AddInterceptors。如果拦截器本身需要依赖注入其他服务,也可以先注册拦截器为Scoped或Singleton,再以typeof形式添加,但多数时间戳场景无外部依赖,直接new即可。
注册完成后,任何通过AppDbContext保存的IAuditable实体都会在调用SaveChanges或SaveChangesAsync时自动带上时间,业务层完全不需要写一行赋值代码。
时区与并发注意点
使用DateTime.UtcNow而不是本地时间,可以避免服务器时区配置不同导致数据错乱。如果前端需要展示本地时间,在查询出来后再做转换,不要存入本地时间。
在高频并发写入场景中,拦截器里的now变量是在每次保存开始时统一获取的,因此同一批提交的所有实体时间一致,这通常是符合预期的行为。若业务要求精确到毫秒且绝对不允许重复,可考虑使用数据库默认值或序列,但一般审计用途无需如此严格。
与基类方案的对比
另一种常见做法是让所有实体继承一个AuditableEntity基类。基类方案简单直观,但C#只支持单继承,如果实体已有其他父类就会冲突。拦截器方案基于接口,更灵活,也更容易在已有项目中逐步接入。
| 方案 | 侵入性 | 继承限制 | 维护成本 |
|---|---|---|---|
| 手动赋值 | 高 | 无 | 高 |
| 基类字段 | 中 | 有 | 低 |
| SaveChanges拦截器 | 低 | 无 | 低 |
从表中可以看出,拦截器在保持低侵入的同时没有继承限制,是多数新项目的优选。老项目若已用基类,也可以保留原有方式,不必强制迁移。
总结
通过实现ISaveChangesInterceptor并在DbContext中注册,我们让EF Core在保存前自动维护创建和修改时间。该方式解耦业务代码,统一规则,且基于接口不限制实体结构。配合UTC时间与合理的状态判断,可稳定支撑日常审计需求。
EF_CoreSaveChanges_Interceptor审计字段修改时间:2026-08-01 01:21:29