基於ABP落地領域驅動設計-03.倉儲和規約最佳實踐和原則

iEricLee發表於2021-06-25

圍繞DDDABP Framework兩個核心技術,後面還會陸續釋出核心構件實現綜合案例實現系列文章,敬請關注!
ABP Framework 研習社(QQ群:726299208)
ABP Framework 學習及實施DDD經驗分享;示例原始碼、電子書共享,歡迎加入!

系列文章

倉儲

倉儲(介面)是一組集合的介面,被領域層和應用層用來訪問資料持久化系統(資料庫),以讀寫業務物件,業務物件通常是聚合。

倉儲的通用原則

  • 在領域層中定義倉儲介面,在基礎層中實現倉儲介面(比如:EntityFrameworkCore專案或MongoDB專案)
  • 倉儲不包含業務邏輯,專注資料處理。
  • 倉儲介面應該保持 資料提供程式/ORM 獨立性。舉個例子,倉儲介面定義的方法不能返回 DbSet 物件,因為該物件由 EF Core 提供,如果使用 MongoDB 資料庫則無法實現該介面。
  • 為聚合根建立對應倉儲,而不是所有實體。因為子集合實體(聚合)應該通過聚合根訪問。

倉儲中不包含領域邏輯

雖然這個規則一開始看起來很好理解,但在實際開發過程中,很容易在不經意間將業務邏輯放到倉儲中。

示例:從倉儲中獲取 inactive 狀態的 Issue

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Domain.Repositories;

namespace IssueTracking.Issues
{
  public interface IIssueRepository:IRepository<Issue,Guid>
  {
    Task<List<Issue>> GetInActiveIssuesAsync();
  }
}

IIssueRepository 繼承 IRepository<Issue,Guid> 介面,新增了 GetInActiveIssuesAsync() 方法。與之對應的聚合根型別是 Issue 類:

public class Issue:AggregateRoot<Guid>,IHasCreationTime
{
  public bool IsClosed{get;private set;}
  public Guid? AssignedUserId{get;private set;}
  public DateTime CreationTime{get;private set;}
  public DateTime? LastCommentTime{get;private set;}
}

規則要求我們:倉儲不應該知道業務規則,那麼問題來了:什麼是 inactive Issue(未啟用的問題)?這是業務規則

為了更好地理解,我們繼續看看介面方法的實現:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using IssueTracking.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Volo.Abp.Domain.Repositories.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;

namespace IssumeTracking.Issues
{
  public class EfCoreIssueRepository:
    EfCoreRepository<IssueTrackingDbContext,Issue,Guid>,
    IIssueRepository
  {
    public EfCoreIssueRepository(
      IDbContextProvider<IssueTrackingDbContext> dbContextProvider
    ):base(dbContextProvider)
    {}
    public async Task<List<Issue>> GetInActiveIssueAsynce()
    {
      var daysAgo30=DateTime.Now.Subtract(TimeSpan.FromDays(30));

      var dbSet =await GetDbSetAsync();
      return await dbSet.Where(i=>
        //開啟狀態
        !i.IsClosed &&
        //無分配人
        i.AssingedUserId ==null &&
        //建立時間在30天前
        i.CreationTime < daysAgo30 &&
        //沒有評論或最後一次評論在30天前
        (i.LastCommentTime == null || i.LastCommentTime < daysAgo30)
      ).ToListAsync();
    }
  }
}

GetInActiveIssueAsynce 實現方法中,對於未啟用的Issue 這條業務規則,需要滿足條件:開啟狀態、未分配給任何人、建立超過30天、最近30天沒有評論。

如果我們將業務規則隱含在倉儲中,當我們需要重複使用這個業務邏輯時,問題就出現了。

舉個例子,在 Issue 實體中希望新增一個方法 bool IsInActive(),用於檢測 Issue 是否未啟用狀態。

看看如何實現:

public class Issue:AggregateRoot<Guid>,IHasCreationTime
{
  public bool IsClosed {get;private set;}
  public Guid? AssignedUserId{get;private set;}
  public DateTime CreationTiem{get;private set;}
  public DateTime? LastCommentTime{get;private set;}
  //...
  public bool IsInActive(){
    var daysAgo30=DateTime.Now.Subtract(TimeSpan.FromDays(30));
    return
      //開啟狀態
      !IsClosed &&
      //無分配人
      AssignedUserId ==null &&
      //建立時間在30天前
      CreationTime < daysAgo30 &&
      //無評論或最後一次評論在30天前
      (LastCommentTime == null || LastCommentTime < daysAgo30 );
  }
}

我們不得不復制、貼上、修改程式碼。如果對未啟用的Issue 規則改變了怎麼辦?我們應該記得同時更新這兩個地方。這是業務邏輯重複,程式碼的壞味道,是相當危險的。

這個問題的一個很好的解決方案就是規約

規約

規約是一個命名的、可重用的可組合的和可測試的類,用於根據業務規則過濾領域物件

ABP框架提供了必要的基礎設施,以輕鬆建立規約並在你的應用程式程式碼中使用。讓我們把 inactive Issue 非活動問題業務規則實現為一個規約類

using System;
using System.Linq.Expressions;
using Volo.Abp.Specifications;

namespace IssueTracking.Issues
{
  public class InActiveIssueSpecification:Specification<Issue>
  {
    public override Expression<Func<Issue,bool>> ToExpression()
    {
      var daysAgo30=DateTime.Now.Subtract(TimeSpan.FromDays(30));
      return i =>
        //開啟狀態
        !i.IsClosed &&
        //無分配人
        i.AssingedUserId ==null &&
        //建立時間超過30天
        i.CreationTime < daysAgo30 &&
        //沒有評論或最後評論超過30天
        (i.LastCommentTime == null || i.LastCommentTime < daysAgo30)
    }
  }
}

Specification<T> 基類可以幫助我們簡單地建立規約類,我們可以將倉儲中的表示式移到規約中。

現在,可以在 Issue 實體和 EfCoreIssueRepository 類中使用 InActiveIssueSpecification 規約。

在實體中使用規約

Specification類提供了一個IsSatisfiedBy方法,如果給定的物件(實體)滿足該規範,則返回true。我們可以重新編寫Issue.IsInActive方法,如下所示:

public class Issue:AggregateRoot<Guid>,IHasCreationTime
{
  public bool IsClosed{get;private set;}
  public Guid? AssignedUserId{get;private set;}
  public DateTime CreationTiem{get;private set;}
  public DateTime? LastCommentTime{get;private set;}
  //...
  public bool IsInActive()
  {
    return new InActiveIssueSpecification().IsSatisfiedBy(this);
  }
}

建立一個 InActiveIssueSpecification 新例項,使用其 IsSatisfiedBy 方法,進行規約驗證。

在倉儲中使用規約

首先,修改倉儲介面:

public interface IIssueRepository:IRepository<Issue,Guid>
{
  Task<List<Issue>> GetIssuesAsync(ISpecification<Issue> spec);
}

將方法名 GetInActiveIssuesAsync 改為 GetIssuesAsync (命名更加簡潔),接收一個規約物件引數。將規約判斷的程式碼邏輯從倉儲中移出之後,我們不再需要定義不同的方法來獲取不同條件下的Issue,比如:GetAssignedIssues(...) 獲取已有分配人的問題列表,GetLockedIssues(...) 獲取已鎖定問題列表 等。

修改倉儲的實現:

public class EfCoreIssueRepository:
  EfCoreRepository<IssueTrackingDbContext,Issue,Guid>,
  IIssueRepository
{
  public EfCoreIssueRepository(
    IDbContextProvider<IssueTrackingDbContext> dbContextProvider
  ):base(dbContextProvider)
  {}
  public async Task<List<Issue>> GetIssuesAsync(ISpecification<Issue> spec)
  {
    var dbSet = await GetDbSetAsync();
    return await dbSet
      .Where(spec.ToExpresion())
      .ToListAsync();
  }
}

ToExpression()方法返回一個表示式,可以直接作為 Where 方法的引數傳遞,實現實體過濾。

最後,我們將規約例項,傳遞給 GetIssuesAsync 方法:

public class IssueAppServie : ApplciationService,IIssueAppService
{
  private readonly IIssueRepository _issueRepository;
  public IssueAppService (IIssueRepository issueRepository)
  {
    _issueRepository = issueRepository;
  }
  public async Task DoItAsync()
  {
    var issues = await _issueRepository.GetIssuesAsync(
      new InActiveIssueSpecification();
    );
  }
}

預設倉儲

實際上,你不需要建立自定義倉儲就能使用規約。標準的IRepository 介面已經擴充套件 IQueryable 介面,所以你可以直接使用標準的LINQ擴充套件方法。(非常帥氣!!!)

public class IssueAppServie : ApplciationService,IIssueAppService
{
  private readonly IRepository<Issue,Guid> _issueRepository;
  public IssueAppService (IRepository<Issue,Guid> issueRepository)
  {
    _issueRepository = issueRepository;
  }
  public async Task DoItAsync()
  {
    var queryable = await _issueRepository.GetQueryableAsync();
    var issues = AsyncExecuter.ToListAsync(
      queryable.Where(new InActiveIssueSpecification())
    );
  }
}

AsyncExecuter是ABP框架提供的一個工具類,用於使用非同步LINQ擴充套件方法(比如這裡的ToListAsync),而不依賴於EF Core NuGet 包

組合規約

規範的一個強大的地方是它們是可以組合使用的。假設我們有另一個規約,當問題 Issue 處於指定里程碑中時返回true

public class MilestoneSpecification : Specification<Issue>
{
  public Guid MilestoneId{get;}
  public MilestoneSpecification (Guid milestoneId)
  {
    MilestoneId = milestoneId;
  }
  public override Expression<Func<Issue,bool>> ToExpression()
  {
    return i => i.MilestoneId == MilestoneId;
  }
}

我們新定義了一個新的引數化規約,和前面定義 InActiveIssueSpecification 不同。那麼如何組合兩個規約,獲取指定里程碑中未啟用的 Issue(問題)呢?

public class IssueAppServie : ApplciationService,IIssueAppService
{
  private readonly IRepository<Issue,Guid> _issueRepository;
  public IssueAppService (IRepository<Issue,Guid> issueRepository)
  {
    _issueRepository = issueRepository;
  }
  public async Task DoItAsync(Guid milesoneId)
  {
    var queryable = await _issueRepository.GetQueryableAsync();
    var issues = AsyncExecuter.ToListAsync(
      queryable.Where(new InActiveIssueSpecification()
                          .Add(new MilestoneSpecification(milestoneId))
                          .ToExpression()
                )
    );
  }
}

示例中使用 Add 擴充套件方法組合規約,還有更多的擴充套件方法,比如:Or(...) AndNot(...)

學習幫助

圍繞DDDABP Framework兩個核心技術,後面還會陸續釋出核心構件實現綜合案例實現系列文章,敬請關注!

ABP Framework 研習社(QQ群:726299208)
專注 ABP Framework 學習及DDD實施經驗分享;示例原始碼、電子書共享,歡迎加入!
image

相關文章