[Abp vNext 源碼分析] - 5. DDD 的領域層支持(倉儲、實體、值對象)

来源:https://www.cnblogs.com/myzony/archive/2019/07/20/11216688.html
-Advertisement-
Play Games

一、簡要介紹 ABP vNext 框架本身就是圍繞著 DDD 理念進行設計的,所以在 DDD 裡面我們能夠見到的實體、倉儲、值對象、領域服務,ABP vNext 框架都為我們進行了實現,這些基礎設施都存放在 Volo.Abp.Ddd.Domain 項目當中。 本篇文章將會側重於理論講解,但也只是一個 ...


一、簡要介紹

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

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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 15.迭代器:工具 1.可迭代對象: ​ 官方聲明,只要具有_\_iter\_\_方法的就是可迭代對象 list,dict,str,set,tuple 可迭代對象,使用靈活 2.迭代器: 官方聲明:只要具有\_\_iter\_\_方法_\_next\_\_方法就是迭代器 將可迭代對象,轉換成迭代器 ...
  • EF是微軟推出的官方ORM框架,預設防註入可以配合LINQ一起使用,更方便開發人員。 首先通過SQLSERVER現在有的資料庫類生產EF 右鍵-》添加-》新建項,選擇AOD.NET實體數據模型,來自資料庫的Code FIrst 完成添加後會生成多個文件,並且在你的項目的配置文件中有資料庫的鏈接字元串 ...
  • 目錄 1.開發工具 2.GitLab伺服器搭建 3.新建webapi 4.Dockerfile配置 5.配置docker compose.yml 6.配置.gitlab ci.yml 7.在GitLab上添加一個新項目 8.GitLib Runner安裝 9.提交代碼到gitlab 10.在GitL ...
  • 如下圖所示,新建的類不能直接使用,會顯示報錯,檢查命名空間什麼的,未果 通過百度搜索,發現這麼一篇文章:https://blog.csdn.net/younghaiqing/article/details/71627959 不錯,將類文件的屬性中的“生成操作”里的“內容”改成“編譯”,保存後就能解決 ...
  • Windows服務是非常強大的應用程式,可用於在backgorund中執行許多不同類型的任務。他們可以在不需要任何用戶登錄的情況下啟動,並且可以使用除登錄用戶之外的其他用戶帳戶運行。但是,如果通過遵循常規服務開發步驟開發Windows服務應用程式,即使在開發環境中也難以調試。 本文提出了一種不使用任 ...
  • .NET Core CSharp 初級篇 1 4 本節內容為this、索引器、靜態、常量以及只讀 簡介 在之前的課程中,我們談論過了靜態函數和欄位的一小部分知識,本節內容中,我們將詳細的講解關於對象操作的例子,以及更加深入的解釋面向對象。 常量 常量,顧名思義,就是一直為同一個值的變數,並且值不可以 ...
  • 1、將啟動圖片保存到Drawable文件夾下 2、在Drawable文件夾下創建splashscreen.xml 3、在android項目的 Resources 文件夾下添加“Values”文件夾,創建 Styles.xml,設置其創建內容如下: 4、在Android項目下創建一個SplashScr ...
  • 剛纔對數據進行批量更新時,收到一條錯誤信息:The JSON request was too large to be deserialized。 查找資料,原來json對象數量有限制,得需要在web.config時行配置參數: <appSettings> <add key="aspnet:MaxJs ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...