基於ABP實現DDD--領域服務、應用服務和DTO實踐

2022-07-21 12:02:22

  什麼是領域服務呢?領域服務就是領域物件本身的服務,通常是通過多個聚合以實現單個聚合無法處理的邏輯。

一.領域服務實踐

接下來將聚合根Issue中的AssignToAsync()方法[將問題分配給使用者],剝離到領域服務當中。如下:

// ABP當中的領域服務類通常都是以Manager結尾的
public class IssueManager : DomainService
{
    private readonly IRepository<Issue,Guid> _issueRepository;
    
    // 在建構函式中注入需要的倉儲
    public IssueManager(IRepository<Issue,Guid> issueRepository)
    {
        _issueRepository = issueRepository;
    }

    public async Task AssignToAsync(Issue issue, AppUser user)
    {
        // 通過倉儲獲取分配給該使用者的,並且沒有關閉的Issue的數量
        var openIssueCount = await _issueRepository.CountAsync(i => i.AssignedUserId == user.id && !i.IsClosed);
        
        // 如果超過3個,那麼丟擲異常
        if (openIssueCount > 3)
        {
            throw new BusinessException("IssueTracking:ConcurrentOpenIssueLimit");
        }

        issue.AssignedUserId = user.Id;
    }
}

需要說明的是通常不需要為領域服務IssueManager在建立一個介面IIssueManager。

二.應用服務實踐

應用服務的輸入和輸出通常都是DTO,其中的難點是區分領域邏輯和應用邏輯,即哪些服務放在領域層實現,哪些服務放在應用層來實現。

namespace IssueTracking.Issues
{
    public class IssueAppService :ApplicationService.IIssueAppService
    {
        private readonly IssueManager _issueManager;
        private readonly IRepository<Issue,Guid> _issueRepository;
        private readonly IRepository<AppUser,Guid> _userRepository;
        public IssueAppService(
            IssueManager issueManager,
            IRepository<Issue,Guid> issueRepository,
            IRepository<AppUser,Guid> userRepository
        )
        {
            _issueManager=issueManager;
            _issueRepository=issueRepository;
            _userRepository=userRepository;
        }
        
        [Authorize]
        public async Task AssignAsync(IssueAssignDto input)
        {
            var issue=await _issueRepository.GetAsync(input.IssueId);
            var user=await _userRepository.GetAsync(inpu.UserId);
            await _issueManager.AssignToAsync(issue,user);
            await _issueRepository.UpdateAsync(issue);
        }
    }
}

在上述程式碼中,為什麼最後執行_issueRepository.UpdateAsync(issue)呢?其中有2層含義,第1層是Issue通過_issueManager.AssignToAsync(issue,user)發生了變化,需要進行更新操作(從下圖可知Issue聚合根中包含AssignedUserId欄位);第2層是EF Core中有狀態變更跟蹤,Update並不是必須的,但是還是建議顯式呼叫Update,用來適配其它的資料庫提供程式。

三.資料傳輸物件DTO實踐

DTO的本質是在應用層和展示層傳遞狀態資料,通常應用層的輸入和輸出都是DTO,這樣做的最大好處就是不暴露實體的結構設計。

1.輸入DTO實踐

(1)不要重用輸入DTO
不使用的屬性不要定義在輸入DTO中;不要重用輸入DTO有2種方式:一種方式是為每個應用服務方法定義特定的輸入DTO,另一種方式是不要使用DTO繼承。下面是錯誤的輸入DTO實踐,理由詳見註釋:

public interface IUserAppService : IApplicationService
{
    Task CreateAsync(UserDto input); //Id在該方法中沒有用到
    Task UpdateAsync(UserDto input); // Password在該方法中沒有用到
    Task ChangePasswordAsync(UserDto input); // CreationTime在該方法中沒有用到
}
public class UserDto
{
    public Guid Id { get; set; }
    public string UserName { get; set; }
    public string Email { get; set; }
    public string Password { get; set; }
    public DateTime CreateTime { get; set; }
}

下面是正確的輸入DTO實踐:

public interface IUserAppService : IApplicationService
{
    Task CreateAsync(UserCreationDto input);
    Task UpdateAsync(UserUpdateDto input);
    Task ChangePasswordAsync(UserChangePasswordDto input);
}
public class UserCreationDto
{
    public string UserName { get; set; }
    public string Email { get; set; }
    public string Password { get; set; }
}
public class UserUpdateDto
{
    public Guid Id { get; set; }
    public string UserName { get; set; }
    public string Email { get; set; }
}
public class UserChangePasswordDto
{
    public Guid Id { get; set; }
    public string Password { get; set; }
}

(2)輸入DTO中的驗證邏輯
主要是在DTO內部通過資料註解特性、FluentValidation,或者實現IValidatableObject介面等方式來執行簡單的驗證。需要注意的是不要在DTO中執行領域驗證,比如檢測使用者名稱是否唯一的驗證等。下面在輸入DTO中使用資料註解特性:

namespace IssueTracking.Users
{
    public class UserCreationDto
    {
        [Required]
        [StringLength(UserConsts.MaxUserNameLength)]
        public string UserName {get;set;}
        [Required]
        [EmailAddress]
        [StringLength(UserConsts.MaxEmailLength)]
        public string Email{get;set;}
        [Required]
        [StringLength(UserConsts.MaxEmailLength,MinimumLength=UserConsts.MinPasswordLength)]
        public string Password{get;set;}
    }
}

ABP會自動驗證輸入DTO中的註解,如果驗證失敗,那麼丟擲AbpValidationException異常,並且返回400狀態碼。個人建議使用FluentValidation方式進行驗證,而不是宣告式的資料註解,這樣做的優點是將驗證規則和DTO類徹底分離開

2.輸出DTO實踐

輸出DTO最佳實踐:主要是儘可能的複用輸出DTO,但是切記不能把輸入DTO作為輸出DTO;輸出DTO可以包含更多的屬性;Create和Update方法返回DTO。下面是錯誤的輸出DTO實踐:

public interface IUserAppService:IApplicationService
{
    UserDto Get(Guid id);
    List<UserNameAndEmailDto> GetUserNameAndEmail(Guid id);
    List<string> GetRoles(Guid id);
    List<UserListDto> GetList();
    UserCreateResultDto Create(UserCreationDto input);
    UserUpdateResultDto Update(UserUpdateDto input);
}

下面是正確的輸出DTO實踐:

public interface IUserAppService:IApplicationService
{
    UserDto Get(Guid id);
    List<UserDto> GetList();
    UserDto Create(UserCreationDto input);
    UserDto Update(UserUpdateDto input);
}
public class UserDto
{
    public Guid Id{get;set;}
    public string UserName{get;set;}
    public string Email{get;set;}
    public DateTiem CreationTime{get;set;}
    public List<string> Roles{get;set;}
}

說明:刪除GetUserNameAndEmail()和GetRoles()方法,因為它們與Get()方法重複了,即它們的功能都可以通過Get()方法來實現。

3.物件對映工具

  為什麼需要物件對映工具呢?由於實體和DTO具有相同或者相似的屬性,如果手工處理實體和DTO間的轉換,那麼效率是非常低的,因此需要物件對映工具高效的完成實體和DTO間的轉換。
  在ABP中使用的物件對映框架是AutoMapper,官方的建議是:僅對實體到輸出DTO做自動物件對映,不建議輸入DTO到實體做自動物件對映。因為DTO是實體的部分或者全部欄位,自己推測前者是比較確定的,而由於複雜的業務規則讓後者的對映充滿了不確定性。具體為什麼不使用輸入DTO到實體做自動物件對映的原因參考[1]。
自動物件對映在應用服務層中實現,該類需要繼承自Profile類:

雖然官方不建議輸入DTO到實體做自動物件對映,但是在通常的實踐中還是較多使用CreateOrUpdateXXXDto到實體XXX的自動物件對映:

關於FluentValidation和AutoMapper這2個庫就不單獨在這裡展開講了,後面單獨文章進行講解操作和原理。

參考文獻:
[1]基於ABP Framework實現領域驅動設計:https://url39.ctfile.com/f/2501739-616007877-f3e258?p=2096 (存取密碼: 2096)
[2]FluentValidation官方檔案:https://docs.fluentvalidation.net/en/latest/
[3]FluentValidation GitHub:https://github.com/FluentValidation/FluentValidation/blob/main/docs/index.rst
[4]AutoMapper官方檔案:http://automapper.org/
[5]AutoMapper GitHub:https://github.com/AutoMapper/AutoMapper