什麼是領域服務呢?領域服務就是領域物件本身的服務,通常是通過多個聚合以實現單個聚合無法處理的邏輯。
接下來將聚合根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,這樣做的最大好處就是不暴露實體的結構設計。
(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類徹底分離開。
輸出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()方法來實現。
為什麼需要物件對映工具呢?由於實體和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