[C# starter-kit] Domain Entities | `AuditableEntity`基类 | 跟踪变化 | 软删除

第4章:Domain Entities & Auditable Behavior

欢迎回来

第3章:命令/查询职责分离(CQRS)与MediatR中,我们学习了如何通过将=="执行"(命令)与"查询"(查询)分离来路由组织==应用程序的逻辑。这些命令和查询,如CreateBrandCommandSearchBrandsCommand,并不是凭空操作的;它们与代表我们业务核心概念的实际数据进行交互

想象一下,我们正在构建一个图书馆系统。我们有书籍、会员和借阅记录。这些不仅仅是随机的数据片段;它们是图书馆处理的基本"事物"。每个"事物"(如一本书)都有自己的属性(标题、作者、ISBN)和规则(可以被借出、归还、损坏)。

本章介绍领域实体 ,它们是应用程序业务逻辑的基本"事物"或"构建块",例如BrandProductTodoItem

我们还将探讨可审计行为 ,这是一种智能的方式,可以自动跟踪这些实体的重要历史记录,例如谁创建了它们、最后一次修改的时间,甚至如何"软删除"它们而不会永久丢失数据。

这个系统确保我们的数据可靠,并提供清晰的历史记录,就像一位细心的图书管理员记录每本书的旅程,而不会真正将其从目录中移除。

问题:跟踪变化和保留历史

考虑一下我们在前几章中的Brand示例。当使用EntityTable组件和CreateBrandCommandUpdateBrandCommand创建新品牌或更新现有品牌时:

  • 你如何知道创建了这个品牌?
  • 它具体是在什么时候创建的?
  • 最后修改了它的描述?
  • 修改是在什么时候进行的?
  • 如果你"删除"一个品牌,它应该真正从数据库中消失,还是仅仅标记为删除以便以后可以恢复?

如果没有标准化的方式来处理这些问题,需要在每个数据类型 中添加字段,如CreatedDateCreatedByModifiedDateModifiedByIsDeleted。然后,你还需要在每次创建、更新或"删除"记录时编写代码来填充这些字段。这将是一项重复且容易出错的工作!

解决方案:领域实体与可审计行为

dotnet-starter-kit通过以下强大组合解决了这个问题:

1. 领域实体:核心业务"事物"

领域实体 是一个类,代表应用程序业务领域中的核心概念。这些对象具有身份(唯一ID)、属性(如NameDescription)和行为(如Update方法)。

在我们的项目中,BrandProductTodoItem都是领域实体的例子。它们被设计为封装自己的数据和逻辑。

2. AuditableEntity:内置的历史记录器

与其手动为每个实体添加跟踪字段,我们的实体继承自一个名为AuditableEntity的特殊类。这个类为所有需要历史记录的实体提供了一个基础蓝图。

AuditableEntity自动提供:

  • Created:实体首次创建的时间。
  • CreatedBy:创建它的用户ID。
  • LastModified:实体最后一次更新的时间。
  • LastModifiedBy:最后一次更新它的用户ID。
  • Deleted:实体被"软删除"的时间戳。
  • DeletedBy:"软删除"它的用户ID。

这意味着只需继承AuditableEntity,就能免费获得这些强大的审计功能

3. 软删除:永不真正消失

DeletedDeletedBy属性启用了"软删除"。与其从数据库中物理删除记录(这可能很危险并导致数据丢失),软删除只是将记录标记为某个DateTimeOffset时间的Deleted,由某个DeletedBy用户操作。

记录仍然存在于数据库中,但通常从普通查询中过滤掉。这就像将文件移动到回收站而不是永久删除。

4. IAggregateRoot:事务一致性的标记

你可能还会注意到一些实体实现了IAggregateRoot。这是领域驱动设计(DDD)中的一个标记接口。它表示这个实体是相关对象集群的"根",应该被视为数据更改的单一单元。对于初学者来说,只需知道它有助于保持数据的一致性:如果你修改了一个IAggregateRoot,其边界内的所有更改应该一起成功或失败。

如何使用:构建领域实体

让我们看看我们的Brand实体以及ProductTodoItem如何利用AuditableEntity

AuditableEntity基类

所有主要的领域实体最终都继承自AuditableEntity<Guid>,而它本身又继承自BaseEntity<Guid>。这提供了唯一的Id(一个Guid)和可审计属性。

(之前的手写rpc专栏中用到过同样的设计思想:实现Json-Rpc

csharp 复制代码
// src/api/framework/Core/Domain/AuditableEntity.cs
public class AuditableEntity<TId> : BaseEntity<TId>, IAuditable, ISoftDeletable
{
    public DateTimeOffset Created { get; set; }
    public Guid CreatedBy { get; set; }
    public DateTimeOffset LastModified { get; set; }
    public Guid? LastModifiedBy { get; set; }
    public DateTimeOffset? Deleted { get; set; }
    public Guid? DeletedBy { get; set; }
}

public abstract class AuditableEntity : AuditableEntity<Guid>
{
    protected AuditableEntity() => Id = Guid.NewGuid();
}

解释 :这段代码定义了AuditableEntity类。注意它有CreatedCreatedByLastModifiedLastModifiedByDeletedDeletedBy属性。它还实现了IAuditableISoftDeletable接口,这些接口定义了这些属性。没有泛型类型参数的AuditableEntity是一个方便的基类,适用于始终使用Guid作为ID的实体,在创建时自动分配一个新的Guid

创建Brand实体

我们的Brand类简单地继承自AuditableEntity,并定义了自己的唯一属性和方法。它不需要担心CreatedByLastModified------这些会自动处理!

csharp 复制代码
// src/api/modules/Catalog/Catalog.Domain/Brand.cs
public class Brand : AuditableEntity, IAggregateRoot
{
    public string Name { get; private set; } = string.Empty;
    public string? Description { get; private set; }

    // ORM(对象关系映射器)的私有构造函数
    private Brand() { }

    // 创建新实例的私有构造函数
    private Brand(Guid id, string name, string? description)
    {
        Id = id; // 如果需要,可以手动设置ID,或者让AuditableEntity分配默认的Guid
        Name = name;
        Description = description;
        // 可选地排队一个领域事件,如BrandCreated
    }

    // 创建新Brand的公共静态方法
    public static Brand Create(string name, string? description)
    {
        return new Brand(Guid.NewGuid(), name, description);
    }

    // 更新Brand属性的方法
    public Brand Update(string? name, string? description)
    {
        bool isUpdated = false;

        if (!string.IsNullOrWhiteSpace(name) && !string.Equals(Name, name, StringComparison.OrdinalIgnoreCase))
        {
            Name = name;
            isUpdated = true;
        }
        // ... Description的类似逻辑 ...

        if (isUpdated)
        {
            // 可选地排队一个领域事件,如BrandUpdated
        }
        return this;
    }
}

解释Brand实体定义了NameDescription。关键的是,它从AuditableEntity继承了IdCreatedCreatedBy等。CreateUpdate方法包含特定于Brand的业务逻辑,例如更新其NameDescription。当调用Create时,返回一个新的Brand实例,准备保存。当调用Update时,修改现有Brand对象的属性。

类似地,ProductTodoItem实体也继承自AuditableEntity

csharp 复制代码
// src/api/modules/Catalog/Catalog.Domain/Product.cs
public class Product : AuditableEntity, IAggregateRoot
{
    public string Name { get; private set; } = string.Empty;
    public string? Description { get; private set; }
    public decimal Price { get; private set; }
    public Guid? BrandId { get; private set; }
    // ... 构造函数和方法 ...
    public static Product Create(string name, string? description, decimal price, Guid? brandId) { /* ... */ return new Product(); }
    public Product Update(string? name, string? description, decimal? price, Guid? brandId) { /* ... */ return this; }
}

// src/api/modules/Todo/Domain/TodoItem.cs
public sealed class TodoItem : AuditableEntity, IAggregateRoot
{
    public string Title { get; private set; } = string.Empty;
    public string Note { get; private set; } = string.Empty;
    // ... 构造函数和方法 ...
    public static TodoItem Create(string title, string note) { /* ... */ return new TodoItem(); }
    public TodoItem Update(string? title, string? note) { /* ... */ return this; }
}

解释 :就像Brand一样,ProductTodoItem通过简单地继承AuditableEntity获得了所有审计功能。然后它们添加自己的特定属性和方法,保持代码专注于它们是什么 ,而不是它们的历史如何被跟踪

幕后:AuditInterceptor

那么,这些CreatedCreatedByLastModifiedLastModifiedByDeletedDeletedBy属性是如何自动填充的呢?魔法发生在**AuditInterceptor**中。

当你的应用程序保存对数据库的更改时(使用Entity Framework Core,我们将在第5章:数据持久化层中介绍),AuditInterceptor在更改发送到数据库之前"拦截"这些更改。

以下是处理CreateBrandCommand时的简化流程:

解释 :当CreateBrandHandler要求Repository执行AddAsync添加一个新的Brand时,Repository告诉Entity Framework的DbContext。在DbContext实际写入数据库之前,AuditInterceptor介入。它看到一个新的Brand实体正在被Added,因此自动填充Created时间戳和CreatedBy用户ID。对于更新,它会填充LastModifiedLastModifiedBy。对于删除,它通过设置DeletedDeletedBy执行软删除。

深入代码:AuditInterceptor

AuditInterceptor是一个特殊的类,实现了Entity Framework Core的SaveChangesInterceptor。这允许它挂钩到保存过程中。

csharp 复制代码
// src/api/framework/Infrastructure/Persistence/Interceptors/AuditInterceptor.cs
public class AuditInterceptor(ICurrentUser currentUser, TimeProvider timeProvider, IPublisher publisher) : SaveChangesInterceptor
{
    // ... 其他方法 ...

    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
    {
        UpdateEntities(eventData.Context); // 魔法发生在这里!
        // ... 还会发布审计跟踪 ...
        return await base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    public void UpdateEntities(DbContext? context)
    {
        if (context == null) return;
        foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
        {
            var utcNow = timeProvider.GetUtcNow();
            if (entry.State is EntityState.Added or EntityState.Modified || entry.HasChangedOwnedEntities())
            {
                if (entry.State == EntityState.Added)
                {
                    entry.Entity.CreatedBy = currentUser.GetUserId(); // 从身份中获取当前用户
                    entry.Entity.Created = utcNow;
                }
                entry.Entity.LastModifiedBy = currentUser.GetUserId(); // 在添加或修改时始终更新
                entry.Entity.LastModified = utcNow;
            }
            if(entry.State is EntityState.Deleted && entry.Entity is ISoftDeletable softDelete)
            {
                softDelete.DeletedBy = currentUser.GetUserId();
                softDelete.Deleted = utcNow;
                entry.State = EntityState.Modified; // 将状态更改为Modified以进行软删除
            }
        }
    }
}

解释

  • Entity Framework在更改提交到数据库之前 调用SavingChangesAsync方法。
  • 然后它调用UpdateEntities,这是核心逻辑。
  • UpdateEntities循环遍历DbContext跟踪的所有实体(即新的、修改的或删除的实体)。
  • 对于Added实体,它使用ICurrentUser服务(从第2章:身份验证与授权(Identity & JWT)中知道登录用户是谁)和当前时间设置CreatedByCreated
  • 对于AddedModified实体,它总是更新LastModifiedByLastModified
  • 关键的是,对于标记为Deleted 实现ISoftDeletable(如我们的AuditableEntity)的实体,它实际上不会删除它们。相反,它设置DeletedByDeleted,然后将EntityState改回Modified。这使得Entity Framework用删除信息更新记录,而不是物理删除它。

其他基类和契约

为了完整性,以下是BaseEntityIAuditable / ISoftDeletable契约的简单定义:

csharp 复制代码
// src/api/framework/Core/Domain/BaseEntity.cs
public abstract class BaseEntity<TId> : IEntity<TId>
{
    public TId Id { get; protected init; } = default!;
    // ... 领域事件集合 ...
}

// src/api/framework/Core/Domain/Contracts/IAuditable.cs
public interface IAuditable
{
    DateTimeOffset Created { get; }
    Guid CreatedBy { get; }
    DateTimeOffset LastModified { get; }
    Guid? LastModifiedBy { get; }
}

// src/api/framework/Core/Domain/Contracts/ISoftDeletable.cs
public interface ISoftDeletable
{
    DateTimeOffset? Deleted { get; set; }
    Guid? DeletedBy { get; set; }
}

解释

  • BaseEntity为任何实体提供基本的Id属性,无论它是否可审计。它还包括一个领域事件集合(用于更高级的解耦业务逻辑,超出本章范围)。
  • IAuditableISoftDeletable是接口,类似于类将具有某些属性的承诺。AuditableEntity实现了这些承诺,确保它具有审计和软删除所需的所有字段。

总结

概念 描述 类比
领域实体 具有身份、属性和行为的核心业务概念(Brand)。 一本实体书、会员或借阅记录。
AuditableEntity 为实体提供Id、创建、修改和软删除跟踪的基类。 每个项目的标准化日志页,预印有"创建者"、"最后修改"等。
可审计行为 自动填充CreatedCreatedByLastModifiedLastModifiedBy属性。 自动在日志页上盖章日期和用户ID的时间戳机。
软删除 将记录标记为Deleted而不是删除它,使其可恢复。 将书移到"退役"区而不是烧毁它。
IAggregateRoot 标记接口,表示实体是事务一致性的边界。 必须始终放在一起的相关物品组的"封面"。
AuditInterceptor Entity Framework Core组件,在保存时自动填充可审计属性。 图书管理员的助手,在归档前填写日志条目。

刚刚揭开了领域实体和可审计行为的强大概念,了解到像BrandProductTodoItem这样的实体是应用程序业务逻辑的基本构建块。

通过继承AuditableEntity,这些实体自动获得了有价值的历史跟踪能力,包括谁创建或最后修改了它们以及时间戳。

此外,AuditableEntity启用了"软删除",保护你的数据免受意外永久删除的影响。这整个系统在后台无缝工作,归功于AuditInterceptor,它在保存数据更改时自动填充这些审计字段。

这种定义核心数据的强大方式确保了完整性,并为所有重要更改提供了清晰的审计跟踪。接下来,我们将深入探讨这些实体如何实际存储和从数据库中检索:第5章:数据持久化层

相关推荐
刀客Doc3 小时前
刀客doc:亚马逊广告再下一城,拿下微软DSP广告业务
大数据·人工智能·microsoft
潇凝子潇3 小时前
MySQL Redo Log 和 Undo Log 满了会有什么问题
数据库·mysql
Leinwin3 小时前
微软发布Azure容器存储v2.0.0国际版
microsoft·azure
鹿鸣天涯3 小时前
仍可绕过:新变通方案可实现微软 Win11 装机 OOBE 创建本地账号
microsoft
周杰伦_Jay5 小时前
【Homebrew安装 MySQL 】macOS 用 Homebrew 安装 MySQL 完整教程
数据库·mysql·macos
李宥小哥10 小时前
C#基础11-常用类
android·java·c#
悟能不能悟10 小时前
redis的红锁
数据库·redis·缓存
偶尔的鼠标人11 小时前
Avalonia中,使用DataTable类型作为DataGrid的ItemSource 数据源
ui·c#·avalonia
安当加密12 小时前
MySQL数据库透明加密(TDE)解决方案:基于国密SM4的合规与性能优化实践
数据库·mysql·性能优化