上一篇:《 "DDD 領域驅動設計-如何 DDD?" 》 開源地址: "https://github.com/yuezhongxin/CNBlogs.Apply.Sample" (代碼已更新) 閱讀目錄: JsPermissionApply 生命周期 改進 JsPermissionApply 實體 ...
上一篇:《DDD 領域驅動設計-如何 DDD?》
開源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample(代碼已更新)
閱讀目錄:
- JsPermissionApply 生命周期
- 改進 JsPermissionApply 實體
- 重命名 UserAuthenticationService
- 改進 JsPermissionApplyRepository
- 改進領域單元測試
如何完善領域模型?指的是完善 JS 許可權申請領域模型,也就是 JsPermissionApply Domain Model。
在上篇博文中,關於 JsPermissionApply 領域模型的設計,只是一個剛出生的“嬰兒”,還不是很成熟,並且很多細緻的業務並沒有考慮到,本篇將對 JsPermissionApply 領域模型進行完善,如何完善呢?望著解決方案中的項目和代碼,好像又束手無策,這時候如果沒有一點思考,而是直接編寫代碼,到最後你會發現 DDD 又變成了腳本式開發,所以,我們在做領域模型開發的時候,需要一個切入點,把更多的精力放在業務上,而不是實現的代碼上,那這個切入點是什麼呢?沒錯,就是上篇博文中的“業務流程圖”,又簡單完善了下:
1. JsPermissionApply 生命周期
在完善 JsPermissionApply 領域模型之前,我們需要先探討下 JsPermissionApply 實體的生命周期,這個在接下來完善的時候會非常重要,能影響 JsPermissionApply 實體生命周期的唯一因素,就是改變其自身的狀態,從上面的業務流程圖中,我們就可以看到改變狀態的地方:“申請狀態為待審核”、“申請狀態改為通過”、“申請狀態改為未通過”、“申請狀態改為鎖定”,能改變實體狀態的行為都是業務行為,這個在領域模型設計的時候,要重點關註。
用戶申請 JS 許可權的最終目的是開通 JS 許可權,對於 JsPermissionApply 實體而言,就是自身狀態為“通過”,所以,我們可以認為,當 JsPermissionApply 實體狀態為“通過”的時候,那麼 JsPermissionApply 實體的生命周期就結束了,JsPermissionApply 生命周期開始的時候,就是創建 JsPermissionApply 實體對象的時候,也就是實體狀態為“待審核”的時候。
好,上面的分析聽起來很有道理,感覺應該沒什麼問題,但在實現 JsPermissionApplyRepository 的時候,就會發現有很多問題(後面會說到),JsPermissionApply 的關鍵字是 Apply(申請),對於一個申請來說,生命周期的結束就是其經過了審核,不論是通過還是不通過,鎖定還是不鎖定,這個申請的生命周期就結束了,再次申請就是另一個 JsPermissionApply 實體對象了,對於實體生命周期有效期內,其實體必須是唯一性的。
導致上面兩種分析的不同,主要是關註點不同,第一種以用戶為中心,第二種以申請為中心,以用戶為中心的分析方式,在我們平常的開發過程中會經常遇到,因為我們開發的系統基本上都是給人用的,所以很多業務都是圍繞用戶進行展開,好像沒有什麼不對,但如果這樣進行分析設計,那麼每個系統的核心域都是用戶了,領域模型也變成了用戶領域模型,所以,我們在分析業務系統的時候,最好進行細分,並把用戶的因素隔離開,最後把核心和非核心進行區分開。
2. 改進 JsPermissionApply 實體
先看下之前 JsPermissionApply 實體的部分代碼:
namespace CNBlogs.Apply.Domain
{
public class JsPermissionApply : IAggregateRoot
{
private IEventBus eventBus;
...
public void Process(string replyContent, Status status)
{
this.ReplyContent = replyContent;
this.Status = status;
this.ApprovedTime = DateTime.Now;
eventBus = IocContainer.Default.Resolve<IEventBus>();
if (this.Status == Status.Pass)
{
eventBus.Publish(new JsPermissionOpenedEvent() { UserId = this.UserId });
eventBus.Publish(new MessageSentEvent() { Title = "系統通知", Content = "審核通過", RecipientId = this.UserId });
}
else if (this.Status == Status.Deny)
{
eventBus.Publish(new MessageSentEvent() { Title = "系統通知", Content = "審核不通過", RecipientId = this.UserId });
}
}
}
}
Process 的設計會讓領域專家看不懂,為什麼?看下對應的單元測試:
[Fact]
public async Task ProcessApply()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetByUserId(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);
jsPermissionApply.Process("審核通過", Status.Pass);
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}
Process 是啥?如果領域專家不是開發人員,通過一個申請,他會認為應該有一個直接通過申請的操作,而不是調用一個不知道幹啥的 Process 方法,然後再傳幾個不知道的參數,在 IDDD 書中,代碼也是和領域專家交流的通用語言之一,所以,開發人員編寫的代碼需要讓領域專家看懂,至少代碼要表達一個最直接的業務操作。
所以,對於申請的處理,通過就是通過,不通過就是不通過,要用代碼表達的簡單粗暴。
改進代碼:
namespace CNBlogs.Apply.Domain
{
public class JsPermissionApply : IAggregateRoot
{
private IEventBus eventBus;
...
public async Task Pass()
{
this.Status = Status.Pass;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = "恭喜您!您的JS許可權申請已通過審批。";
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new JsPermissionOpenedEvent() { UserId = this.UserId });
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS許可權申請已批准", Content = this.ReplyContent, RecipientId = this.UserId });
}
public async Task Deny(string replyContent)
{
this.Status = Status.Deny;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = $"抱歉!您的JS許可權申請沒有被批准,{(string.IsNullOrEmpty(replyContent) ? "" : $"具體原因:{replyContent}<br/>")}麻煩您重新填寫申請理由。";
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS許可權申請未通過審批", Content = this.ReplyContent, RecipientId = this.UserId });
}
public async Task Lock()
{
this.Status = Status.Lock;
this.ApprovedTime = DateTime.Now;
this.ReplyContent = "抱歉!您的JS許可權申請沒有被批准,並且申請已被鎖定,具體請聯繫[email protected]。";
eventBus = IocContainer.Default.Resolve<IEventBus>();
await eventBus.Publish(new MessageSentEvent() { Title = "您的JS許可權申請已被鎖定", Content = this.ReplyContent, RecipientId = this.UserId });
}
}
}
這樣改進還有一個好處,就是改變 JsPermissionApply 狀態會變的更加明瞭,也更加受保護,什麼意思?比如之前的 Process 的方法,我們可以通過參數任意改變 JsPermissionApply 的狀態,這是不被允許的,現在我們只能通過三個操作改變對應的三種狀態。
JsPermissionApply 實體改變了,對應的單元測試也要進行更新(後面講到)。
3. 重命名 UserAuthenticationService
UserAuthenticationService 是領域服務,一看到這個命名,會認為這是關於用戶驗證的服務,我們再看上面的流程圖,會發現有一個“驗證用戶信息”操作,但前面還有一個“驗證申請狀態”操作,而在之前的設計實現中,這兩個操作都是放在 UserAuthenticationService 中的,如下:
namespace CNBlogs.Apply.Domain.DomainServices
{
public class UserAuthenticationService : IUserAuthenticationService
{
private IJsPermissionApplyRepository _jsPermissionApplyRepository;
public UserAuthenticationService(IJsPermissionApplyRepository jsPermissionApplyRepository)
{
_jsPermissionApplyRepository = jsPermissionApplyRepository;
}
public async Task<string> Verfiy(int userId)
{
if (!await UserService.IsHasBlog(userId))
{
return "必須先開通博客,才能申請JS許可權";
}
var entity = await _jsPermissionApplyRepository.GetByUserId(userId).FirstOrDefaultAsync();
if (entity != null)
{
if (entity.Status == Status.Pass)
{
return "您的JS許可權申請正在處理中,請稍後";
}
if (entity.Status == Status.Lock)
{
return "您暫時無法申請JS許可權,請聯繫[email protected]";
}
}
return string.Empty;
}
}
}
IsHasBlog 屬於用戶驗證,但下麵的 jsPermissionApply.Status 驗證就不屬於了,放在 UserAuthenticationService 中也不合適,我的想法是把這部分驗證獨立出來,用 ApplyAuthenticationService 領域服務實現,後來仔細一想,似乎和上面實體生命周期遇到的問題有些類似,誤把用戶當作核心考慮了,在 JS 許可權申請和審核系統中,對於用戶的驗證,其實就是對申請的驗證,所驗證的最終目的是:某個用戶是否符合要求進行申請操作?
所以,對於申請相關的驗證操作,應該命名為 ApplyAuthenticationService,並且驗證代碼都放在其中。
改進代碼:
namespace CNBlogs.Apply.Domain.DomainServices
{
public class ApplyAuthenticationService : IApplyAuthenticationService
{
private IJsPermissionApplyRepository _jsPermissionApplyRepository;
public ApplyAuthenticationService(IJsPermissionApplyRepository jsPermissionApplyRepository)
{
_jsPermissionApplyRepository = jsPermissionApplyRepository;
}
public async Task<string> Verfiy(int userId)
{
if (!await UserService.IsHasBlog(userId))
{
return "必須先開通博客,才能申請JS許可權";
}
var entity = await _jsPermissionApplyRepository.GetEffective(userId).FirstOrDefaultAsync();
if (entity != null)
{
if (entity.Status == Status.Pass)
{
return "您的JS許可權申請已開通,請勿重覆申請";
}
if (entity.Status == Status.Wait)
{
return "您的JS許可權申請正在處理中,請稍後";
}
if (entity.Status == Status.Lock)
{
return "您暫時無法申請JS許可權,請聯繫[email protected]";
}
}
return string.Empty;
}
}
}
除了 UserAuthenticationService 重命名為 ApplyAuthenticationService,還增加了對 JsPermissionApply 狀態為 Lock 的驗證,並且 IJsPermissionApplyRepository 的 GetByUserId 調用改為了 GetEffective,這個下麵會講到。
4. 改進 JsPermissionApplyRepository
原先的 IJsPermissionApplyRepository 設計:
namespace CNBlogs.Apply.Repository.Interfaces
{
public interface IJsPermissionApplyRepository : IRepository<JsPermissionApply>
{
IQueryable<JsPermissionApply> GetByUserId(int userId);
}
}
這樣的 IJsPermissionApplyRepository 的設計,看似沒什麼問題,並且問題也不出現在實現,而是出現在調用的時候,GetByUserId 會在兩個地方調用:
- ApplyAuthenticationService.Verfiy 調用:獲取 JsPermissionApply 實體對象,用於狀態的驗證,判斷是否符合申請的要求。
- 領域的單元測試代碼中(或者應用層):獲取 JsPermissionApply 實體對象,用於更新其狀態。
對於上面兩個調用方來說,GetByUserId 太模糊了,甚至不知道調用的是什麼東西?並且這兩個地方的調用,獲取的 JsPermissionApply 實體對象也並不相同,嚴格來說,應該是不同狀態下的 JsPermissionApply 實體對象,我們仔細分析下:
- ApplyAuthenticationService.Verfiy 調用:判斷是否符合申請的要求。什麼情況下會符合申請要求呢?就是當狀態為“未通過”的時候,對於申請驗證來說,可以稱之為“有效的”申請,相反,獲取用於申請驗證的 JsPermissionApply 實體對象,應該稱為“無效的”,調用命名為 GetInvalid。
- 領域的單元測試代碼中(或者應用層):用於更新 JsPermissionApply 實體狀態。什麼狀態下的 JsPermissionApply 實體,可以更新其狀態呢?答案就是狀態為“待審核”,所以這個調用應該獲取狀態為“待審核”的 JsPermissionApply 實體對象,調用命名為 GetWaiting。
改進代碼:
namespace CNBlogs.Apply.Repository
{
public class JsPermissionApplyRepository : BaseRepository<JsPermissionApply>, IJsPermissionApplyRepository
{
public JsPermissionApplyRepository(IDbContext dbContext)
: base(dbContext)
{ }
public IQueryable<JsPermissionApply> GetInvalid(int userId)
{
return _entities.Where(x => x.UserId == userId && x.Status != Status.Deny && x.IsActive);
}
public IQueryable<JsPermissionApply> GetWaiting(int userId)
{
return _entities.Where(x => x.UserId == userId && x.Status == Status.Wait && x.IsActive);
}
}
}
5. 改進領域單元測試
原先的單元測試代碼:
namespace CNBlogs.Apply.Domain.Tests
{
public class JsPermissionApplyTest
{
private IUserAuthenticationService _userAuthenticationService;
private IJsPermissionApplyRepository _jsPermissionApplyRepository;
private IUnitOfWork _unitOfWork;
public JsPermissionApplyTest()
{
CNBlogs.Apply.BootStrapper.Startup.Configure();
_userAuthenticationService = IocContainer.Default.Resolve<IUserAuthenticationService>();
_jsPermissionApplyRepository = IocContainer.Default.Resolve<IJsPermissionApplyRepository>();
_unitOfWork = IocContainer.Default.Resolve<IUnitOfWork>();
}
[Fact]
public async Task Apply()
{
var userId = 1;
var verfiyResult = await _userAuthenticationService.Verfiy(userId);
Console.WriteLine(verfiyResult);
Assert.Empty(verfiyResult);
var jsPermissionApply = new JsPermissionApply("我要申請JS許可權", userId, "");
_unitOfWork.RegisterNew(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}
[Fact]
public async Task ProcessApply()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetByUserId(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);
jsPermissionApply.Process("審核通過", Status.Pass);
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}
}
}
看起來似乎沒什麼問題,一個申請和一個審核測試,但我們仔細看上面的業務流程圖,會發現這個測試代碼並不能完全覆蓋所有的業務,並且這個測試代碼也有些太敷衍了,在測試驅動開發中,測試代碼就是所有的業務表達,它應該是項目中最全面和最精細的代碼,在領域驅動設計中,當領域層的代碼完成後,領域專家查看的時候,不會看領域層,而是直接看單元測試中的代碼,因為領域專家不懂代碼,並且他也不懂你是如何實現的,它關心的是我該如何使用它?我想要的業務操作,你有沒有完全實現?單元測試就是最好的體現。
我們該如何改進呢?還是回歸到上面的業務流程圖,並從中歸納出領域專家想要的幾個操作:
- 填寫 JS 許可權申請(需要填寫申請理由)
- 通過 JS 許可權申請
- 拒絕 JS 許可權申請(需要填寫拒絕原因)
- 鎖定 JS 許可權申請
- 刪除(待考慮)
上面這幾個操作,都必須在單元測試代碼中有所體現,並且儘量讓測試顆粒化,比如一個驗證操作,你可以對不同的參數編寫不同的單元測試代碼。
改進代碼:
namespace CNBlogs.Apply.Domain.Tests
{
public class JsPermissionApplyTest
{
private IApplyAuthenticationService _applyAuthenticationService;
private IJsPermissionApplyRepository _jsPermissionApplyRepository;
private IUnitOfWork _unitOfWork;
public JsPermissionApplyTest()
{
CNBlogs.Apply.BootStrapper.Startup.Configure();
_applyAuthenticationService = IocContainer.Default.Resolve<IApplyAuthenticationService>();
_jsPermissionApplyRepository = IocContainer.Default.Resolve<IJsPermissionApplyRepository>();
_unitOfWork = IocContainer.Default.Resolve<IUnitOfWork>();
}
[Fact]
public async Task ApplyTest()
{
var userId = 1;
var verfiyResult = await _applyAuthenticationService.Verfiy(userId);
Console.WriteLine(verfiyResult);
Assert.Empty(verfiyResult);
var jsPermissionApply = new JsPermissionApply("我要申請JS許可權", userId, "");
_unitOfWork.RegisterNew(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}
[Fact]
public async Task ProcessApply_WithPassTest()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);
await jsPermissionApply.Pass();
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}
[Fact]
public async Task ProcessApply_WithDenyTest()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);
await jsPermissionApply.Deny("理由太簡單了。");
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}
[Fact]
public async Task ProcessApply_WithLockTest()
{
var userId = 1;
var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync();
Assert.NotNull(jsPermissionApply);
await jsPermissionApply.Lock();
_unitOfWork.RegisterDirty(jsPermissionApply);
Assert.True(await _unitOfWork.CommitAsync());
}
}
}
改進好了代碼之後,對於開發人員來說,任務似乎完成了,但對於領域專家來說,僅僅是個開始,因為他必須要通過提供的四個操作,來驗證各種情況下的業務操作是否正確,我們來歸納下:
- 申請 -> 申請:ApplyTest -> ApplyTest
- 申請 -> 通過:ApplyTest -> ProcessApply_WithPassTest
- 申請 -> 拒絕:ApplyTest -> ProcessApply_WithDenyTest
- 申請 -> 鎖定:ApplyTest -> ProcessApply_WithLockTest
- 申請 -> 通過 -> 申請:ApplyTest -> ProcessApply_WithPassTest -> ApplyTest
- 申請 -> 拒絕 -> 申請:ApplyTest -> ProcessApply_WithDenyTest -> ApplyTest
- 申請 -> 鎖定 -> 申請:ApplyTest -> ProcessApply_WithLockTest -> ApplyTest
確認上面的所有測試都通過之後,就說明 JsPermissionApply 領域模型設計的還算可以。
DDD 傾向於“測試先行,逐步改進”的設計思路。測試代碼本身便是通用語言在程式中的表達,在開發人員的幫助下,領域專家可以閱讀測試代碼來檢驗領域對象是否滿足業務需求。
當領域層的代碼基本完成之後,就可以在地基上添磚加瓦了,後面的實現都是工作流程的實現,沒有任何業務的包含,比如上面對領域層的單元測試,其實就是應用層的實現,在添磚加瓦的過程中,切記地基的重要性,否則即使蓋再高的摩天大樓,地基不穩,也照樣垮塌。
實際項目的 DDD 應用很有挑戰,也會很有意思。