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

一、简要介绍

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

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

PS:

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

二、源码分析

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

2.1 实体 (Entity)

2.1.1 基本概念

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

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

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

标识是否重要则完全取决于它是否有用,例如有个演唱会订票程序,你可以将座位与观众都当作一个实体处理。那么在分配座位时,每个座位肯定都会有一个唯一的座位号(唯一标识),可也能拥有其他描述属性(是否是 VIP 座位、价格等...)。

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

另一种方式就是入场卷方式,门票上没有座位号,你想坐哪儿就坐哪儿。这个时候座位号就不需要与门票建立关联,在这种情况下座位就不是一个实体,所以不需要唯一标识。

* 上述例子与描述改编自 《领域驱动设计:软件核心复杂性应对之道》的 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 的相关源码。

三、总结

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

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

posted @ 2019-07-20 09:39  MyZony  阅读(8776)  评论(9编辑  收藏  举报