[Abp vNext 源码分析] - 5. DDD 的领域层支持(仓储、实体、值对象)

1、简要介绍

ABP vNext 框架自己就是围绕着 DDD 理念进行设计的,因此在 DDD 里面咱们可以见到的实体、仓储、值对象、领域服务,ABP vNext 框架都为咱们进行了实现,这些基础设施都存放在 Volo.Abp.Ddd.Domain 项目当中。html

本篇文章将会侧重于理论讲解,但也只是一个抛砖引玉的做用,关于 DDD 相关的知识能够阅读 Eric Evans 所编写的 《领域驱动设计:软件核心复杂性应对之道》。前端

PS:数据库

该书也是目前我正在阅读的 DDD 理论书籍,由于基于 DDD 理论,咱们可以精准地划分微服务的业务边界,为后续微服务架构的可扩展性提供坚实的基础。编程

2、源码分析

Volo.Abp.Ddd.Domain 分为 Volo 和 Microsoft 两个文件夹,在 Microsoft 文件夹当中主要是针对仓储和实体进行自动注入。数组

2.1 实体 (Entity)

2.1.1 基本概念

只要用过 EF Core 框架的人,基本都知道什么是实体。不过不少人就跟我同样,只是将实体做为数据库表在 C# 语言当中的另外一种展示方式,认为它跟普通的对象没什么不同。架构

PS:虽然每一个对象都会有一个内在的 对象引用指针 来做为惟一标识。并发

在 DDD 的概念当中,经过标识定义的对象被称为实体(Entity)。虽然它们的属性可能由于不一样的操做而被改变(多种生命周期),但必须保证一种内在的连续性。为了保证这种内在的连续性,就须要一个有意义而且惟一的属性app

标识是否重要则彻底取决于它是否有用,例若有个演唱会订票程序,你能够将座位与观众都看成一个实体处理。那么在分配座位时,每一个座位确定都会有一个惟一的座位号(惟一标识),可也能拥有其余描述属性(是不是 VIP 座位、价格等...)。框架

那么座位是否须要惟一标识,是否为一个实体,就取决于不一样的入场方式。假如说是一人一票制,而且每张门票上面都有固定的座位号,这个时候座位就是一个实体,由于它须要座位号来区分不一样的座位。dom

另外一种方式就是入场卷方式,门票上没有座位号,你想坐哪儿就坐哪儿。这个时候座位号就不须要与门票创建关联,在这种状况下座位就不是一个实体,因此不须要惟一标识。

* 上述例子与描述改编自 《领域驱动设计:软件核心复杂性应对之道》的 ENTITY 一节。

2.1.2 如何实现

了解了 DDD 概念里面的实体描述以后,咱们就来看一下 ABP vNext 为咱们准备了怎样的基础设施。

首先看 Entities 文件夹下关于实体的基础定义,在实体的基础定义类里面,为每一个实体定义了惟一标识。而且在某些状况下,咱们须要确保 ID 在多个计算机系统之间具备惟一性

尤为是在多个系统/平台进行对接的时候,若是每一个系统针对于 “张三” 这个用户的 ID 不是一致的,都是本身生成 ID ,那么就须要介入一个新的抽象层进行关系映射。

IEntity<TKey> 的默认实现 Entity<TKey> 中,不只提供了标识定义,也重写了 Equals() 比较方法和 ==  != 操做符,用于区别不一样实体。它为对象统必定义了一个 TKey 属性,该属性将会做为实体的惟一标识字段。

public override bool Equals(object obj)
{
    // 比较的对象为 NULL 或者对象不是派生自 Entity<T> 都视为不相等。
    if (obj == null || !(obj is Entity<TKey>))
    {
        return false;
    }

    // 比较的对象与当前对象属于同一个引用,视为相等的。
    if (ReferenceEquals(this, obj))
    {
        return true;
    }

    // 当前比较主要适用于 EF Core,若是任意对象是使用的默认 Id,即临时对象,则其默认 ID 都为负数,视为不相等。
    var other = (Entity<TKey>)obj;
    if (EntityHelper.HasDefaultId(this) && EntityHelper.HasDefaultId(other))
    {
        return false;
    }

    // 主要判断当前对象与比较对象的类型信息,看他们两个是否属于 IS-A 关系,若是不是,则视为不相等。
    var typeOfThis = GetType().GetTypeInfo();
    var typeOfOther = other.GetType().GetTypeInfo();
    if (!typeOfThis.IsAssignableFrom(typeOfOther) && !typeOfOther.IsAssignableFrom(typeOfThis))
    {
        return false;
    }

    // 若是两个实体他们的租户 Id 不一样,也视为不相等。
    if (this is IMultiTenant && other is IMultiTenant &&
        this.As<IMultiTenant>().TenantId != other.As<IMultiTenant>().TenantId)
    {
        return false;
    }

    // 经过泛型的 Equals 方法进行最后的比较。
    return Id.Equals(other.Id);
}

实体自己是支持序列化的,因此特别标注了 [Serializable] 特性。

[Serializable]
public abstract class Entity<TKey> : Entity, IEntity<TKey>
{
    // ... 其余代码。
}

针对于某些实体多是 复合主键 的状况,ABP vNext 则推荐使用 IEntityEntity 进行处理。

/// <summary>
/// 定义一个实体,但它的主键可能不是 “Id”,也有多是否复合主键。
/// 开发人员应该尽量使用 <see cref="IEntity{TKey}"/> 来定义实体,以便更好的与其余框架/结构进行集成。
/// </summary>
public interface IEntity
{
    /// <summary>
    /// 返回当前实体的标识数组。
    /// </summary>
    object[] GetKeys();
}

2.2 自动审计

在 Entities 文件夹里面,还有一个 Auditing 文件夹。在这个文件夹里面定义了不少对象,咱们最为经常使用的就是 FullAuditiedEntity 对象了。从字面意思来看,它是一个包含了全部审计属性的实体。

[Serializable]
public abstract class FullAuditedEntity<TKey> : AuditedEntity<TKey>, IFullAuditedObject
{
    // 软删除标记,为 true 时说明实体已经被删除,反之亦然。
    public virtual bool IsDeleted { get; set; }

    // 删除实体的用户 Id。
    public virtual Guid? DeleterId { get; set; }

    // 实体被删除的时间。
    public virtual DateTime? DeletionTime { get; set; }
}

那么,什么是审计属性呢?在 ABP vNext 内部将如下属性定义为审计属性:建立人建立时间修改人修改时间删除人删除时间软删除标记。这些属性不须要开发人员手动去书写/控制,ABP vNext 框架将会自动跟踪这些属性并设置其值。

开发人员除了能够直接继承 FullAuditedEntity 之外,也能够考虑集成其余的审计实例,例如只包含建立人与建立时间的 CreationAuditedEntity。若是你以为你只想要建立人、软删除标记、修改时间的话,也能够直接继承相应的接口。

public class TestEntity : Entity<int>,IMayHaveCreator,ISoftDelete,IHasModificationTime
{
    /// <summary>
    /// 建立人的 Id。
    /// </summary>
    public Guid? CreatorId { get; set; }
    
    /// <summary>
    /// 软删除标记。
    /// </summary>
    public bool IsDeleted { get; set; }
    
    /// <summary>
    /// 最后的修改时间。
    /// </summary>
    public DateTime? LastModificationTime { get; set; }
}

这里我只重点提一下关于审计实体相关的内容,对于聚合的根对象的审计实体,内容也是类似的,就再也不赘述。

2.3 值对象 (ValueObject)

2.3.1 基本概念

DDD 关于值对象某一个概念来讲,每一个值对象都是单一的副本,这个概念你能够类比 C# 里面关于值对象和引用对象的区别。

值对象与实体最大的区别就在于,值对象是没有概念标识的,还有比较重要的一点就是值对象是不可变的,所谓的不可变,就是值对象产生任何变化应该直接替换掉原有副本,而不是在原有副本上进行修改。若是值对象是可变的,那么它必定不能被共享。值对象能够引用实体或者其余的值对象。

这里仍然以书中的例子进行说明值对象的标识问题,例如 “地址” 这个概念。

若是我在淘宝买了一个键盘,个人室友也从淘宝买了同款键盘。对于淘宝系统来讲,咱们两个是否处于同一个地址并不重要,因此这里 “地址” 就是一个值对象。由于系统不须要关心两个地址的惟一标识是否一致,在业务上来讲也没有这个须要。

另外一个状况就是家里停电了,我和个人室友同时在电力服务系统提交了工单。这个时候对于电力系统来讲,若是两个工单的地址是在同一个地方,那么只须要派一我的去进行维修便可。这种状况下,地址就是一个实体,由于地址涉及到比较,而比较的依据则是地址的惟一标识。

上述状况还有的另外一种实现方式,即咱们将住处抽象为一个实体,电力系统与住处进行关联。住处里面包含地址,这个时候地址就是一个值对象。由于这个时候电力系统关心的是住处是否一致,而地址则做为一个普通的属性而已。

关于值对象的另外一个用法则更加通俗,例如一个 Person 类,他原来的定义是拥有一个 Id、姓名、街道、社区、城市。那么咱们能够将街道、社区、城市抽象到一个值对象 Address 类里面,每一个值对象内部包含的属性应该造成一个概念上的总体

2.3.2 如何实现

ABP vNext 对于值对象的实现是比较粗糙的,他仅参考 MSDN 定义了一个简单的 ValueObject 类型,具体的用法开发人员能够参考 MSDN 实现值对象的细节,下文仅是摘抄部份内容进行简要描述。

MSDN 也是以地址为例,他将 Address 定义为一个值对象,以下代码。

public class Address : ValueObject
{
    public String Street { get; private set; }
    public String City { get; private set; }
    public String State { get; private set; }
    public String Country { get; private set; }
    public String ZipCode { get; private set; }

    private Address() { }

    public Address(string street, string city, string state, string country, string zipcode)
    {
        Street = street;
        City = city;
        State = state;
        Country = country;
        ZipCode = zipcode;
    }

    protected override IEnumerable<object> GetAtomicValues()
    {
        // Using a yield return statement to return each element one at a time
        yield return Street;
        yield return City;
        yield return State;
        yield return Country;
        yield return ZipCode;
    }
}

不过咱们知道,若是一个值对象须要持久化到数据库,没有 Id 标识咋办?MSDN 上面也说明了在 EF Core 1.1 和 EF Core 2.0 的处理方法,这里咱们只着重说明 EF Core 2.0 的处理方法。

EF Core 2.0 可使用 owned entity(固有实体类型) 来实现值对象,固有实体的如下特征能够帮助咱们实现值对象。

  • 固有对象能够用做属性,而且没有本身的标识。
  • 在查询全部实体时,固有实体将会包含进去。例如我查询订单 A,那么就会将地址这个值对象包含到订单 A 的结果当中。

但一个类型无论怎样都是会拥有它本身的标识的,这里再也不详细叙述,更加详细的能够参考 MSDN 英文原版说明。(中文版翻译有问题)

  • The identity of the owner
  • The navigation property pointing to them
  • In the case of collections of owned types, an independent component (not yet supported in EF Core 2.0, coming up on 2.2).

EF Core 不会自动发现固有实体类型,须要显示声明,这里以 MSDN 官方的 eShopOnContainers DEMO 为例。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfiguration(new ClientRequestEntityTypeConfiguration());
    modelBuilder.ApplyConfiguration(new PaymentMethodEntityTypeConfiguration());
    modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());
    modelBuilder.ApplyConfiguration(new OrderItemEntityTypeConfiguration());
    //...Additional type configurations
}

接着咱们来到 OrderEntityTypeConfiguration 类型的 Configure() 方法中。

public void Configure(EntityTypeBuilder<Order> orderConfiguration)
{
    orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);
    orderConfiguration.HasKey(o => o.Id);
    orderConfiguration.Ignore(b => b.DomainEvents);
    orderConfiguration.Property(o => o.Id)
        .ForSqlServerUseSequenceHiLo("orderseq", OrderingContext.DEFAULT_SCHEMA);

    // 说明 Address 属性是 Order 类型的固有实体。
    orderConfiguration.OwnsOne(o => o.Address);

    orderConfiguration.Property<DateTime>("OrderDate").IsRequired();

    //...Additional validations, constraints and code...
    //...
}

默认状况下,EF Core 会将固有实体的数据库列名,以 <实体的属性名> _ <固有实体的属性> 。以上面的 Address 类型字段为例,将会生成 Address_StreetAddress_City 这样的名称。你也能够经过流畅接口来重命名这些列,代码以下:

orderConfiguration.OwnsOne(p => p.Address)
                            .Property(p=>p.Street).HasColumnName("ShippingStreet");

orderConfiguration.OwnsOne(p => p.Address)
                            .Property(p=>p.City).HasColumnName("ShippingCity");

2.4 聚合

若是说实体的概念还比较好理解的话,那么聚合则是在实体之上新的抽象。聚合就是一组相关对象的集合,他会有一个根对象(root),和它的一个边界(boundary)。对于聚合外部来讲,只可以引用它的根对象,而在聚合内部的其余对象则能够相互引用。

一个简单的例子(《领域驱动设计》)来讲,汽车是一个具备全局标识的实体,每一辆汽车都拥有本身惟一的标识。在某些时候,咱们可能会须要知道轮胎的磨损状况与千米数,由于汽车有四个轮胎,因此咱们也须要将轮胎视为实体,为其分配惟一本地的标识,这个标识是聚合内惟一的。可是在脱离了汽车这个边界以后,咱们就不须要关心这些轮胎的标识。

因此在上述例子当中,汽车是一个聚合的根实体,而轮胎处于这个聚合的边界以内。


那么一个聚合应该怎样进行设计呢?这里我引用汤雪华大神的 《关于领域驱动设计(DDD)中聚合设计的一些思考》《聚合(根)、实体、值对象精炼思考总结》 说明一下聚合根要怎么设计才合理。

聚合的几大设计原则:

  1. 聚合是用来封装不变性(即固定规则),而不是将领域对象简单组合到一块儿。
  2. 聚合应该尽可能设计成小聚合。
  3. 聚合与聚合之间的关系应该经过 Id 进行引用。
  4. 聚合内部应该是强一致性(同一事务),聚合之间只须要追求最终一致性便可。

以上内容咱们仍是以经典的订单系统来举例子,说明咱们的实体与聚合应该怎样进行划分。咱们有一个订单系统,其结构以下图:

其中有一个固定规则,就是采购项(Line Item)的总量不可以超过 PO 总额(approved limit)的限制,这里的 Part 是具体采购的部件(产品),它拥有一个 price 属性做为它的金额。

从上述业务场景咱们就能够得出如下问题:

  1. 固定规则的实施,即添加新的采购项时,PO 须要检查总额,若是超出限制视为无效。
  2. 当 PO 被删除或者存档时,采购项也应该一并处理。(同生共死原则
  3. 多用户的竞争问题,若是在采购过程当中,采购项与部件都被用户修改,会产生问题。

场景 1:

当用户编辑任何一个对象时,锁定该对象,直到编辑完成提交事务。这样就会形成 George 编辑订单 #0001 的采购项 001 时,Amanda 没法修改该采购项。可是 Amanda 能够修改其余的采购项,这样最后提交的时候就会致使 #0001 订单破坏了固定规则。

场景 2:

若是锁定单行对象不行,那么咱们直接锁定 PO 对象,而且为了防止 Part 的价格被修改,Part 对象也须要被锁定。这样就会形成太多的数据争用,如今 3 我的都须要等待。

从上述场景来看,咱们能够得出如下结论:

  1. Part 在不少 PO 当中被使用。
  2. 对 Part 的修改少于对 PO 的修改。
  3. PO 与采购项不能分开,后者独立存在没有意义。
  4. 对 Part 的价格修改不必定要实时传播给 PO,仅取决于修改价格时 PO 处于什么状态。

有以上结论能够知道,咱们能够将 Part 的价格冗余到采购项,PO 和采购项的建立与删除是很天然的业务规则,而 Part 的建立与删除是独立的,因此将 PO 与采购项能划为一个聚合。

Abp vNext 框架也为咱们提供了聚合的定义与具体实现,即 AggregateRoot 类型。该类型也继承自 Entity 类型,而且内部提供了一个并发令牌防止并发冲突。

而且在其内部也提供了领域事件的快速增删方法,其余的与常规实体基本一致。经过领域事件,咱们能够完成对事务的拆分。例如上述的例子当中,咱们也能够为 Part 增长一个领域事件,当价格被更新时,PO 能够订阅这个事件,实现对应的采购项更新。

只是这里你会奇怪,增长的事件到哪儿去了呢?他们这些事件最终会被添加到 EntityChangeReport 类型的 DomainEvents 集合里面,而且在实体变动时进行触发。

关于聚合的 示例,在 ABP vNext 官网已经有十分详细的描述,这里我贴上代码供你们理解如下,官方的例子仍然是以订单和采购项来讲的。

public class Order : AggregateRoot<Guid>
{
    public virtual string ReferenceNo { get; protected set; }

    public virtual int TotalItemCount { get; protected set; }

    public virtual DateTime CreationTime { get; protected set; }

    public virtual List<OrderLine> OrderLines { get; protected set; }

    protected Order()
    {

    }

    public Order(Guid id, string referenceNo)
    {
        Check.NotNull(referenceNo, nameof(referenceNo));
        
        Id = id;
        ReferenceNo = referenceNo;
        
        OrderLines = new List<OrderLine>();
    }

    public void AddProduct(Guid productId, int count)
    {
        if (count <= 0)
        {
            throw new ArgumentException(
                "You can not add zero or negative count of products!",
                nameof(count)
            );
        }

        var existingLine = OrderLines.FirstOrDefault(ol => ol.ProductId == productId);

        if (existingLine == null)
        {
            OrderLines.Add(new OrderLine(this.Id, productId, count));
        }
        else
        {
            existingLine.ChangeCount(existingLine.Count + count);
        }

        TotalItemCount += count;
    }
}

public class OrderLine : Entity
{
    public virtual Guid OrderId { get; protected set; }

    public virtual Guid ProductId { get; protected set; }

    public virtual int Count { get; protected set; }

    protected OrderLine()
    {

    }

    internal OrderLine(Guid orderId, Guid productId, int count)
    {
        OrderId = orderId;
        ProductId = productId;
        Count = count;
    }

    internal void ChangeCount(int newCount)
    {
        Count = newCount;
    }
}

2.5 服务 (Service)

根据 DDD 理论来讲,每一个实体或者值对象已经具备一些业务方法,为何还须要服务对象来进行处理呢?

由于在某些状况下,某些重要的领域动做都不属于任何实体或者值对象,强行将它概括在某一个对象里面,那么就会产生概念上的混淆。

服务都是没有本身的状态,它们除了承载领域操做之外没有其余任何意义。服务则是做为一种接口提供操做,一个良好的服务定义拥有一下几个特征。

  • 与领域概念相关的操做不是实体或者值对象的天然组成部分
  • 接口是根据领域模型的其余元素定义的。
  • 操做是无状态的。

从上述定义来看,咱们的控制器(Controller)就符合这几个特征,尤为是无状态的定义。那么咱们哪些操做可以放到服务对象当中呢?根据 DDD 理论来讲,只有领域当中某个重要的过程或者转换操做不是实体或值对象的天然职责的时候,就应该添加一个独立的服务来承载这些操做。

那么问题来了,在层级架构来讲,领域层的服务对象应用层的服务对象最难以区分。以书中的例子举例,当客户余额小于某个阈值的时候,就会向客户发送电子邮件。在这里,应用服务负责通知的设置,而领域服务则须要肯定客户是否知足阈值。这里就涉及到了银行领域的业务,说白了领域服务是会涉及到具体业务规则的。

下面就是书中关于不一样分层当中服务对象的划分:

从上面的描述来看,领域层的应用服务就对应着 ABP vNext 框架当中的应用服务。因此咱们能够将应用服务做为 API 接口暴露给前端(表现层),由于应用服务仅仅是起一个协调领域层和基础设施层的做用。(相似脚本)

2.5.1 领域服务 (Domain Service)

上面咱们了解了什么是领域服务,ABP vNext 为咱们提供了领域服务的基本抽象定义 IDomainServiceDomainService

它们的内部实现比较简单,只注入了一些经常使用的基础组件,咱们使用的时候直接继承 DomainService 类型便可。

public abstract class DomainService : IDomainService
{
    public IServiceProvider ServiceProvider { get; set; }
    protected readonly object ServiceProviderLock = new object();
    protected TService LazyGetRequiredService<TService>(ref TService reference)
    {
        // 比较简单的双重检查锁定模式。
        if (reference == null)
        {
            lock (ServiceProviderLock)
            {
                if (reference == null)
                {
                    reference = ServiceProvider.GetRequiredService<TService>();
                }
            }
        }

        return reference;
    }

    public IClock Clock => LazyGetRequiredService(ref _clock);
    private IClock _clock;

    // Guid 生成器。
    public IGuidGenerator GuidGenerator { get; set; }

    // 日志工厂。
    public ILoggerFactory LoggerFactory => LazyGetRequiredService(ref _loggerFactory);
    private ILoggerFactory _loggerFactory;
    
    // 获取当前租户。
    public ICurrentTenant CurrentTenant => LazyGetRequiredService(ref _currentTenant);
    private ICurrentTenant _currentTenant;

    // 日志组件。
    protected ILogger Logger => _lazyLogger.Value;
    private Lazy<ILogger> _lazyLogger => new Lazy<ILogger>(() => LoggerFactory?.CreateLogger(GetType().FullName) ?? NullLogger.Instance, true);
    
    protected DomainService()
    {
        GuidGenerator = SimpleGuidGenerator.Instance;
    }
}

2.5.2 应用服务 (Application Service)

应用服务的内容比较复杂繁多,会在下一篇文章《[Abp vNext 源码分析] - 6. DDD 的应用层支持 (应用服务)》里面进行详细描述,这里就暂不进行说明。

2.6 仓储 (Repository)

仓储这个东西你们应该都不会陌生,毕竟仓储模式这玩意儿玩了这么久了,我等 Crud 码农必备利器。那么这里的仓储和 DDD 概念里面的仓储有什么异同呢?

2.6.1 背景

咱们首先要明确 DDD 里面为何会引入仓储这个概念,虽然咱们能够经过遍历对象的关联来获取相关的对象,但老是要有一个起点。传统开发人员会构造一个 SQL 查询,将其传递给基础设施层的某个查询服务,而后根据获得的表/行数据重建实体对象,ORM 框架就是这样诞生的。

经过上述手段,开发人员就会试图绕开领域模型,转而直接获取或者操做它们所须要的数据,这样就会致使愈来愈多的领域规则被嵌入到查询代码当中。更为严重的是,开发人员将会直接查询数据库从中提取它们须要的数据,而不是经过聚合的根来获得这些对象。这样就会致使领域逻辑(业务规则)进入查询代码当中,而咱们的实体和值对象最终只是存放数据的容器而已。最后咱们的领域层只是一个空壳,最后使得模型可有可无。

因此咱们须要一种组件,可以经过根遍历查找对象,而且禁止其余方法对聚合内部的任何对象进行访问。而持久化的值对象能够经过遍历某个实体找到,因此值对象是不须要全局搜索的。

而仓储就可以解决上述问题,仓储能够将某种类型的全部对象表示为一个概念上的集合。开发人员只须要调用仓储对外提供的简单接口,就能够重建实体,而具体的查询、插入等技术细节彻底被仓储封装。这样开发人员只须要关注领域模型。

仓储的优势有如下几点:

  • 提供简单的模型,可用来获取持久化对象并管理它们的生命周期。
  • 将应用程序与持久化技术解耦。
  • 利于进行单元测试,例如使用内存数据库替换掉实际访问的数据库。

2.6.2 实现

ABP vNext 为咱们提供了几种类型的仓储 IRepositoryIBasicRepositoryIReadOnlyRepository 等,其实从名字就能够看出来它们具体的职责。首先咱们来看 IReadonly<XXX> 仓储,很明显这种类型的仓储只提供了查询方法,由于它们是只读的。

public interface IReadOnlyBasicRepository<TEntity> : IRepository
    where TEntity : class, IEntity
{
    // 得到全部实体对象。
    List<TEntity> GetList(bool includeDetails = false);

    // 得到全部实体对象。
    Task<List<TEntity>> GetListAsync(bool includeDetails = false, CancellationToken cancellationToken = default);

    // 得到实体对象的数据量。
    long GetCount();

    // 得到实体对象的数据量。
    Task<long> GetCountAsync(CancellationToken cancellationToken = default);
}

public interface IReadOnlyBasicRepository<TEntity, TKey> : IReadOnlyBasicRepository<TEntity>
    where TEntity : class, IEntity<TKey>
{
    // 根据实体的惟一标识重建对象,没有找到对象时抛出 EntityNotFoundException 异常。
    [NotNull]
    TEntity Get(TKey id, bool includeDetails = true);

    [NotNull]
    Task<TEntity> GetAsync(TKey id, bool includeDetails = true, CancellationToken cancellationToken = default);

    //  根据实体的惟一标识重建对象,没有找到对象时返回 null。
    [CanBeNull]
    TEntity Find(TKey id, bool includeDetails = true);

    Task<TEntity> FindAsync(TKey id, bool includeDetails = true, CancellationToken cancellationToken = default);
}

除了只读仓储之外, 还拥有支持插入、更新、删除的仓储定义,它们都存放在 IBasicRepository 当中。在 Volo.Abp.Ddd.Domain 模块里面为咱们提供了仓储类型的抽象实现 RepositoryBase

这个抽象基类里面咱们须要注意几个基础组件:

  1. BasicRepositoryBase 基类里面注入的 ICancellationTokenProvider 对象。
  2. RepositoryBase 基类注入的 IDataFilter 对象。
  3. RepositoryBase 基类注入的 ICurrentTenant 对象。

以上三个对象都不是咱们讲过的组件,这里我先大概说一下它们的做用。

2.6.2.1 ICancellationTokenProvider

CancellationToken 不少人都用过,它的做用是用来取消某个耗时的异步任务。ICancellationTokenProvider 顾名思义就是 CancellationToken 的提供者,那么谁提供呢?

能够看到它有两个定义,一个是从 Http 上下文获取,一个是默认实现,首先来看通常都很简单的默认实现。

public class NullCancellationTokenProvider : ICancellationTokenProvider
{
    public static NullCancellationTokenProvider Instance { get; } = new NullCancellationTokenProvider();

    public CancellationToken Token { get; } = CancellationToken.None;

    private NullCancellationTokenProvider()
    {
        
    }
}

emmm,确实很简单,他直接返回的就是 CancellationToken.None 空值。那咱们如今去看一下 Http 上下文的实现吧:

[Dependency(ReplaceServices = true)]
public class HttpContextCancellationTokenProvider : ICancellationTokenProvider, ITransientDependency
{
    public CancellationToken Token => _httpContextAccessor.HttpContext?.RequestAborted ?? CancellationToken.None;

    private readonly IHttpContextAccessor _httpContextAccessor;

    public HttpContextCancellationTokenProvider(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }
}

从上面能够看到,这个提供者是从 HttpContext 里面拿的 RequestAborted ,这个属性是哪儿来的呢?看它的说明是:

Notifies when the connection underlying this request is aborted and thus request operations should be cancelled.

Soga,这个意思就是若是一个 Http 请求被停止的时候,就会触发的取消标记哦。

那么它放在仓储基类里面干什么呢?确定是要取消掉耗时的查询/持久化异步任务啊,否则一直等么...

2.6.2.2 IDataFilter

这个接口名字跟以前同样,很通俗,数据过滤器,用来过滤查询数据用的。使用过 ABP 框架的同窗确定知道这玩意儿,主要是用来过滤多租户和软删除标记的。

protected virtual TQueryable ApplyDataFilters<TQueryable>(TQueryable query)
    where TQueryable : IQueryable<TEntity>
{
    // 若是实体实现了软删除标记,过滤掉已删除的数据。
    if (typeof(ISoftDelete).IsAssignableFrom(typeof(TEntity)))
    {
        query = (TQueryable)query.WhereIf(DataFilter.IsEnabled<ISoftDelete>(), e => ((ISoftDelete)e).IsDeleted == false);
    }

    // 若是实体实现了多租户标记,根据租户 Id 过滤数据。
    if (typeof(IMultiTenant).IsAssignableFrom(typeof(TEntity)))
    {
        var tenantId = CurrentTenant.Id;
        query = (TQueryable)query.WhereIf(DataFilter.IsEnabled<IMultiTenant>(), e => ((IMultiTenant)e).TenantId == tenantId);
    }

    return query;
}

更加详细的咱们放在后面说明...这里你只须要知道它是用来过滤数据的就好了。

2.6.2.3 ICurrentTenant

英语在学习编程的时候仍是很重要的,这个接口的意思是当前租户,确定这玩意儿就是提供当前登陆用户的租户 Id 咯,在上面的例子里面有使用到。

2.6.3 仓储的注册

不管是 ABP vNext 提供的默认仓储也好,仍是说咱们本身定义的仓储也好,都须要注入到 IoC 容器当中。ABP vNext 为咱们提供了一个仓储注册基类 RepositoryRegisterarBase<TOptions> ,查看这个基类的实现就会发现仓储的具体实现模块都实现了这个基类。

这是由于仓储确定会有多种实现的,例如 EF Core 的仓储实现确定有本身的一套注册机制,因此这里仅提供了一个抽象基类给开发人员。

在基类里面,ABP vNext 首先会注册自定义的仓储类型,由于从仓储的 DDD 定义来看,咱们有些业务可能会须要一些特殊的仓储接口,这个时候就须要自定义仓储了。

public virtual void AddRepositories()
{
    // 遍历自定义仓储。
    foreach (var customRepository in Options.CustomRepositories)
    {
        // 调用注册方法,注册这些仓储。
        Options.Services.AddDefaultRepository(customRepository.Key, customRepository.Value);
    }

    // 是否注册 ABP vNext 生成的默认仓储。
    if (Options.RegisterDefaultRepositories)
    {
        RegisterDefaultRepositories();
    }
}

CustomRepositories 里面的仓储是经过基类 CommonDbContextRegistrationOptions 所定义的 AddRepository() 方法进行添加的。例如单元测试里面就有使用范例:

public override void ConfigureServices(ServiceConfigurationContext context)
{
    var connStr = Guid.NewGuid().ToString();

    Configure<DbConnectionOptions>(options =>
    {
        options.ConnectionStrings.Default = connStr;
    });

    // 添加自定义仓储。
    context.Services.AddMemoryDbContext<TestAppMemoryDbContext>(options =>
    {
        options.AddDefaultRepositories();
        options.AddRepository<City, CityRepository>();
    });
}

接着咱们看自定义仓储是如何注册到 IoC 容器里面的呢?这里调用的 AddDefaultRepository() 方法就是在 Microsoft 文件夹里面定义的注册扩展方法。

public static IServiceCollection AddDefaultRepository(this IServiceCollection services, Type entityType, Type repositoryImplementationType)
{
    // 注册复合主键实体所对应的仓储。
    //IReadOnlyBasicRepository<TEntity>
    var readOnlyBasicRepositoryInterface = typeof(IReadOnlyBasicRepository<>).MakeGenericType(entityType);
    if (readOnlyBasicRepositoryInterface.IsAssignableFrom(repositoryImplementationType))
    {
        services.TryAddTransient(readOnlyBasicRepositoryInterface, repositoryImplementationType);

        //IReadOnlyRepository<TEntity>
        var readOnlyRepositoryInterface = typeof(IReadOnlyRepository<>).MakeGenericType(entityType);
        if (readOnlyRepositoryInterface.IsAssignableFrom(repositoryImplementationType))
        {
            services.TryAddTransient(readOnlyRepositoryInterface, repositoryImplementationType);
        }

        //IBasicRepository<TEntity>
        var basicRepositoryInterface = typeof(IBasicRepository<>).MakeGenericType(entityType);
        if (basicRepositoryInterface.IsAssignableFrom(repositoryImplementationType))
        {
            services.TryAddTransient(basicRepositoryInterface, repositoryImplementationType);

            //IRepository<TEntity>
            var repositoryInterface = typeof(IRepository<>).MakeGenericType(entityType);
            if (repositoryInterface.IsAssignableFrom(repositoryImplementationType))
            {
                services.TryAddTransient(repositoryInterface, repositoryImplementationType);
            }
        }
    }

    // 首先得到实体的主键类型,再进行注册。
    var primaryKeyType = EntityHelper.FindPrimaryKeyType(entityType);
    if (primaryKeyType != null)
    {
        //IReadOnlyBasicRepository<TEntity, TKey>
        var readOnlyBasicRepositoryInterfaceWithPk = typeof(IReadOnlyBasicRepository<,>).MakeGenericType(entityType, primaryKeyType);
        if (readOnlyBasicRepositoryInterfaceWithPk.IsAssignableFrom(repositoryImplementationType))
        {
            services.TryAddTransient(readOnlyBasicRepositoryInterfaceWithPk, repositoryImplementationType);

            //IReadOnlyRepository<TEntity, TKey>
            var readOnlyRepositoryInterfaceWithPk = typeof(IReadOnlyRepository<,>).MakeGenericType(entityType, primaryKeyType);
            if (readOnlyRepositoryInterfaceWithPk.IsAssignableFrom(repositoryImplementationType))
            {
                services.TryAddTransient(readOnlyRepositoryInterfaceWithPk, repositoryImplementationType);
            }

            //IBasicRepository<TEntity, TKey>
            var basicRepositoryInterfaceWithPk = typeof(IBasicRepository<,>).MakeGenericType(entityType, primaryKeyType);
            if (basicRepositoryInterfaceWithPk.IsAssignableFrom(repositoryImplementationType))
            {
                services.TryAddTransient(basicRepositoryInterfaceWithPk, repositoryImplementationType);

                //IRepository<TEntity, TKey>
                var repositoryInterfaceWithPk = typeof(IRepository<,>).MakeGenericType(entityType, primaryKeyType);
                if (repositoryInterfaceWithPk.IsAssignableFrom(repositoryImplementationType))
                {
                    services.TryAddTransient(repositoryInterfaceWithPk, repositoryImplementationType);
                }
            }
        }
    }

    return services;
}

上面代码没什么好说的,只是根据不一样的类型来进行不一样的注册而已。

以上是注册咱们自定义的仓储类型,只要开发人员调用过 AddDefaultRepositories() 方法,那么 ABP vNext 会为每一个不一样的实体注册响应的默认仓库。

public ICommonDbContextRegistrationOptionsBuilder AddDefaultRepositories(bool includeAllEntities = false)
{
    // 能够看到将参数设置为 true 了。
    RegisterDefaultRepositories = true;
    IncludeAllEntitiesForDefaultRepositories = includeAllEntities;

    return this;
}

默认仓库仅包含基础仓储所定义的增删改查等方法,开发人员只须要注入相应的接口就可以直接使用。既然要为每一个实体类型注入对应的默认仓储,确定就须要知道当前项目有多少个实体,并得到它们的类型定义。

这里咱们基类仅仅是调用抽象方法 GetEntityTypes() ,而后根据具体实现返回的类型定义来注册默认仓储。

protected virtual void RegisterDefaultRepositories()
{
    foreach (var entityType in GetEntityTypes(Options.OriginalDbContextType))
    {
        // 判断该实体类型是否须要注册默认仓储。
        if (!ShouldRegisterDefaultRepositoryFor(entityType))
        {
            continue;
        }

        // 为实体对象注册相应的默认仓储,这里仍然调用以前的扩展方法进行注册。
        RegisterDefaultRepository(entityType);
    }
}

找到 EF Core 定义的仓储注册器,就可以看到他是经过遍历 DbContext 里面的属性来获取全部实体类型定义的。

public static IEnumerable<Type> GetEntityTypes(Type dbContextType)
{
    return
        from property in dbContextType.GetTypeInfo().GetProperties(BindingFlags.Public | BindingFlags.Instance)
        where
            ReflectionHelper.IsAssignableToGenericType(property.PropertyType, typeof(DbSet<>)) &&
            typeof(IEntity).IsAssignableFrom(property.PropertyType.GenericTypeArguments[0])
        select property.PropertyType.GenericTypeArguments[0];
}

最后的最后,这个注册器在何时被调用的呢?注册器通常是在项目的基础设施模块当中进行调用,这里以单元测试的代码为例,它是使用的 EF Core 做为持久层的基础设施。

[DependsOn(typeof(AbpEntityFrameworkCoreModule))]
public class AbpEfCoreTestSecondContextModule : AbpModule
{
    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        // 注意这里。
        context.Services.AddAbpDbContext<SecondDbContext>(options =>
        {
            options.AddDefaultRepositories();
        });

        // 注意这里。
        context.Services.AddAbpDbContext<ThirdDbContext.ThirdDbContext>(options =>
        {
            options.AddDefaultRepositories<IThirdDbContext>();
        });
    }
}

跳转到 ABP vNext 提供的 EF Core模块,找到 AddAbpDbContext() 方法当中,发现了仓储注册器。

public static class AbpEfCoreServiceCollectionExtensions
{
    public static IServiceCollection AddAbpDbContext<TDbContext>(
        this IServiceCollection services, 
        Action<IAbpDbContextRegistrationOptionsBuilder> optionsBuilder = null)
        where TDbContext : AbpDbContext<TDbContext>
    {
        services.AddMemoryCache();

        var options = new AbpDbContextRegistrationOptions(typeof(TDbContext), services);
        optionsBuilder?.Invoke(options);

        services.TryAddTransient(DbContextOptionsFactory.Create<TDbContext>);

        foreach (var dbContextType in options.ReplacedDbContextTypes)
        {
            services.Replace(ServiceDescriptor.Transient(dbContextType, typeof(TDbContext)));
        }

        // 在这里。
        new EfCoreRepositoryRegistrar(options).AddRepositories();

        return services;
    }
}

2.7 领域事件

在 ABP vNext 中,除了本地事件总线之外,还为咱们提供了基于 Rabbit MQ 的分布式事件总线。关于事件总线的内容,这里就再也不详细赘述,后面会有专门的文章讲解事件总线的相关知识。

在这里,主要提一下什么是领域事件。其实领域事件与普通的事件并没什么本质上的不一样,只是它们触发的地方和携带的参数有点特殊罢了。而且按照聚合的特性来讲,其实聚合与聚合之间的通信,主要是经过领域事件来实现的。

这里的领域事件都是针对于实体产生变动时须要被触发的事件,例如咱们有一个学生实体,在它被修改以后,ABP vNext 框架就会触发一个实体更新事件。

触发领域事件这些动做都被封装在 EntityChangeEventHelper 里面,以刚才的例子来讲,咱们能够看到它会触发如下代码:

public virtual async Task TriggerEntityUpdatedEventOnUowCompletedAsync(object entity)
{
    // 触发本地事件总线。
    await TriggerEventWithEntity(
        LocalEventBus,
        typeof(EntityUpdatedEventData<>),
        entity,
        false
    );

    var eto = EntityToEtoMapper.Map(entity);
    if (eto != null)
    {
        // 触发分布式事件总线。
        await TriggerEventWithEntity(
            DistributedEventBus,
            typeof(EntityUpdatedEto<>),
            eto,
            false
        );
    }
}

关于领域事件其余的细节就再也不描述,若是你们想要更加全面的了解,请直接阅读 ABP vNext 的相关源码。

3、总结

本篇文章更多的注重 DDD 理论,关于 ABP vNext 的技术实现细节并未体如今当前模块,后续我会在其余章节注重描述关于上述 DDD 概念的技术实现。

4、点击我跳转到文章目录

相关文章
相关标签/搜索