Entity Framework Core(EF Core)拦截器允许截获、修改和/或抑制 EF Core 操作。 这包括执行命令等低级别数据库作,以及更高级别的作,例如对 SaveChanges 的调用。
拦截器与日志记录和诊断不同,因为拦截器允许修改或抑制正在拦截的操作。 简单的日志记录 或 Microsoft.Extensions.Logging 是日志记录的更好选择。
配置上下文时,会为每个 DbContext 实例注册侦听器。 使用 诊断侦听器 获取相同的信息,但对于进程中的所有 DbContext 实例。
可用的拦截器
下表显示了可用的侦听器接口:
| 拦截 器 | 操作被截获 | 单例 |
|---|---|---|
| IDbCommandInterceptor | 创建命令 执行命令 命令失败 释放命令的 DbDataReader |
否 |
| IDbConnectionInterceptor | 打开和关闭连接 创建连接 连接失败 |
否 |
| IDbTransactionInterceptor | 创建事务 使用现有事务 提交事务 回滚事务 创建和使用保存点事务失败 |
否 |
| ISaveChangesInterceptor | 更改已保存 更改保存失败 乐观并发处理 |
否 |
| IMaterializationInterceptor | 从查询结果创建、初始化和完成实体实例 | 是的 |
| IQueryExpressionInterceptor | 在编译查询之前修改 LINQ 表达式树 | 是的 |
| IIdentityResolutionInterceptor | 跟踪实体时解决标识冲突 | 是的 |
注册拦截器
AddInterceptors时,会使用拦截器进行注册。 这通常是在重写中完成的 DbContext.OnConfiguring。 例如:
public class ExampleContext : BlogsContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}
或者,AddInterceptors 可以被调用作为 AddDbContext 的一部分或在创建 DbContextOptions 实例以传递给 DbContext 构造函数时调用。
小窍门
在使用 AddDbContext 或将 DbContextOptions 实例传递给 DbContext 构造函数时,仍会调用 OnConfiguring。 这样,无论如何构造 DbContext,它都是应用上下文配置的理想位置。
侦听器通常是无状态的,这意味着单个侦听器实例可用于所有 DbContext 实例。 例如:
public class TaggedQueryCommandInterceptorContext : BlogsContext
{
private static readonly TaggedQueryCommandInterceptor _interceptor
= new TaggedQueryCommandInterceptor();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(_interceptor);
}
每个拦截器实例都必须实现派生自 IInterceptor的一个或多个接口。 即使每个实例实现多个拦截接口,每个实例也应注册一次;EF Core 将根据需要为每个接口路由事件。
单例拦截器
某些拦截器实现ISingletonInterceptor(见上表);这些拦截器在 EF Core 的内部服务供应商中注册为单例服务,这意味着在所有使用相同服务供应商的 DbContext 实例之间共享单个实例。
由于单例拦截器成为 EF Core 内部服务配置的一部分,因此每个不同的拦截器实例都会导致生成新的内部服务提供程序。 每次配置时传递一个DbContext(例如,在AddDbContext中)最终将触发ManyServiceProvidersCreatedWarning并会降低性能。
警告
始终为所有 DbContext 实例重复使用同一个单例拦截器实例。 每次配置上下文时不要创建新实例。
例如,以下 情况不正确 ,因为为每个上下文配置创建了一个新的侦听器实例:
// Don't do this! A new instance each time causes a new internal service provider to be built.
services.AddDbContext<CustomerContext>(
b => b.UseSqlServer(connectionString)
.AddInterceptors(new MyMaterializationInterceptor()));
请改用同一实例:
// Correct: reuse a single interceptor instance
var interceptor = new MyMaterializationInterceptor();
services.AddDbContext<CustomerContext>(
b => b.UseSqlServer(connectionString)
.AddInterceptors(interceptor));
或使用静态字段:
public class CustomerContext : DbContext
{
private static readonly MyMaterializationInterceptor _interceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(_interceptor);
}
由于这些拦截器是单一实例,因此它们必须是线程安全的。 一般来说,它们不应保持可变的状态。 如果需要访问作用域服务(例如当前 DbContext),可使用传递给每个拦截器方法的事件数据中的 Context 或类似属性。
数据库拦截
注释
数据库拦截仅适用于关系数据库提供程序。
低级数据库拦截拆分为下表所示的三个接口。
| 拦截 器 | 已拦截的数据库操作 |
|---|---|
| IDbCommandInterceptor | 创建命令 执行命令 命令失败 释放命令的 DbDataReader |
| IDbConnectionInterceptor | 打开和关闭连接 创建连接 连接失败 |
| IDbTransactionInterceptor | 创建事务 使用现有事务 提交事务 回滚事务 创建和使用保存点事务失败 |
基类 DbCommandInterceptor, DbConnectionInterceptor并 DbTransactionInterceptor 包含相应接口中每个方法的 no-op 实现。 使用基类可避免实现未使用的拦截方法。
每种拦截器类型的方法成对出现,第一个在数据库操作开始前被调用,第二个在操作完成后调用。 例如, DbCommandInterceptor.ReaderExecuting 在执行查询之前调用,并在 DbCommandInterceptor.ReaderExecuted 查询发送到数据库后调用。
每对方法都有同步和异步变体。 这允许异步 I/O(例如请求访问令牌)作为拦截异步数据库操作的一部分进行。
示例:用于添加查询提示的命令拦截
小窍门
可以从 GitHub 下载命令拦截器示例 。
在将 SQL 发送到数据库之前,可以使用 IDbCommandInterceptor 来修改 SQL。 此示例演示如何修改 SQL 以包含查询提示。
通常,截获最棘手的部分是确定命令何时对应于需要修改的查询。 分析 SQL 是一个选项,但往往很脆弱。 另一个选项是使用 EF Core 查询标记 标记应修改的每个查询。 例如:
var blogs1 = await context.Blogs.TagWith("Use hint: robust plan").ToListAsync();
然后,可以在侦听器中检测此标记,因为它始终作为注释包含在命令文本的第一行中。 在检测标记时,将修改查询 SQL 以添加相应的提示:
public class TaggedQueryCommandInterceptor : DbCommandInterceptor
{
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
ManipulateCommand(command);
return result;
}
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
ManipulateCommand(command);
return new ValueTask<InterceptionResult<DbDataReader>>(result);
}
private static void ManipulateCommand(DbCommand command)
{
if (command.CommandText.StartsWith("-- Use hint: robust plan", StringComparison.Ordinal))
{
command.CommandText += " OPTION (ROBUST PLAN)";
}
}
}
注意:
- 侦听器继承自 DbCommandInterceptor ,以避免在侦听器接口中实现每个方法。
- 侦听器实现同步和异步方法。 这可确保将相同的查询提示应用于同步和异步查询。
- 拦截器实现
Executing由 EF Core 调用的方法,这些方法使用生成的 SQL 在发送到数据库之前。 与Executed方法的形成对比,这些方法是在数据库调用返回后才被调用。
在标记查询时,运行此示例中的代码将生成以下内容:
-- Use hint: robust plan
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b] OPTION (ROBUST PLAN)
另一方面,如果未标记查询,则会将其发送到未修改的数据库:
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
示例:使用 AAD 进行 SQL Azure 身份验证的连接拦截
小窍门
可以从 GitHub 下载连接拦截器示例 。
一个 IDbConnectionInterceptor 可用于在连接到数据库之前对 DbConnection 进行处理。 这可用于获取 Azure Active Directory (AAD) 访问令牌。 例如:
public class AadAuthenticationInterceptor : DbConnectionInterceptor
{
public override InterceptionResult ConnectionOpening(
DbConnection connection,
ConnectionEventData eventData,
InterceptionResult result)
=> throw new InvalidOperationException("Open connections asynchronously when using AAD authentication.");
public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
DbConnection connection,
ConnectionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
var sqlConnection = (SqlConnection)connection;
var provider = new AzureServiceTokenProvider();
// Note: in some situations the access token may not be cached automatically the Azure Token Provider.
// Depending on the kind of token requested, you may need to implement your own caching here.
sqlConnection.AccessToken = await provider.GetAccessTokenAsync("https://database.windows.net/", null, cancellationToken);
return result;
}
}
小窍门
Microsoft.Data.SqlClient 现在通过连接字符串支持 AAD 身份验证。 有关详细信息,请参阅 SqlAuthenticationMethod。
警告
请注意,如果进行同步调用以打开连接,侦听器将引发。 这是因为没有用于获取访问令牌的非异步方法, 并且没有通用和简单的方法来从非异步上下文调用异步方法,而不会造成死锁的风险。
警告
在某些情况下,访问令牌可能不会由 Azure 令牌提供程序自动缓存。 具体根据所请求的令牌类型,您可能需要在此处实现自己的缓存方案。
示例:连接字符串的延迟初始化
连接字符串通常是从配置文件读取的静态资产。 配置 UseSqlServer 时,可以轻松将这些项传递给 DbContext 或类似项。 但是,有时连接字符串可能会因每个上下文实例而更改。 例如,多租户系统中的每个租户可能有不同的连接字符串。
IDbConnectionInterceptor可用于处理动态连接和连接字符串。 首先能够在无需任何连接字符串的情况下配置 DbContext。 例如:
services.AddDbContext<CustomerContext>(
b => b.UseSqlServer());
然后,可以实现其中一个 IDbConnectionInterceptor 方法来配置连接,然后再使用它。
ConnectionOpeningAsync 是一个不错的选择,因为它可以执行异步操作来获取连接字符串、查找访问令牌等。 例如,假设有一个服务,它的范围限定在当前请求,并且了解当前租户:
services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();
警告
每次需要对连接字符串、访问令牌或类似项执行异步查找时,可能会非常慢。 请考虑缓存这些内容,并仅定期刷新缓存的字符串或令牌。 例如,访问令牌通常可在需要刷新之前长时间使用。
这可以通过构造函数注入到每个 DbContext 实例中:
public class CustomerContext : DbContext
{
private readonly ITenantConnectionStringFactory _connectionStringFactory;
public CustomerContext(
DbContextOptions<CustomerContext> options,
ITenantConnectionStringFactory connectionStringFactory)
: base(options)
{
_connectionStringFactory = connectionStringFactory;
}
// ...
}
然后,在为上下文构造拦截器实现时使用此服务:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(
new ConnectionStringInitializationInterceptor(_connectionStringFactory));
最后,拦截器使用此服务异步获取连接字符串,并在第一次使用连接时对其进行设置:
public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
private readonly ITenantConnectionStringFactory _connectionStringFactory;
public ConnectionStringInitializationInterceptor(ITenantConnectionStringFactory connectionStringFactory)
{
_connectionStringFactory = connectionStringFactory;
}
public override InterceptionResult ConnectionOpening(
DbConnection connection,
ConnectionEventData eventData,
InterceptionResult result)
=> throw new NotSupportedException("Synchronous connections not supported.");
public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
CancellationToken cancellationToken = new())
{
if (string.IsNullOrEmpty(connection.ConnectionString))
{
connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
}
return result;
}
}
注释
仅在第一次使用连接时获取连接字符串。 之后,将使用存储在 DbConnection 上的连接字符串,而无需查找新的连接字符串。
小窍门
此拦截器替代要引发的非异步 ConnectionOpening 方法,因为必须从异步代码路径调用用于获取连接字符串的服务。
示例:用于缓存的高级命令拦截
小窍门
可以从 GitHub 下载高级命令拦截器示例 。
EF Core 侦听器可以:
- 告知 EF Core 禁止执行正在截获的操作
- 将操作结果报告给 EF Core
此示例展示了一个拦截器,它利用这些功能,行为类似于简单的二级缓存。 为特定查询返回缓存的查询结果,避免数据库往返。
警告
以这种方式更改 EF Core 默认行为时请小心。 如果 EF Core 收到无法正确处理的异常结果,则 EF Core 的行为可能出乎意料。 此外,此示例演示拦截器概念;它不用作可靠的二级缓存实现的模板。
在此示例中,应用程序经常执行查询以获取最新的“每日消息”:
async Task<string> GetDailyMessage(DailyMessageContext context)
=> (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;
此查询 已标记 ,以便可以轻松在拦截器中检测到它。 其思路是每天只查询数据库一次新消息。 在其他情况下,应用程序将使用缓存的结果。 (此示例使用样本中的延迟 10 秒来模拟新的一天。
侦听器状态
此侦听器是有状态的:它存储查询的最新每日消息的 ID 和消息文本,以及执行该查询的时间。 由于此状态,我们还需要一个 锁 ,因为缓存要求多个上下文实例必须使用相同的侦听器。
private readonly object _lock = new object();
private int _id;
private string _message;
private DateTime _queriedAt;
执行之前
在 Executing 方法(即在进行数据库调用之前),侦听器会检测标记的查询,然后检查是否存在缓存的结果。 如果找到此类结果,则会取消查询并改用缓存结果。
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal))
{
lock (_lock)
{
if (_message != null
&& DateTime.UtcNow < _queriedAt + new TimeSpan(0, 0, 10))
{
command.CommandText = "-- Get_Daily_Message: Skipping DB call; using cache.";
result = InterceptionResult<DbDataReader>.SuppressWithResult(new CachedDailyMessageDataReader(_id, _message));
}
}
}
return new ValueTask<InterceptionResult<DbDataReader>>(result);
}
请注意代码如何调用 InterceptionResult<TResult>.SuppressWithResult 并传递包含缓存数据的替换 DbDataReader 项。 然后返回此 InterceptionResult,导致查询执行被阻止。 替换读取器由 EF Core 用作查询结果。
此拦截器还修改命令文本。 此操作不是必需的,但可以提高日志消息的清晰度。 命令文本不需要是有效的 SQL,因为查询现在不会执行。
执行后
如果没有缓存的消息可用,或者消息已过期,则上述代码不会禁止显示结果。 因此,EF Core 会像正常一样执行查询。 在执行完毕后,将返回到拦截器Executed的方法。 此时,如果结果尚未缓存读取器,则会从实际读取器中提取新的消息 ID 和字符串,并缓存该查询以供下一次使用此查询。
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal)
&& !(result is CachedDailyMessageDataReader))
{
try
{
await result.ReadAsync(cancellationToken);
lock (_lock)
{
_id = result.GetInt32(0);
_message = result.GetString(1);
_queriedAt = DateTime.UtcNow;
return new CachedDailyMessageDataReader(_id, _message);
}
}
finally
{
await result.DisposeAsync();
}
}
return result;
}
演示
缓存侦听器示例包含一个简单的控制台应用程序,用于查询每日消息以测试缓存:
// 1. Initialize the database with some daily messages.
using (var context = new DailyMessageContext())
{
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
context.AddRange(
new DailyMessage { Message = "Remember: All builds are GA; no builds are RTM." },
new DailyMessage { Message = "Keep calm and drink tea" });
await context.SaveChangesAsync();
}
// 2. Query for the most recent daily message. It will be cached for 10 seconds.
using (var context = new DailyMessageContext())
{
Console.WriteLine(await GetDailyMessage(context));
}
// 3. Insert a new daily message.
using (var context = new DailyMessageContext())
{
context.Add(new DailyMessage { Message = "Free beer for unicorns" });
await context.SaveChangesAsync();
}
// 4. Cached message is used until cache expires.
using (var context = new DailyMessageContext())
{
Console.WriteLine(await GetDailyMessage(context));
}
// 5. Pretend it's the next day.
Thread.Sleep(10000);
// 6. Cache is expired, so the last message will not be queried again.
using (var context = new DailyMessageContext())
{
Console.WriteLine(await GetDailyMessage(context));
}
async Task<string> GetDailyMessage(DailyMessageContext context)
=> (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;
这会导致生成以下输出:
info: 10/15/2020 12:32:11.801 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Get_Daily_Message
SELECT "d"."Id", "d"."Message"
FROM "DailyMessages" AS "d"
ORDER BY "d"."Id" DESC
LIMIT 1
Keep calm and drink tea
info: 10/15/2020 12:32:11.821 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[@p0='Free beer for unicorns' (Size = 22)], CommandType='Text', CommandTimeout='30']
INSERT INTO "DailyMessages" ("Message")
VALUES (@p0);
SELECT "Id"
FROM "DailyMessages"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();
info: 10/15/2020 12:32:11.826 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Get_Daily_Message: Skipping DB call; using cache.
Keep calm and drink tea
info: 10/15/2020 12:32:21.833 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Get_Daily_Message
SELECT "d"."Id", "d"."Message"
FROM "DailyMessages" AS "d"
ORDER BY "d"."Id" DESC
LIMIT 1
Free beer for unicorns
请注意,在日志输出中,应用程序继续使用缓存的消息,直到超时过期,此时再次查询数据库以获取任何新消息。
示例:记录 SQL Server 查询统计信息
此示例演示了两个侦听器,这些侦听器协同工作以将 SQL Server 查询统计信息发送到应用程序日志。 若要生成统计信息,需要一个 IDbCommandInterceptor 来执行两项操作。
首先,拦截器将添加带有 SET STATISTICS IO ON 的命令的前缀,该命令指示 SQL Server 在使用结果集后将统计信息发送到客户端:
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;
return new(result);
}
其次,拦截器将实现 DataReaderClosingAsync 方法,该方法在完成对结果的使用后会被调用,但在关闭 DbDataReader 。 当 SQL Server 发送统计信息时,它会将它们放在读取器上的第二个结果中,因此此时拦截器会通过调用填充到连接的统计信息 NextResultAsync 来读取该结果。
public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
DbCommand command,
DataReaderClosingEventData eventData,
InterceptionResult result)
{
await eventData.DataReader.NextResultAsync();
return result;
}
需要第二个拦截器才能从连接获取统计信息,并将其写出到应用程序的记录器。 为此,我们将使用 IDbConnectionInterceptor 来实现 ConnectionCreated 方法。 在 EF Core 创建连接后立即调用 ConnectionCreated,因此可用于执行该连接的其他配置。 在这种情况下,拦截器获得ILogger,然后接入SqlConnection.InfoMessage事件以记录消息。
public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
{
logger.LogInformation(1, args.Message);
};
return result;
}
重要
仅当 EF Core 创建 ConnectionCreating 时,才会调用 ConnectionCreated 和 DbConnection 方法。 如果应用程序创建 DbConnection 并将其传递给 EF Core,则不会调用它们。
按命令源筛选
传递给诊断源和拦截器的CommandEventData包含一个CommandSource属性,该属性指示 EF 中哪个部分负责创建该命令。 这可用作侦听器中的筛选器。 例如,我们可能需要一个仅应用于来自 SaveChanges 的命令的拦截器:
public class CommandSourceInterceptor : DbCommandInterceptor
{
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
if (eventData.CommandSource == CommandSource.SaveChanges)
{
Console.WriteLine($"Saving changes for {eventData.Context!.GetType().Name}:");
Console.WriteLine();
Console.WriteLine(command.CommandText);
}
return result;
}
}
保存更改拦截
小窍门
可以从 GitHub 下载 SaveChanges 拦截器示例 。
SaveChanges 和 SaveChangesAsync 截距点由 ISaveChangesInterceptor 接口定义。 至于其他拦截器,已经作为便利提供了带有 no-op 方法的 SaveChangesInterceptor 基类。
小窍门
拦截器很强大。 但是,在许多情况下,重写 SaveChanges 方法或使用 DbContext 上提供的 用于 SaveChanges 的 .NET 事件 可能会更简单。
示例:用于审核的 SaveChanges 拦截器
可以截获 SaveChanges 以创建所做更改的独立审核记录。
注释
这不是一个可靠的审核解决方案。 相反,它是一个简单示例,用于演示截获的特征。
应用程序上下文
用于审核的示例使用包含博客和文章的简单 DbContext。
public class BlogsContext : DbContext
{
private readonly AuditingInterceptor _auditingInterceptor = new AuditingInterceptor("DataSource=audit.db");
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.AddInterceptors(_auditingInterceptor)
.UseSqlite("DataSource=blogs.db");
public DbSet<Blog> Blogs { get; set; }
}
public class Blog
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<Post> Posts { get; } = new List<Post>();
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; }
public Blog Blog { get; set; }
}
请注意,为每个 DbContext 实例会注册拦截器的新实例。 这是因为审计拦截器包含与当前上下文实例相关联的状态。
审核上下文
此示例还包含用于审核数据库的第二个 DbContext 和模型。
public class AuditContext : DbContext
{
private readonly string _connectionString;
public AuditContext(string connectionString)
{
_connectionString = connectionString;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseSqlite(_connectionString);
public DbSet<SaveChangesAudit> SaveChangesAudits { get; set; }
}
public class SaveChangesAudit
{
public int Id { get; set; }
public Guid AuditId { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public bool Succeeded { get; set; }
public string ErrorMessage { get; set; }
public ICollection<EntityAudit> Entities { get; } = new List<EntityAudit>();
}
public class EntityAudit
{
public int Id { get; set; }
public EntityState State { get; set; }
public string AuditMessage { get; set; }
public SaveChangesAudit SaveChangesAudit { get; set; }
}
拦截器
使用侦听器进行审核的一般思路是:
- 审核消息在 SaveChanges 的开头创建,并写入审核数据库
- 允许 SaveChanges 继续进行
- 如果 SaveChanges 成功,则会更新审核消息以指示成功
- 如果 SaveChanges 失败,则会更新审核消息以指示失败
在将任何更改发送到数据库之前,使用ISaveChangesInterceptor.SavingChanges和ISaveChangesInterceptor.SavingChangesAsync的覆盖来处理第一阶段。
public async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
_audit = CreateAudit(eventData.Context);
using var auditContext = new AuditContext(_connectionString);
auditContext.Add(_audit);
await auditContext.SaveChangesAsync();
return result;
}
public InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
_audit = CreateAudit(eventData.Context);
using var auditContext = new AuditContext(_connectionString);
auditContext.Add(_audit);
auditContext.SaveChanges();
return result;
}
重写同步和异步方法可确保无论调用SaveChanges还是SaveChangesAsync,审核都会进行。 另请注意,异步重载本身能够对审核数据库执行非阻塞的异步 I/O 操作。 你可能希望从同步 SavingChanges 方法引发,以确保所有数据库 I/O 都是异步的。 然后,这要求应用程序始终调用SaveChangesAsync,永远不要调用SaveChanges。
审核消息
每个拦截器方法都有一个 eventData 参数,提供有关正在截获的事件的上下文信息。 在这种情况下,当前应用程序 DbContext 包含在事件数据中,然后用于创建审核消息。
private static SaveChangesAudit CreateAudit(DbContext context)
{
context.ChangeTracker.DetectChanges();
var audit = new SaveChangesAudit { AuditId = Guid.NewGuid(), StartTime = DateTime.UtcNow };
foreach (var entry in context.ChangeTracker.Entries())
{
var auditMessage = entry.State switch
{
EntityState.Deleted => CreateDeletedMessage(entry),
EntityState.Modified => CreateModifiedMessage(entry),
EntityState.Added => CreateAddedMessage(entry),
_ => null
};
if (auditMessage != null)
{
audit.Entities.Add(new EntityAudit { State = entry.State, AuditMessage = auditMessage });
}
}
return audit;
string CreateAddedMessage(EntityEntry entry)
=> entry.Properties.Aggregate(
$"Inserting {entry.Metadata.DisplayName()} with ",
(auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
string CreateModifiedMessage(EntityEntry entry)
=> entry.Properties.Where(property => property.IsModified || property.Metadata.IsPrimaryKey()).Aggregate(
$"Updating {entry.Metadata.DisplayName()} with ",
(auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
string CreateDeletedMessage(EntityEntry entry)
=> entry.Properties.Where(property => property.Metadata.IsPrimaryKey()).Aggregate(
$"Deleting {entry.Metadata.DisplayName()} with ",
(auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
}
结果是一个 SaveChangesAudit 实体,其中包含一个 EntityAudit 实体集合,每个插入、更新或删除操作对应一个 EntityAudit 实体。 然后,侦听器将这些实体插入审核数据库中。
小窍门
ToString 方法在每个 EF Core 事件数据类中被重定义,以生成事件的相应日志消息。 例如,调用 ContextInitializedEventData.ToString 会生成“Entity Framework Core 5.0.0 使用提供程序‘Microsoft.EntityFrameworkCore.Sqlite’和选项‘无’初始化‘BlogsContext’”。
检测成功
审核实体存储在侦听器上,以便在 SaveChanges 成功或失败后再次访问它。 对于成功, ISaveChangesInterceptor.SavedChanges 或 ISaveChangesInterceptor.SavedChangesAsync 被调用。
public int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = true;
_audit.EndTime = DateTime.UtcNow;
auditContext.SaveChanges();
return result;
}
public async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = true;
_audit.EndTime = DateTime.UtcNow;
await auditContext.SaveChangesAsync(cancellationToken);
return result;
}
审核实体附加到审核上下文,因为它已存在于数据库中,需要更新。 然后,我们将SucceededEndTime这些属性标记为已修改,以便 SaveChanges 将更新发送到审核数据库。
检测失败
失败的处理方式与成功的方式大致相同,但在ISaveChangesInterceptor.SaveChangesFailed方法或ISaveChangesInterceptor.SaveChangesFailedAsync方法中。 事件数据包含引发的异常。
public void SaveChangesFailed(DbContextErrorEventData eventData)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = false;
_audit.EndTime = DateTime.UtcNow;
_audit.ErrorMessage = eventData.Exception.Message;
auditContext.SaveChanges();
}
public async Task SaveChangesFailedAsync(
DbContextErrorEventData eventData,
CancellationToken cancellationToken = default)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = false;
_audit.EndTime = DateTime.UtcNow;
_audit.ErrorMessage = eventData.Exception.InnerException?.Message;
await auditContext.SaveChangesAsync(cancellationToken);
}
演示
审核示例包含一个简单的控制台应用程序,该应用程序对博客数据库进行更改,然后显示创建的审核。
// Insert, update, and delete some entities
using (var context = new BlogsContext())
{
context.Add(
new Blog { Name = "EF Blog", Posts = { new Post { Title = "EF Core 3.1!" }, new Post { Title = "EF Core 5.0!" } } });
await context.SaveChangesAsync();
}
using (var context = new BlogsContext())
{
var blog = await context.Blogs.Include(e => e.Posts).SingleAsync();
blog.Name = "EF Core Blog";
context.Remove(blog.Posts.First());
blog.Posts.Add(new Post { Title = "EF Core 6.0!" });
await context.SaveChangesAsync();
}
// Do an insert that will fail
using (var context = new BlogsContext())
{
try
{
context.Add(new Post { Id = 3, Title = "EF Core 3.1!" });
await context.SaveChangesAsync();
}
catch (DbUpdateException)
{
}
}
// Look at the audit trail
using (var context = new AuditContext("DataSource=audit.db"))
{
foreach (var audit in await context.SaveChangesAudits.Include(e => e.Entities).ToListAsync())
{
Console.WriteLine(
$"Audit {audit.AuditId} from {audit.StartTime} to {audit.EndTime} was{(audit.Succeeded ? "" : " not")} successful.");
foreach (var entity in audit.Entities)
{
Console.WriteLine($" {entity.AuditMessage}");
}
if (!audit.Succeeded)
{
Console.WriteLine($" Error: {audit.ErrorMessage}");
}
}
}
结果显示审核数据库的内容:
Audit 52e94327-1767-4046-a3ca-4c6b1eecbca6 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
Inserting Blog with Id: '-2147482647' Name: 'EF Blog'
Inserting Post with Id: '-2147482647' BlogId: '-2147482647' Title: 'EF Core 3.1!'
Inserting Post with Id: '-2147482646' BlogId: '-2147482647' Title: 'EF Core 5.0!'
Audit 8450f57a-5030-4211-a534-eb66b8da7040 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
Inserting Post with Id: '-2147482645' BlogId: '1' Title: 'EF Core 6.0!'
Updating Blog with Id: '1' Name: 'EF Core Blog'
Deleting Post with Id: '1'
Audit 201fef4d-66a7-43ad-b9b6-b57e9d3f37b3 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was not successful.
Inserting Post with Id: '3' BlogId: '' Title: 'EF Core 3.1!'
Error: SQLite Error 19: 'UNIQUE constraint failed: Post.Id'.
示例:乐观并发拦截
EF Core 通过检查实际受更新或删除影响的行数是否与预期受影响的行数相同,支持乐观并发模式。 这通常与并发令牌耦合,也就是说,仅当行自读取预期值以来尚未更新时,一个列值才会匹配其预期值。
EF 通过引发 DbUpdateConcurrencyException 来发出违反乐观并发的信号。
ISaveChangesInterceptor具有方法ThrowingConcurrencyException和ThrowingConcurrencyExceptionAsync,在DbUpdateConcurrencyException被抛出之前调用。 这些拦截点允许抑制异常,可能结合异步数据库更改以解决违规。
例如,如果两个请求几乎同时尝试删除同一实体,则第二个删除可能会失败,因为数据库中的行不再存在。 这可能没问题,最终结果是实体已被删除。 以下拦截器演示了如何完成此操作:
public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
public InterceptionResult ThrowingConcurrencyException(
ConcurrencyExceptionEventData eventData,
InterceptionResult result)
{
if (eventData.Entries.All(e => e.State == EntityState.Deleted))
{
Console.WriteLine("Suppressing Concurrency violation for command:");
Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);
return InterceptionResult.Suppress();
}
return result;
}
public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
ConcurrencyExceptionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
=> new(ThrowingConcurrencyException(eventData, result));
}
关于此拦截器,有几个值得注意的事项:
- 同时实现同步和异步拦截方法。 如果应用程序可以调用
SaveChanges或SaveChangesAsync,这一点非常重要。 但是,如果所有应用程序代码都是异步的,则只需实现ThrowingConcurrencyExceptionAsync。 同样,如果应用程序从未使用异步数据库方法,则只需实现ThrowingConcurrencyException。 对于具有同步和异步方法的所有拦截器,这通常都是如此。 - 拦截器有权访问要保存的实体的 EntityEntry 对象。 在这种情况下,用于检查删除操作是否发生并发冲突。
- 如果应用程序使用的是关系数据库提供程序,则可以将 ConcurrencyExceptionEventData 对象强制转换为 RelationalConcurrencyExceptionEventData 对象。 这提供了有关正在执行的数据库操作的其他特定于关系的信息。 在这种情况下,关系命令文本将打印到控制台。
- 返回
InterceptionResult.Suppress()告知 EF Core 抑止它即将采取的操作,在这种情况下,将抛出DbUpdateConcurrencyException。 这种更改 EF Core 行为的能力,而不仅仅是观察 EF Core 正在执行的操作,是拦截器最强大的功能之一。
实体化拦截
IMaterializationInterceptor 支持在创建实体实例之前和之后以及初始化该实例的属性前后截获。 拦截器可以更改或替换每个点的实体实例。 这允许:
- 设置验证、计算值或标记所需的未映射属性或调用方法。
- 使用工厂创建实例。
- 创建与 EF 通常会创建的不同的实体实例,例如缓存中的实例或代理类型的实例。
- 将服务注入实体实例。
注释
IMaterializationInterceptor 是单例拦截器,这意味着所有 DbContext 实例共享同一个实例。
示例:对实体创建执行简单操作
假设我们想要跟踪从数据库中检索实体的时间,也许这样就能显示给正在编辑数据的用户。 为此,我们首先定义一个接口:
public interface IHasRetrieved
{
DateTime Retrieved { get; set; }
}
使用接口与拦截器很常见,因为它允许同一拦截器处理许多不同的实体类型。 例如:
public class Customer : IHasRetrieved
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string? PhoneNumber { get; set; }
[NotMapped]
public DateTime Retrieved { get; set; }
}
请注意,[NotMapped] 属性用于表示此属性仅在处理实体时使用,不应保存到数据库。
然后,拦截器必须从 IMaterializationInterceptor 实现合适的方法,并设置检索到的时间:
public class SetRetrievedInterceptor : IMaterializationInterceptor
{
public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
{
if (instance is IHasRetrieved hasRetrieved)
{
hasRetrieved.Retrieved = DateTime.UtcNow;
}
return instance;
}
}
配置 DbContext 时会注册一个拦截器实例:
public class CustomerContext : DbContext
{
private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.AddInterceptors(_setRetrievedInterceptor)
.UseSqlite("Data Source = customers.db");
}
小窍门
此拦截器是无状态的,这是常见的,因此在所有 DbContext 实例之间创建和共享单个实例。
现在,每当从数据库查询 Customer 时,将自动设置 Retrieved 属性。 例如:
await using (var context = new CustomerContext())
{
var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}
生成输出:
Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'
示例:将服务注入实体
EF Core 已内置支持将某些特殊服务注入上下文实例;例如,请参阅延迟加载而不使用代理,这可以通过注入 ILazyLoader 服务来工作。
可以使用 IMaterializationInterceptor 将此通用化为任何服务。 以下示例演示如何将 ILogger 注入实体,以便它们可以执行自己的日志记录。
注释
将服务注入实体会将这些实体类型与注入的服务相耦合,有些人认为这是一种反模式。
与之前一样,接口用于定义可以执行的操作。
public interface IHasLogger
{
ILogger? Logger { get; set; }
}
将要记录的实体类型必须实现此接口。 例如:
public class Customer : IHasLogger
{
private string? _phoneNumber;
public int Id { get; set; }
public string Name { get; set; } = null!;
public string? PhoneNumber
{
get => _phoneNumber;
set
{
Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");
_phoneNumber = value;
}
}
[NotMapped]
public ILogger? Logger { get; set; }
}
这一次,拦截器必须实现 IMaterializationInterceptor.InitializedInstance,在创建每个实体实例并初始化其属性值后调用。 拦截器从上下文中获取 ILogger,并使用它初始化 IHasLogger.Logger:
public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
private ILogger? _logger;
public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
{
if (instance is IHasLogger hasLogger)
{
_logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
hasLogger.Logger = _logger;
}
return instance;
}
}
这一次,拦截器的新实例用于每个 DbContext 实例,因为获取 ILogger 可以更改每个 DbContext 实例,并且 ILogger 缓存在拦截器上:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());
现在,每当 Customer.PhoneNumber 发生更改时,此更改将记录到应用程序的日志中。 例如:
info: CustomersLogger[1]
Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.
查询表达式截获
IQueryExpressionInterceptor 允许在编译查询之前截获 查询的 LINQ 表达式树 。 这可用于以跨应用程序应用的方式动态修改查询。
注释
IQueryExpressionInterceptor 是一个单例拦截器,这意味着单个实例通常在所有 DbContext 实例之间共享。
警告
侦听器功能强大,但在处理表达式树时很容易出错。 始终考虑是否有一种更轻松的方式来实现所需内容,例如直接修改查询。
示例:将排序注入查询以实现稳定的排序
请考虑返回客户页面的方法:
Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
using var context = new CustomerContext();
return context.Customers
.OrderBy(e => EF.Property<object>(e, sortProperty))
.Skip(page * 20).Take(20).ToListAsync();
}
小窍门
此查询使用 EF.Property 方法指定要排序的属性。 这允许应用程序动态传入属性名称,允许按实体类型的任何属性进行排序。 请注意,按非索引列排序可能很慢。
只要用于排序的属性始终返回稳定的排序,此操作就会正常工作。 但情况可能并不总是如此。 例如,上述 LINQ 查询在按 Customer.City 排序时在 SQLite 上生成以下内容:
SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0
如果有多个客户具有相同 City,则此查询的排序不稳定。 这可能会导致当用户在浏览数据时出现丢失或重复结果。
解决此问题的一种常见方法是按主键执行辅助排序。 拦截器可以动态添加辅助排序,而不是需要手动将其添加到每个查询中。 为便于执行此作,我们为具有整数主键的任何实体定义接口:
public interface IHasIntKey
{
int Id { get; }
}
此接口由感兴趣的实体类型实现:
public class Customer : IHasIntKey
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string? City { get; set; }
public string? PhoneNumber { get; set; }
}
然后,我们需要一个实现IQueryExpressionInterceptor的拦截器。
public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
=> new KeyOrderingExpressionVisitor().Visit(queryExpression);
private class KeyOrderingExpressionVisitor : ExpressionVisitor
{
private static readonly MethodInfo ThenByMethod
= typeof(Queryable).GetMethods()
.Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);
protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
{
var methodInfo = methodCallExpression!.Method;
if (methodInfo.DeclaringType == typeof(Queryable)
&& methodInfo.Name == nameof(Queryable.OrderBy)
&& methodInfo.GetParameters().Length == 2)
{
var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
{
var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
var entityParameterExpression = lambdaExpression.Parameters[0];
return Expression.Call(
ThenByMethod.MakeGenericMethod(
sourceType,
typeof(int)),
methodCallExpression,
Expression.Lambda(
typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
entityParameterExpression));
}
}
return base.VisitMethodCall(methodCallExpression);
}
}
}
这看起来可能相当复杂,而且确实如此! 使用表达式树通常不容易。 我们看看发生了什么:
从根本上讲,该拦截器封装了一个ExpressionVisitor。 访问者将替代 VisitMethodCall,每当查询表达式树中存在对方法的调用时,都会调用此方法。
访问者检查这是否是对我们感兴趣的 OrderBy 方法的调用。
如果是,则访问者会进一步检查该泛型方法调用是否是针对实现我们
IHasIntKey接口的类型。此时我们知道方法调用的格式为
OrderBy(e => ...)。 我们从此调用中提取 lambda 表达式,并获取该表达式中使用的参数,即e。我们现在使用 MethodCallExpression 生成器方法生成新的 Expression.Call。 在本例中,要调用的方法为
ThenBy(e => e.Id)。 我们使用上面提取的参数和对Id接口IHasIntKey属性的属性访问来生成此属性。此调用的输入是原始
OrderBy(e => ...),因此最终结果是OrderBy(e => ...).ThenBy(e => e.Id)的表达式。此修改后的表达式从访问者返回,这意味着 LINQ 查询现已经过适当修改,以包含
ThenBy调用。EF Core 继续将此查询表达式编译为适用于当前使用数据库的 SQL。
注册此侦听器并执行 GetPageOfCustomers 现在将生成以下 SQL:
SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0
现在,即使有多个客户具有相同的 City,也会始终生成稳定的排序。
在许多情况下,只需直接修改查询即可实现相同的效果。 例如:
Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
using var context = new CustomerContext();
return context.Customers
.OrderBy(e => EF.Property<object>(e, sortProperty))
.ThenBy(e => e.Id)
.Skip(page * 20).Take(20).ToListAsync();
}
在这种情况下,ThenBy 会简单地添加到查询中。 是的,它可能需要单独对每个查询执行,但很简单,易于理解,并且将始终正常工作。
标识解析拦截
IIdentityResolutionInterceptor 允许在 DbContext 开始跟踪新实体实例时拦截身份解析冲突。
注释
当前仅当使用DbContext.Update、DbContext.Attach等方法来跟踪已经用相同键跟踪的实体时,才会调用此拦截器。 对于从查询返回的实体,不调用它。 这可能会在将来的版本中更改; 请参阅此问题。
DbContext 只能跟踪一个具有任何给定主键值的实体实例。 这意味着,必须将具有相同键值的实体的多个实例解析为单个实例。 使用现有跟踪实例和新实例调用此类型的拦截器,并且必须将新实例中的任何属性值和关系更改应用到现有实例。 然后,新实例将被丢弃。
EF Core 提供内置实现, UpdatingIdentityResolutionInterceptor该实现使用新实例中的值更新现有跟踪实体。 配置上下文时,可以注册此项:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.AddInterceptors(new UpdatingIdentityResolutionInterceptor());
若要实现自定义标识解析逻辑,请创建一个实现 IIdentityResolutionInterceptor 的类,并重写 UpdateTrackedInstance 方法:
public class CustomIdentityResolutionInterceptor : IIdentityResolutionInterceptor
{
public void UpdateTrackedInstance(
IdentityResolutionInterceptionData interceptionData,
EntityEntry existingEntry,
object newEntity)
{
// Custom logic to merge property values from newEntity into the existing tracked entity
existingEntry.CurrentValues.SetValues(newEntity);
}
}