Entity Framework Core创建和更新字段

8
我在所有实体上都有4个自定义字段。这四个字段分别是:CreatedByCreatedDateUpdatedByUpdatedDate
是否有一种方法可以钩入Entity Framework Core事件,以便在插入时填充CreatedDate为当前日期时间,并将CreatedBy填充为当前用户?当数据库有更新时,它将用当前日期时间填充UpdatedDate,并用当前用户填充UpdatedBy
3个回答

19

基本上@Steve的方法是可取的,但当前实现方式使得难以对您的项目进行单元测试。

通过一点重构,您可以使其适合进行单元测试,并保持SOLID原则和封装性。

这是Steve示例的重构版本

public abstract class AuditableEntity
{
    public DateTime CreatedDate { get; set; }
    public string CreatedBy { get; set; }
    public DateTime UpdatedDate { get; set; }
    public string UpdatedBy { get; set; }
}

public class AuditableDbContext : DbContext
{
    protected readonly IUserService userService;
    protected readonly DbContextOptions options;
    protected readonly ITimeService timeService;

    public BaseDbContext(DbContextOptions options, IUserService userService, ITimeService timeService) : base(options)
    {
        userService = userService ?? throw new ArgumentNullException(nameof(userService));
        timeService = timeService ?? throw new ArgumentNullException(nameof(timeService));
    }

    public override int SaveChanges()
    {
        // get entries that are being Added or Updated
        var modifiedEntries = ChangeTracker.Entries()
                .Where(x => (x.State == EntityState.Added || x.State == EntityState.Modified));

        var identityName = userService.CurrentUser.Name;
        var now = timeService.CurrentTime;

        foreach (var entry in modifiedEntries)
        {
            var entity = entry.Entity as AuditableEntity;

            if (entry.State == EntityState.Added)
            {
                entity.CreatedBy = identityName ?? "unknown";
                entity.CreatedDate = now;
            }

            entity.UpdatedBy = identityName ?? "unknown";
            entity.UpdatedDate = now;
        }

        return base.SaveChanges();
    }
}
现在,对于单元测试,模型/领域/业务层很容易模拟时间和用户/主体,并且不需要依赖EF Core,更好地封装了您的领域逻辑。当然,您可以进一步重构以使用更模块化的方法,使用策略模式,但这超出了范围。您还可以使用ASP.NET Core Boilerplate,它还提供了一个可审计(和软删除)的EF Core DbContext实现(这里这里)。

我喜欢这个想法。一个问题可能是,如果您有一个不可审计的实体,或者有一种不同的保存设置,那么这种方法将无法工作,除非您维护几个不同类型的dbcontext。 - Steve
2
@Steve:ChangeTracker.Entries().TypeOf<AuditableEntity>()已经过滤了可审计实体。但如果想进一步模块化,可以使用策略模式并将其拆分为多个IEntitySaveHandler<T>IEntitySaveHandler,然后在其上有两种方法:CanHandleHandle方法。然后每个处理程序都可以检查它是否适用于该实体,如果是,则执行更改。这种方法也会尊重开放/封闭原则(对扩展开放,对修改关闭),您可以添加新的处理程序而不修改基本DbContext。 - Tseng
但这有点超出了一个单一问题的范围。 - Tseng
@Tseng,这种更改需要应用于所有“保存”方法(SaveChanges、SaveChangesAsync等),以防用户使用不同的方法?还是有一个可以应用此解决方案的单个方法,然后由各种方法消耗? - Aeseir
2
@Aeseir:据我所知,由于没有通用的方法,您需要将其应用于两种方法。 - Tseng

4

我和你有完全相同的布局,我称之为“Audit”字段。

我的解决方式是创建一个名为AuditableEntity的基本抽象类来保存属性,并公开一个名为PrepareSave的方法。在PrepareSave中,我根据需要设置字段的值:

public abstract class AuditableEntity
{
    public DateTime CreatedDate { get; set; }
    public string CreatedBy { get; set; }
    public DateTime UpdatedDate { get; set; }
    public string UpdatedBy { get; set; }

    public virtual void PrepareSave(EntityState state)
    {
        var identityName = Thread.CurrentPrincipal.Identity.Name;
        var now = DateTime.UtcNow;

        if (state == EntityState.Added)
        {
            CreatedBy = identityName ?? "unknown";
            CreatedDate = now;
        }

        UpdatedBy = identityName ?? "unknown";
        UpdatedDate = now;
    }
}

我将PrepareSave设置为虚拟方法,这样我就可以在需要的时候重写实体。根据您的实现方式,您可能需要更改获取身份信息的方式。
要调用此方法,我在我的DbContext上覆盖了SaveChanges方法,并在每个正在添加或更新的实体上调用了PrepareSave方法(我从更改跟踪器中获取):
public override int SaveChanges()
{
    // get entries that are being Added or Updated
    var modifiedEntries = ChangeTracker.Entries()
            .Where(x => x.State == EntityState.Added || x.State == EntityState.Modified);

    foreach (var entry in modifiedEntries)
    {
        // try and convert to an Auditable Entity
        var entity = entry.Entity as AuditableEntity;
        // call PrepareSave on the entity, telling it the state it is in
        entity?.PrepareSave(entry.State);
    }

    var result = base.SaveChanges();
    return result;
}

现在,每当我在我的DbContext上调用SaveChanges(无论是直接调用还是通过存储库),任何继承AuditableEntity的实体都将根据需要设置其审计字段。

你也可以将这个功能封装到一个“接口”IAuditableEntity中,这样你就不必强制让实体类都继承自一个基类。 - marc_s
1
@marc_s 我确实有一个接口(实际上我还有几个更高层级的接口),但为了简洁起见,我省略了它,因为 AuditableEntity 包含了设置字段的相关逻辑。 - Steve
1
这不是最佳解决方案。PrepareSave方法违反了至少两个最佳实践:1)来自SOLID的SRP:单一责任原则,因为AuditableEntity有多个职责:跟踪要更新的字段并保存其自身状态以及关注点分离的违规。2)它还违反了封装性:通过在所有实体的公共方法中声明EntityState作为参数,您现在已经将所有层(例如域/业务层)与持久技术紧密耦合。 - Tseng
你应该将所有的代码移动到 SaveChanges 方法中,并将 IHttpContextAccessor 注入(或者更好的方式是:注入一个访问 HttpContext 并返回已登录主体的服务)到你的 DbContext 基类中。这样,你就可以实现干净的解耦,而且在你的模型中没有与持久化相关的代码。如果没有它,你的模型将变得更难进行单元测试,因为无法模拟 datetime/thread 访问。 - Tseng
1
@Tseng 我并不是在假装这是最好的解决方案,就像大多数Entity Framework的事情一样,有很多方法可以做到这一点,而这对我们来说是有效的。最终,这只是我正在使用的代码库的一部分,其中包含大量其他基类和自定义实体框架工作方式的内容,因为我们的要求足够复杂,不能很好地使用开箱即用的解决方案。并非所有实体都实现了AuditableEntity,它们可能实现其他基类,因此采用了这种方法。欢迎您提出您的建议。 - Steve

0

不幸的是,我的声望太低了,无法添加评论。出于这个原因,我添加了我的答案。所以: 对Tseng答案的一个补充。当实体处于修改状态时,您必须将CreatedBy和CreatedDate属性的IsModidied设置为false。否则,这些字段将被可选值重写为字符串和DateTime类型。

if (entry.State == EntityState.added)
{
    CreatedBy = identityName ?? "unknown";
    CreatedDate = now;
}
else
{
    Entry<AuditableEntity>(entry).Property(p => p.CreatedBy).IsModified = false;
    Entry<AuditableEntity>(entry).Property(p => p.CreatedDate).IsModified = false;
    // or
    // entity.Property("CreatedBy").IsModified = false;
    // entity.Property("CreatedDate").IsModified = false;
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接