1. EF Core 拦截器机制概述
EF Core 拦截器(Interceptor)是 Entity Framework Core 提供的一个强大扩展点,它允许开发人员在 EF Core 执行特定操作时插入自定义逻辑。这种机制基于观察者模式实现,通过拦截器我们可以监控、修改甚至阻止 EF Core 的默认行为。
在实际项目中,拦截器最常见的应用场景包括:
- 数据审计(记录谁在什么时候修改了什么数据)
- 自动填充创建时间/修改时间等通用字段
- SQL 语句监控和性能分析
- 多租户数据过滤
- 软删除实现
EF Core 提供了多种类型的拦截器,每种拦截器对应不同的执行阶段和操作类型。其中最重要的两种是:
- DbCommandInterceptor:拦截所有数据库命令(增删改查)
- SaveChangesInterceptor:专门拦截 SaveChanges 和 SaveChangesAsync 操作
提示:拦截器与 EF Core 的另一个重要扩展点——查询筛选器(Global Query Filters)不同。查询筛选器主要用于在查询时自动应用过滤条件(如软删除),而拦截器则可以干预更底层的操作。
2. SaveChangesInterceptor 深度解析
2.1 SaveChangesInterceptor 的工作原理
SaveChangesInterceptor 专门用于拦截 EF Core 的 SaveChanges 和 SaveChangesAsync 方法。当调用这两个方法时,拦截器会在不同阶段触发相应的事件:
csharp复制public class MySaveChangesInterceptor : SaveChangesInterceptor
{
// 在 SaveChanges 开始前触发
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
// 预处理逻辑
return result;
}
// 在 SaveChanges 完成后触发
public override int SavedChanges(
SaveChangesCompletedEventData eventData,
int result)
{
// 后处理逻辑
return result;
}
// 异步版本的方法也有对应的重写
}
2.2 典型应用场景
2.2.1 自动审计日志
通过 SavingChanges 方法,我们可以获取即将保存的所有变更:
csharp复制public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
var changeTracker = eventData.Context.ChangeTracker;
foreach (var entry in changeTracker.Entries())
{
if (entry.State == EntityState.Added ||
entry.State == EntityState.Modified ||
entry.State == EntityState.Deleted)
{
// 记录审计日志
LogAudit(entry);
}
}
return result;
}
2.2.2 自动填充时间戳
对于包含 CreatedAt 和 UpdatedAt 的实体,可以自动填充这些字段:
csharp复制foreach (var entry in changeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = DateTime.UtcNow;
entry.Entity.UpdatedAt = DateTime.UtcNow;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = DateTime.UtcNow;
}
}
注意:在使用自动填充时间戳时,建议始终使用 UTC 时间,避免时区问题。同时,对于高并发场景,可以考虑使用数据库服务器时间而非应用服务器时间。
3. CommandInterceptor 实战应用
3.1 CommandInterceptor 的核心方法
DbCommandInterceptor 提供了更多细粒度的控制点,可以拦截所有发送到数据库的命令:
csharp复制public class MyCommandInterceptor : DbCommandInterceptor
{
// 命令执行前
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
// 可以修改 SQL 命令
return result;
}
// 命令执行后
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
// 可以记录执行耗时等
return result;
}
// 其他方法:NonQueryExecuting/Executed, ScalarExecuting/Executed
}
3.2 性能监控实现
通过 CommandInterceptor 可以轻松实现 SQL 执行时间监控:
csharp复制public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
eventData.Context.Items["CommandStartTime"] = stopwatch;
return await base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
if (eventData.Context.Items.TryGetValue("CommandStartTime", out var startTimeObj)
&& startTimeObj is Stopwatch stopwatch)
{
stopwatch.Stop();
LogCommandDuration(command.CommandText, stopwatch.ElapsedMilliseconds);
}
return await base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
3.3 SQL 日志记录与修改
CommandInterceptor 还可以用于记录或修改 SQL:
csharp复制public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
// 记录原始 SQL
var originalSql = command.CommandText;
// 修改 SQL(例如添加查询提示)
if (command.CommandText.StartsWith("SELECT"))
{
command.CommandText += " OPTION (MAXDOP 4)";
}
return base.ReaderExecuting(command, eventData, result);
}
提示:修改 SQL 时要特别小心,确保不会破坏原有逻辑或引入 SQL 注入漏洞。对于简单的添加查询提示等操作相对安全,但复杂的 SQL 解析和修改建议使用专门的 SQL 解析库。
4. 审计日志系统完整实现
4.1 审计模型设计
一个完整的审计日志系统通常包含以下核心字段:
csharp复制public class AuditLog
{
public Guid Id { get; set; }
public string UserId { get; set; } // 操作用户
public string Action { get; set; } // 操作类型:Create/Update/Delete
public string TableName { get; set; } // 表名
public string EntityId { get; set; } // 实体主键
public DateTime Timestamp { get; set; } // 操作时间
public string OldValues { get; set; } // 旧值(JSON)
public string NewValues { get; set; } // 新值(JSON)
public string ChangedColumns { get; set; } // 变更的列
}
4.2 拦截器实现审计
结合 SaveChangesInterceptor 实现审计:
csharp复制public class AuditInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUserService _currentUserService;
public AuditInterceptor(ICurrentUserService currentUserService)
{
_currentUserService = currentUserService;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
var context = eventData.Context;
var auditEntries = new List<AuditEntry>();
foreach (var entry in context.ChangeTracker.Entries())
{
if (entry.State == EntityState.Detached ||
entry.State == EntityState.Unchanged)
continue;
var auditEntry = new AuditEntry(entry)
{
TableName = entry.Metadata.GetTableName(),
UserId = _currentUserService.UserId
};
auditEntries.Add(auditEntry);
foreach (var property in entry.Properties)
{
if (property.IsTemporary)
continue;
string propertyName = property.Metadata.Name;
if (property.Metadata.IsPrimaryKey())
{
auditEntry.EntityId = property.CurrentValue.ToString();
continue;
}
switch (entry.State)
{
case EntityState.Added:
auditEntry.Action = "Create";
auditEntry.NewValues[propertyName] = property.CurrentValue;
break;
case EntityState.Deleted:
auditEntry.Action = "Delete";
auditEntry.OldValues[propertyName] = property.OriginalValue;
break;
case EntityState.Modified:
if (property.IsModified)
{
auditEntry.Action = "Update";
auditEntry.OldValues[propertyName] = property.OriginalValue;
auditEntry.NewValues[propertyName] = property.CurrentValue;
auditEntry.ChangedColumns.Add(propertyName);
}
break;
}
}
}
if (auditEntries.Any())
{
var auditLogs = auditEntries.Select(x => x.ToAuditLog());
context.Set<AuditLog>().AddRange(auditLogs);
}
return base.SavingChanges(eventData, result);
}
}
4.3 审计性能优化
审计日志可能会对性能产生影响,特别是在高频写入场景下。以下是一些优化建议:
- 异步写入:将审计日志写入改为异步操作,可以使用内存队列缓冲:
csharp复制public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
var auditLogs = eventData.Context.ChangeTracker
.Entries<AuditLog>()
.Where(e => e.State == EntityState.Added)
.Select(e => e.Entity)
.ToList();
if (auditLogs.Any())
{
await _auditQueue.AddRangeAsync(auditLogs, cancellationToken);
}
return await base.SavedChangesAsync(eventData, result, cancellationToken);
}
-
批量处理:设置阈值,当积累一定数量的审计日志后再批量写入。
-
简化日志内容:只记录真正需要审计的字段,避免记录大文本或二进制字段。
-
分表存储:按时间范围(如每月)将审计日志存储到不同的表中,提高查询性能。
5. 拦截器注册与配置
5.1 拦截器注册方式
在 DbContext 配置中注册拦截器:
csharp复制protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(connectionString)
.AddInterceptors(new AuditInterceptor(currentUserService))
.AddInterceptors(new CommandInterceptor());
}
或者使用依赖注入:
csharp复制services.AddDbContext<MyDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(
sp.GetRequiredService<AuditInterceptor>(),
sp.GetRequiredService<CommandInterceptor>());
});
5.2 拦截器生命周期管理
拦截器的生命周期取决于其注册方式:
- 直接实例化的拦截器是单例的
- 通过 DI 容器注册的拦截器遵循其注册的生命周期(Scoped/Transient/Singleton)
重要:如果拦截器有状态或依赖 Scoped 服务(如获取当前用户),必须确保使用适当的作用域。通常建议将拦截器注册为 Scoped。
5.3 拦截器执行顺序
当注册多个拦截器时,它们的执行顺序如下:
SavingChanges按照注册的相反顺序执行(最后注册的先执行)SavedChanges按照注册顺序执行- 对于 CommandInterceptor,执行顺序也是注册的相反顺序
如果需要精确控制执行顺序,可以使用 InterceptorAggregator:
csharp复制public class CompositeInterceptor : SaveChangesInterceptor, IDbCommandInterceptor
{
private readonly IEnumerable<ISaveChangesInterceptor> _saveChangesInterceptors;
private readonly IEnumerable<IDbCommandInterceptor> _commandInterceptors;
// 实现所有方法并调用内部拦截器
}
6. 常见问题与解决方案
6.1 拦截器导致循环调用问题
当在拦截器中修改实体并调用 SaveChanges 时,可能会导致无限递归:
csharp复制public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
// 错误的做法:会导致循环调用
eventData.Context.SaveChanges();
return result;
}
解决方案:
- 避免在拦截器中再次调用 SaveChanges
- 使用标志位检测递归:
csharp复制public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
if (eventData.Context.Items.ContainsKey("AuditInProgress"))
return result;
try
{
eventData.Context.Items["AuditInProgress"] = true;
// 处理逻辑
}
finally
{
eventData.Context.Items.Remove("AuditInProgress");
}
return result;
}
6.2 异步上下文问题
在异步拦截器中,需要注意上下文流动:
csharp复制public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
// 错误:可能会在错误的上下文中执行
var userId = _userService.UserId;
// 正确:确保在正确的上下文中获取值
var userId = await _userService.GetUserIdAsync(cancellationToken);
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
6.3 性能监控数据不准
使用 Stopwatch 测量执行时间时,要注意测量点的选择:
csharp复制public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
// 开始计时(包括参数生成等准备工作)
var stopwatch = Stopwatch.StartNew();
eventData.Context.Items["CommandTimer"] = stopwatch;
return result;
}
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
if (eventData.Context.Items.TryGetValue("CommandTimer", out var timer)
&& timer is Stopwatch stopwatch)
{
stopwatch.Stop();
// 记录的是从 Executing 到 Executed 的总时间
LogExecutionTime(command.CommandText, stopwatch.Elapsed);
}
return result;
}
如果需要更精确的网络往返时间测量,可以考虑使用 CommandExecuting 和 CommandExecuted 事件。
7. 高级应用场景
7.1 多租户数据隔离
通过 CommandInterceptor 可以实现自动的多租户数据过滤:
csharp复制public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
if (!string.IsNullOrEmpty(tenantId) &&
command.CommandText.StartsWith("SELECT") &&
!command.CommandText.Contains("WHERE TenantId"))
{
// 简单示例:实际应用中应使用 SQL 解析器
command.CommandText = command.CommandText.Replace("FROM ", $"FROM WHERE TenantId = '{tenantId}' ");
}
return base.ReaderExecuting(command, eventData, result);
}
警告:直接修改 SQL 字符串存在 SQL 注入风险。生产环境中应使用参数化查询或专门的 SQL 解析器。
7.2 软删除实现
结合拦截器和查询筛选器实现软删除:
csharp复制// 实体接口
public interface ISoftDelete
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
string DeletedBy { get; set; }
}
// 拦截器实现
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
foreach (var entry in eventData.Context.ChangeTracker.Entries<ISoftDelete>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
entry.Entity.DeletedBy = _currentUserService.UserId;
}
}
return base.SavingChanges(eventData, result);
}
// 查询筛选器
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ISoftDelete>().HasQueryFilter(e => !e.IsDeleted);
}
7.3 并发控制增强
通过拦截器实现乐观并发控制的增强版本:
csharp复制public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
if (command.CommandText.StartsWith("UPDATE") ||
command.CommandText.StartsWith("DELETE"))
{
// 为所有更新/删除操作添加版本检查
command.CommandText += " AND Version = @Version";
command.Parameters.Add(new SqlParameter("@Version", GetExpectedVersion()));
}
return base.ReaderExecuting(command, eventData, result);
}
8. 测试与调试技巧
8.1 单元测试拦截器
测试拦截器时,可以模拟 DbContextEventData:
csharp复制[Fact]
public void SavingChanges_ShouldLogAudit()
{
// 准备
var dbContext = new Mock<DbContext>();
var changeTracker = new Mock<ChangeTracker>();
var entries = new List<EntityEntry>();
// 添加测试实体条目
var entity = new TestEntity { Id = 1 };
var entry = TestHelpers.CreateMockEntry(entity, EntityState.Modified);
entries.Add(entry);
changeTracker.Setup(x => x.Entries()).Returns(entries);
dbContext.Setup(x => x.ChangeTracker).Returns(changeTracker.Object);
var eventData = new DbContextEventData(
dbContext.Object,
new EventHeader(Guid.NewGuid()),
DateTime.UtcNow);
var interceptor = new AuditInterceptor(mockUserService.Object);
// 执行
interceptor.SavingChanges(eventData, default);
// 断言
mockAuditService.Verify(x => x.Log(It.IsAny<AuditLog>()), Times.Once);
}
8.2 调试拦截器
调试拦截器时,可以使用以下技巧:
- 在拦截器方法中设置断点
- 使用 Debug.WriteLine 输出调试信息
- 检查 DbCommand 的 CommandText 和 Parameters
- 使用 MiniProfiler 等工具查看实际执行的 SQL
8.3 性能分析
使用拦截器收集性能数据:
csharp复制public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
var elapsed = eventData.Duration.TotalMilliseconds;
_performanceCounter.Record(
command.CommandText,
elapsed,
eventData.Result?.RecordsAffected ?? 0);
if (elapsed > 1000) // 慢查询阈值
{
_logger.LogWarning($"Slow query detected: {command.CommandText} took {elapsed}ms");
}
return base.ReaderExecuted(command, eventData, result);
}
9. 实际项目中的经验分享
在实际项目中使用 EF Core 拦截器时,我总结了以下几点经验:
-
适度使用原则:拦截器非常强大,但不应滥用。将拦截器用于横切关注点(如审计、日志、性能监控),而不是业务逻辑。
-
关注性能影响:每个拦截器都会增加一定的开销。在高性能场景下,应评估拦截器的影响,必要时进行优化或禁用。
-
异常处理:拦截器中的异常可能导致整个操作失败。确保拦截器有良好的错误处理,不影响主要业务流程。
-
测试覆盖:拦截器逻辑应该有完整的单元测试和集成测试覆盖,特别是修改 SQL 或实体状态的拦截器。
-
文档记录:由于拦截器是"隐式"行为,应在项目文档中明确记录所有活跃的拦截器及其功能,避免后续维护困惑。
-
组合使用:SaveChangesInterceptor 和 CommandInterceptor 可以组合使用,前者处理业务语义层面的拦截,后者处理数据库命令层面的拦截。
-
上下文感知:拦截器通常是无状态的,如果需要访问请求上下文或其他作用域服务,应通过构造函数注入,并确保拦截器生命周期配置正确。
-
版本兼容性:不同版本的 EF Core 可能有拦截器 API 的变化,升级时需检查拦截器代码的兼容性。
10. 与其他技术的整合
10.1 与 ASP.NET Core 整合
在 ASP.NET Core 中,可以方便地通过依赖注入使用拦截器:
csharp复制// 注册拦截器
services.AddScoped<AuditInterceptor>();
services.AddScoped<CommandInterceptor>();
// 配置 DbContext
services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlServer(Configuration.GetConnectionString("Default"))
.AddInterceptors(
sp.GetRequiredService<AuditInterceptor>(),
sp.GetRequiredService<CommandInterceptor>());
});
10.2 与 MediatR 整合
结合 MediatR 实现更强大的领域事件发布:
csharp复制public override int SavedChanges(
SaveChangesCompletedEventData eventData,
int result)
{
var domainEvents = eventData.Context.ChangeTracker
.Entries<IAggregateRoot>()
.SelectMany(x => x.Entity.DomainEvents)
.ToList();
if (domainEvents.Any())
{
var mediator = eventData.Context.GetService<IMediator>();
foreach (var domainEvent in domainEvents)
{
mediator.Publish(domainEvent);
}
}
return base.SavedChanges(eventData, result);
}
10.3 与分布式追踪整合
将 EF Core 操作纳入分布式追踪:
csharp复制public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
using var activity = _activitySource.StartActivity("EFCore.Query");
activity?.SetTag("db.statement", command.CommandText);
activity?.SetTag("db.operation", "SELECT");
return base.ReaderExecuting(command, eventData, result);
}
11. 性能优化进阶
11.1 批量操作优化
对于批量插入场景,可以优化审计日志记录:
csharp复制public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
var changeCount = eventData.Context.ChangeTracker
.Entries()
.Count(e => e.State == EntityState.Added ||
e.State == EntityState.Modified ||
e.State == EntityState.Deleted);
// 对于大批量操作,只记录摘要信息而非每个变更
if (changeCount > 100)
{
LogBatchAuditSummary(eventData.Context);
return base.SavingChanges(eventData, result);
}
// 否则记录每个变更详情
return base.SavingChanges(eventData, result);
}
11.2 条件拦截
根据上下文决定是否启用拦截:
csharp复制public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is IAuditDisabledDbContext)
{
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
// 正常审计逻辑
}
11.3 缓存优化
对于频繁访问的查询,可以添加缓存拦截:
csharp复制public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
var cacheKey = GetCacheKey(command);
if (_cache.TryGetValue(cacheKey, out var cachedResult))
{
// 返回缓存结果
return InterceptionResult<DbDataReader>.SuppressWithResult(
new CachedDbDataReader(cachedResult));
}
return await base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
var cacheKey = GetCacheKey(command);
var data = await result.ToListAsync(cancellationToken);
_cache.Set(cacheKey, data, TimeSpan.FromMinutes(5));
return new CachedDbDataReader(data);
}
12. 最佳实践总结
经过多个项目的实践验证,以下是 EF Core 拦截器的最佳实践:
-
单一职责:每个拦截器只负责一个明确的功能(如只做审计或只做性能监控)。
-
无状态设计:尽量保持拦截器无状态,必须的状态应通过 DI 注入。
-
谨慎修改:修改 SQL 或实体状态时要非常小心,确保不会引入错误或安全漏洞。
-
性能意识:拦截器中的逻辑应尽可能高效,避免阻塞操作。
-
全面测试:拦截器逻辑应有完整的单元测试和集成测试覆盖。
-
明确文档:记录每个拦截器的功能、执行顺序和配置方式。
-
渐进采用:从简单的拦截器开始,逐步增加复杂度,避免一开始就实现过于复杂的逻辑。
-
监控报警:对于关键拦截器(如审计),应设置监控确保其正常运行。
-
版本控制:拦截器逻辑变更应有明确的版本控制和回滚计划。
-
团队共识:确保团队所有成员了解项目中使用的拦截器及其影响。
