ASP.NET Core 2.1 Entity Framework Core 的应用程序触发器





5.00/5 (6投票s)
ASP.NET Core 2.1 Entity Framework Core 的应用程序触发器
引言
在这篇文章中,我想谈谈如何扩展您的应用程序和 DbContext
,以便在发生保存时运行任意代码。
背景故事
在处理大量数据库应用程序时,尤其是在使用 Entity Framework 时,我注意到了一种模式:保存更改到数据库,然后根据这些更改执行其他操作。以下是一些示例:
- 当用户状态发生变化时,在 UI 中反映出来。
- 添加或更新产品时,更新库存。
- 删除实体时,执行其他操作,例如检查有效性。
- 当实体以任何方式更改(
添加
、更新
、删除
)时,将其发送到外部服务。
这些操作大多类似于数据库触发器,当数据更改时,需要执行某些操作,但这些操作并不总是与数据库相关,更多的是对数据库更改的响应,有时仅仅是业务逻辑。
因此,在这些应用程序中的一个中,我找到了一种方法来整合这种行为并清理随之而来的重复代码,同时通过将触发器注册到 ASP.NET Core 的 IoC 容器中来保持其可维护性。
在这篇文章中,我们将讨论以下内容:
- 如何扩展
DbContext
以支持触发器 - 如何使用相同的接口或基类将多个实例注册到容器中
- 如何从跟踪的更改创建实体实例,以便我们可以处理具体的对象
- 如何将触发器限制为仅在某些数据条件下触发
- 将依赖项注入到我们的触发器中
- 避免触发器中的无限循环
我们还有很长的路要走,让我们开始吧。
创建触发器框架
ITrigger 接口
我们将从触发器的根开始,那就是 ITrigger
接口。
using Microsoft.EntityFrameworkCore.ChangeTracking;
public interface ITrigger
{
void RegisterChangedEntities(ChangeTracker changeTracker);
Task TriggerAsync();
}
RegisterChangedEntities
方法接受一个ChangeTracker
,以便在需要时,我们可以存储发生的更改以供以后使用。TriggerAync
方法实际执行我们的逻辑,这两个方法之所以分开,是因为我们稍后将对DbContext
进行更改。
TriggerBase 基类
接下来,我们将研究一个基类,它不是强制性的,但它的存在主要有两个原因:
- 用于存放触发器的通用逻辑,包括跟踪实体状态
- 用于根据实体筛选触发器
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore.ChangeTracking;
public abstract class TriggerBase<T> : ITrigger
{
protected IEnumerable<TriggerEntityVersion<T>> TrackedEntities;
protected abstract IEnumerable<TriggerEntityVersion<T>>
RegisterChangedEntitiesInternal(ChangeTracker changeTracker);
protected abstract Task TriggerAsyncInternal(TriggerEntityVersion<T> trackedTriggerEntity);
public void RegisterChangedEntities(ChangeTracker changeTracker)
{
TrackedEntities = RegisterChangedEntitiesInternal(changeTracker).ToArray();
}
public async Task TriggerAsync()
{
foreach (TriggerEntityVersion<T> triggerEntityVersion in TrackedEntities)
{
await TriggerAsyncInternal(triggerEntityVersion);
}
}
}
让我们逐个成员地分解,了解这个基类。
- 该类是一个泛型类型
T
,这确保了在其子类中运行的逻辑将仅适用于我们想要触发器运行的特定实体。 - 受保护的
TrackedEntities
字段存储更改前的实体和更改后的实体,以便我们可以对它们运行触发器逻辑。 abstract
方法RegisterChangedEntitiesInternal
将在该类的具体实现中被覆盖,并确保给定一个ChangeTracker
,它将返回一组我们想要处理的实体。这并不是说它不能返回一个空集合,只是如果我们选择通过TriggerBase
类实现触发器,那么很可能我们会想要保留这些实例以供以后使用。abstract
方法TriggerAsyncInternal
针对我们从集合中保存的每个实体运行我们的触发器逻辑。public
方法RegisterChangedEntities
确保调用abstract
方法RegisterChangedEntitiesInternal
,然后调用.ToArray()
来确保即使我们有一个IEnumerable
查询,它也能真正执行,这样我们就不会在稍后的过程中得到一个状态无效的集合。这主要是出于我的判断,因为很容易忘记IEnumerable
查询具有延迟执行机制。public
方法TriggerAsync
只是枚举所有实体,并对每个实体调用TriggerAsyncInternal
。
现在我们已经讨论了基类,是时候转向 TriggerEntityVersion
的定义了。
TriggerEntityVersion 类
TriggerEntityVersion
类是一个辅助类,用于保存给定实体的旧实例和新实例。
using System.Linq;
using System.Reflection;
using Microsoft.EntityFrameworkCore.ChangeTracking;
public class TriggerEntityVersion<T>
{
public T Old { get; set; }
public T New { get; set; }
public static TriggerEntityVersion<TResult>
CreateFromEntityProperty<TResult>(EntityEntry<TResult> entry) where TResult : class, new()
{
TriggerEntityVersion<TResult> returnedResult = new TriggerEntityVersion<TResult>
{
New = new TResult(),
Old = new TResult()
};
foreach (PropertyInfo propertyInfo in typeof(TResult)
.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty)
.Where(pi => entry.OriginalValues.Properties.Any(property => property.Name == pi.Name)))
{
if (propertyInfo.CanRead && (propertyInfo.PropertyType == typeof(string) ||
propertyInfo.PropertyType.IsValueType))
{
propertyInfo.SetValue(returnedResult.Old, entry.OriginalValues[propertyInfo.Name]);
}
}
foreach (PropertyInfo propertyInfo in typeof(TResult)
.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty)
.Where(pi => entry.OriginalValues.Properties.Any(property => property.Name == pi.Name)))
{
if (propertyInfo.CanRead && (propertyInfo.PropertyType == typeof(string) ||
propertyInfo.PropertyType.IsValueType))
{
propertyInfo.SetValue(returnedResult.New, entry.CurrentValues[propertyInfo.Name]);
}
}
return returnedResult;
}
}
此类 breakdown 如下:
- 我们有两个同类型的属性,一个代表修改前的
Old
实例,另一个代表修改后的New
状态。 - 工厂方法
CreateFromEntityProperty
使用反射,以便我们可以将EntityEntry
转换为我们自己的实体,从而更容易处理,因为EntityEntry
并不容易进行内省和处理。此方法将创建我们的实体实例,并复制正在跟踪的原始值和当前值,但前提是它们可以被写入且是string
或值类型(因为类通常代表其他实体,不包括拥有的属性)。此外,我们只关注被跟踪的属性。
在接下来的部分,当我们看到如何实现具体触发器时,我们将看到这个类是如何使用的。
具体触发器
我们将创建两个触发器来展示它们的不同之处,以及稍后在我们集成到 ServiceProvider
时如何注册多个触发器。
考勤触发器
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DbBroadcast.Models; // this is just to point to the `TriggerEntityVersion`,
// will differ in your system
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.Extensions.Logging;
public class AttendanceTrigger : TriggerBase
{
private readonly ILogger _logger;
public AttendanceTrigger(ILogger logger)
{
_logger = logger;
}
protected override IEnumerable RegisterChangedEntitiesInternal(ChangeTracker changeTracker)
{
return changeTracker
.Entries()
.Where(entry => entry.State == EntityState.Modified)
.Select(TriggerEntityVersion.CreateFromEntityProperty);
}
protected override Task TriggerAsyncInternal(TriggerEntityVersion trackedTriggerEntity)
{
_logger.LogInformation($"Update attendance for user {trackedTriggerEntity.New.Id}");
return Task.CompletedTask;
}
}
从这个触发器的定义中,我们可以看到以下几点:
- 此触发器将应用于
ApplicationUser
实体。 - 由于触发器实例是通过
ServiceProvider
创建的,我们可以通过其构造函数注入依赖项,就像我们对ILogger
所做的那样。 RegisterChangedEntitiesInternal
方法实现了一个对ApplicationUser
类型跟踪实体的查询,仅当它们已被修改时才执行。我们可以检查其他条件,但我建议在.Select
调用之后进行,这样您就可以处理您的实体实际实例。TriggerAsyncInternal
实现只是记录用户的 ID(或其他我们可能想要记录的字段)。
UI 触发器
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.Extensions.Logging;
using DbBroadcast.Models;
public class UiTrigger : TriggerBase<ApplicationUser>
{
private readonly ILogger<AttendanceTrigger> _logger;
public UiTrigger(ILogger<AttendanceTrigger> logger)
{
_logger = logger;
}
protected override IEnumerable<TriggerEntityVersion<ApplicationUser>>
RegisterChangedEntitiesInternal(ChangeTracker changeTracker)
{
return changeTracker.Entries<ApplicationUser>().Select
(TriggerEntityVersion<ApplicationUser>.CreateFromEntityProperty);
}
protected override Task TriggerAsyncInternal
(TriggerEntityVersion<ApplicationUser> trackedTriggerEntity)
{
_logger.LogInformation($"Update UI for user {trackedTriggerEntity.New.Id}");;
return Task.CompletedTask;
}
}
此类与前一个类相同,更多是为了示例目的,只是它有一个不同的消息,并且它将跟踪 ApplicationUser
实体的所有更改,无论其状态如何。
注册触发器
现在我们已经编写了触发器,是时候注册它们了。要注册同一个接口或基类的多个实现,我们只需要在 Startup.ConfigureServices
方法(或您注册服务的任何地方)中进行如下更改:
services.TryAddEnumerable(new []
{
ServiceDescriptor.Transient<ITrigger, AttendanceTrigger>(),
ServiceDescriptor.Transient<ITrigger, UiTrigger>(),
});
这样,您就可以拥有不同生命周期的触发器,数量不限(尽管它们应该与您的上下文的生命周期一致,否则会出错),并且易于维护。您甚至可以有一个配置文件来按需启用某些触发器 :D。
修改 DbContext
在这里,我将展示两种根据您的需求可能很有用的情况。您还会看到实现是相同的,区别在于方便性,因为对于简单的情况,您只需要继承即可,对于复杂的情况,您需要手动进行这些更改。
使用基类
如果您的上下文只继承自 DbContext
,那么您可以使用以下基类:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DbBroadcast.Data.Triggers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
public abstract class TriggerDbContext : DbContext
{
private readonly IServiceProvider _serviceProvider;
public TriggerDbContext(DbContextOptions<ApplicationDbContext> options,
IServiceProvider serviceProvider)
: base(options)
{
_serviceProvider = serviceProvider;
}
public override async Task<int> SaveChangesAsync
(CancellationToken cancellationToken = new CancellationToken())
{
IEnumerable<ITrigger> triggers =
_serviceProvider?.GetServices<ITrigger>()?.ToArray() ?? Enumerable.Empty<ITrigger>();
foreach (ITrigger userTrigger in triggers)
{
userTrigger.RegisterChangedEntities(ChangeTracker);
}
int saveResult = await base.SaveChangesAsync(cancellationToken);
foreach (ITrigger userTrigger in triggers)
{
await userTrigger.TriggerAsync();
}
return saveResult;
}
}
这里要指出的是:
- 我们注入
IServiceProvider
,以便我们可以访问我们的触发器。 - 我们覆盖
SaveChangesAsync
(对于上下文的所有其他保存方法也是如此,尽管此方法是当今使用最多的)并实现更改。- 我们从
ServiceProvider
获取触发器(我们甚至可以过滤它们以获取特定类型的触发器,但最好保持原样,因为它保持简单)。 - 我们遍历每个触发器,并根据我们的触发器注册逻辑保存有更改的实体。
- 我们在数据库中执行实际保存,以确保一切正常(如果出现数据库错误,则触发器将因异常冒泡而取消)。
- 然后我们执行每个触发器。
- 我们返回结果,就好像什么都没发生一样 :D。
- 我们从
请记住,根据此实现,您不希望有一个触发器更新同一实体,否则您可能会陷入循环,因此您必须对触发器有明确的规则,或者根本不在触发器中更改同一实体。
使用现有上下文
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DbBroadcast.Data.Triggers;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using DbBroadcast.Models;
using Microsoft.Extensions.DependencyInjection;
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
private readonly IServiceProvider _serviceProvider;
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options,
IServiceProvider serviceProvider)
: base(options)
{
_serviceProvider = serviceProvider;
}
public override async Task<int> SaveChangesAsync
(CancellationToken cancellationToken = new CancellationToken())
{
IEnumerable<ITrigger> triggers =
_serviceProvider?.GetServices<ITrigger>()?.ToArray() ?? Enumerable.Empty<ITrigger>();
foreach (ITrigger userTrigger in triggers)
{
userTrigger.RegisterChangedEntities(ChangeTracker);
}
int saveResult = await base.SaveChangesAsync(cancellationToken);
foreach (ITrigger userTrigger in triggers)
{
await userTrigger.TriggerAsync();
}
return saveResult;
}
}
如您所见,这与基类几乎相同,但由于此上下文已继承自 IdentityDbContext
,因此您必须自己实现。
要自己实现,您需要更新构造函数以接受 ServiceProvider
并覆盖相应的保存方法。
结论
为了实现这一点,我们利用了继承、触发器的策略模式、使用 ServiceProvider
以及多个注册。
我希望您喜欢这个,就像我喜欢摆弄它一样,我很想知道您可能会想出什么样的触发器。
谢谢,祝您编码愉快。