文章出處

上一篇:《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權限申請沒有被批準,并且申請已被鎖定,具體請聯系contact@cnblogs.com。";

            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權限,請聯系contact@cnblogs.com";
                }
            }
            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權限,請聯系contact@cnblogs.com";
                }
            }
            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 應用很有挑戰,也會很有意思。😏


無意間發現了 Visual Studio 2015 Update 2 一個很實用的功能:


文章列表




Avast logo

Avast 防毒軟體已檢查此封電子郵件的病毒。
www.avast.com


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()