[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 概念的技術實現。

四、點擊我跳轉到文章目錄


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM