文章出處

上一篇:《DDD 領域驅動設計-領域模型中的用戶設計?

開源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample(代碼已更新)

在之前的項目開發中,只有一個 JsPermissionApply 實體(JS 權限申請),所以,CNBlogs.Apply.Domain 設計的有些不全面,或者稱之為不完善,因為在一些簡單的項目開發中,一般只會存在一個實體,單個實體的設計,我們可能會忽略很多的東西,從而以后會導致一些問題的產生,那如果再增加一個實體,CNBlogs.Apply.Domain 該如何設計呢?

按照實際項目開發需要,CNBlogs.Apply.Domain 需要增加一個 BlogChangeApply 實體(博客地址更改申請)。

在 BlogChangeApply 實體設計之前,我們按照之前 JsPermissionApply 實體設計過程,先大致畫一下流程圖:

流程圖很簡單,并且和之前的 JS 權限申請和審核很相似,我們再看一下之前的 JsPermissionApply 實體設計代碼:

namespace CNBlogs.Apply.Domain
{
    public class JsPermissionApply : IAggregateRoot
    {
        private IEventBus eventBus;

        public JsPermissionApply()
        { }

        public JsPermissionApply(string reason, User user, string ip)
        {
            if (string.IsNullOrEmpty(reason))
            {
                throw new ArgumentException("申請內容不能為空");
            }
            if (reason.Length > 3000)
            {
                throw new ArgumentException("申請內容超出最大長度");
            }
            if (user == null)
            {
                throw new ArgumentException("用戶為null");
            }
            if (user.Id == 0)
            {
                throw new ArgumentException("用戶Id為0");
            }
            this.Reason = HttpUtility.HtmlEncode(reason);
            this.User = user;
            this.Ip = ip;
            this.Status = Status.Wait;
        }

        public int Id { get; private set; }

        public string Reason { get; private set; }

        public virtual User User { get; private set; }

        public Status Status { get; private set; } = Status.Wait;

        public string Ip { get; private set; }

        public DateTime ApplyTime { get; private set; } = DateTime.Now;

        public string ReplyContent { get; private set; }

        public DateTime? ApprovedTime { get; private set; }

        public bool IsActive { get; private set; } = true;

        public async Task<Status> GetStatus(string userAlias)
        {
            if (await BlogService.HaveJsPermission(userAlias))
            {
                return Status.Pass;
            }
            else
            {
                if (this.Status == Status.Deny && DateTime.Now > this.ApplyTime.AddDays(3))
                {
                    return Status.None;
                }
                if (this.Status == Status.Pass)
                {
                    return Status.None;
                }
                return this.Status;
            }
        }

        public async Task<bool> Pass()
        {
            if (this.Status != Status.Wait)
            {
                return false;
            }
            this.Status = Status.Pass;
            this.ApprovedTime = DateTime.Now;
            this.ReplyContent = "恭喜您!您的JS權限申請已通過審批。";
            eventBus = IocContainer.Default.Resolve<IEventBus>();
            await eventBus.Publish(new JsPermissionOpenedEvent() { UserAlias = this.User.Alias });
            return true;
        }

        public bool Deny(string replyContent)
        {
            if (this.Status != Status.Wait)
            {
                return false;
            }
            this.Status = Status.Deny;
            this.ApprovedTime = DateTime.Now;
            this.ReplyContent = replyContent;
            return true;
        }

        public bool Lock()
        {
            if (this.Status != Status.Wait)
            {
                return false;
            }
            this.Status = Status.Lock;
            this.ApprovedTime = DateTime.Now;
            this.ReplyContent = "抱歉!您的JS權限申請沒有被批準,并且申請已被鎖定,具體請聯系contact@cnblogs.com。";
            return true;
        }

        public async Task Passed()
        {
            if (this.Status != Status.Pass)
            {
                return;
            }
            eventBus = IocContainer.Default.Resolve<IEventBus>();
            await eventBus.Publish(new MessageSentEvent() { Title = "您的JS權限申請已批準", Content = this.ReplyContent, RecipientId = this.User.Id });
        }

        public async Task Denied()
        {
            if (this.Status != Status.Deny)
            {
                return;
            }
            eventBus = IocContainer.Default.Resolve<IEventBus>();
            await eventBus.Publish(new MessageSentEvent() { Title = "您的JS權限申請未通過審批", Content = this.ReplyContent, RecipientId = this.User.Id });
        }

        public async Task Locked()
        {
            if (this.Status != Status.Lock)
            {
                return;
            }
            eventBus = IocContainer.Default.Resolve<IEventBus>();
            await eventBus.Publish(new MessageSentEvent() { Title = "您的JS權限申請未通過審批", Content = this.ReplyContent, RecipientId = this.User.Id });
        }
    }
}

根據博客地址更改申請和審核的流程圖,然后再結合上面 JsPermissionApply 實體代碼,我們就可以幻想出 BlogChangeApply 的實體代碼,具體是怎樣的了,如果你實現一下,會發現和上面的代碼簡直一摸一樣,區別就在于多了一個 TargetBlogApp(目標博客地址),然后后面的 Repository 和 Application.Services 復制粘貼就行了,沒有任何的難度,這樣設計實現也沒什么問題,但是項目中的重復代碼簡直太多了,領域驅動設計慢慢就變成了一個腳手架,沒有任何的一點用處。

該如何解決上面的問題呢?我們需要思考下 CNBlogs.Apply.Domain 所包含的含義,CNBlogs.Apply.Domain 顧名思議是申請領域,并不是 CNBlogs.JsPermissionApply.Domain,也不是 CNBlogs.BlogChangeApply.Domain,實體的產生是根據聚合根的設計,那 CNBlogs.Apply.Domain 的聚合根是什么呢?在之前的設計中只有 IAggregateRoot 和 IEntity,具體代碼:

namespace CNBlogs.Apply.Domain
{
    public interface IAggregateRoot : IEntity { }
}

namespace CNBlogs.Apply.Domain
{
    public interface IEntity
    {
        int Id { get; }
    }
}

現在再來看上面這種設計,完全是錯誤的,聚合根接口怎么能繼承實體接口呢,還有一個問題,就是如果有多個實體設計,是繼承 IAggregateRoot?還是 IEntity?IEntity 在這樣的設計中,沒有任何的作用,并且閑的很多余,IAggregateRoot 到最后也只是一個抽象的接口,CNBlogs.Apply.Domain 中并沒有具體的實現。

解決上面混亂的問題,就是抽離出 ApplyAggregateRoot(申請聚合根),然后 JsPermissionApply 和 BlogChangeApply 實體都是由它進行產生,在這之前,我們先定義一下 IAggregateRoot:

namespace CNBlogs.Apply.Domain
{
    public interface IAggregateRoot
    {
        int Id { get; }
    }
}

然后根據 JS 權限申請/審核和博客地址更改申請/審核的流程圖,抽離出 ApplyAggregateRoot,并且繼承自 IAggregateRoot,具體實現代碼:

namespace CNBlogs.Apply.Domain
{
    public class ApplyAggregateRoot : IAggregateRoot
    {
        private IEventBus eventBus;

        public ApplyAggregateRoot()
        { }

        public ApplyAggregateRoot(string reason, User user, string ip)
        {
            if (string.IsNullOrEmpty(reason))
            {
                throw new ArgumentException("申請內容不能為空");
            }
            if (reason.Length > 3000)
            {
                throw new ArgumentException("申請內容超出最大長度");
            }
            if (user == null)
            {
                throw new ArgumentException("用戶為null");
            }
            if (user.Id == 0)
            {
                throw new ArgumentException("用戶Id為0");
            }
            this.Reason = HttpUtility.HtmlEncode(reason);
            this.User = user;
            this.Ip = ip;
            this.Status = Status.Wait;
        }

        public int Id { get; protected set; }

        public string Reason { get; protected set; }

        public virtual User User { get; protected set; }

        public Status Status { get; protected set; } = Status.Wait;

        public string Ip { get; protected set; }

        public DateTime ApplyTime { get; protected set; } = DateTime.Now;

        public string ReplyContent { get; protected set; }

        public DateTime? ApprovedTime { get; protected set; }

        public bool IsActive { get; protected set; } = true;

        protected async Task<bool> Pass<TEvent>(string replyContent, TEvent @event)
            where TEvent : IEvent
        {
            if (this.Status != Status.Wait)
            {
                return false;
            }
            this.Status = Status.Pass;
            this.ApprovedTime = DateTime.Now;
            this.ReplyContent = replyContent;
            eventBus = IocContainer.Default.Resolve<IEventBus>();
            await eventBus.Publish(@event);
            return true;
        }

        public bool Deny(string replyContent)
        {
            if (this.Status != Status.Wait)
            {
                return false;
            }
            this.Status = Status.Deny;
            this.ApprovedTime = DateTime.Now;
            this.ReplyContent = replyContent;
            return true;
        }

        protected bool Lock(string replyContent)
        {
            if (this.Status != Status.Wait)
            {
                return false;
            }
            this.Status = Status.Lock;
            this.ApprovedTime = DateTime.Now;
            this.ReplyContent = replyContent;
            return true;
        }

        protected async Task Passed(string title)
        {
            if (this.Status != Status.Pass)
            {
                return;
            }
            await SendMessage(title);
        }

        protected async Task Denied(string title)
        {
            if (this.Status != Status.Deny)
            {
                return;
            }
            await SendMessage(title);
        }

        protected async Task Locked(string title)
        {
            if (this.Status != Status.Lock)
            {
                return;
            }
            await SendMessage(title);
        }

        private async Task SendMessage(string title)
        {
            eventBus = IocContainer.Default.Resolve<IEventBus>();
            await eventBus.Publish(new MessageSentEvent() { Title = title, Content = this.ReplyContent, RecipientId = this.User.Id });
        }
    }
}

ApplyAggregateRoot 的實現,基本上是抽離出 JsPermissionApply 和 BlogChangeApply 實體產生的重復代碼,比如不管什么類型的申請,都包含申請理由、申請人信息、通過或拒絕等操作,這些也就是 ApplyAggregateRoot 所體現的領域含義,我們再來看下 BlogChangeApply 實體的實現代碼:

namespace CNBlogs.Apply.Domain
{
    public class BlogChangeApply : ApplyAggregateRoot
    {
        public BlogChangeApply()
        { }

        public BlogChangeApply(string targetBlogApp, string reason, User user, string ip)
            : base(reason, user, ip)
        {
            if (string.IsNullOrEmpty(targetBlogApp))
            {
                throw new ArgumentException("博客地址不能為空");
            }
            targetBlogApp = targetBlogApp.Trim();
            if (targetBlogApp.Length < 4)
            {
                throw new ArgumentException("博客地址至少4個字符!");
            }
            if (!Regex.IsMatch(targetBlogApp, @"^([0-9a-zA-Z_-])+$"))
            {
                throw new ArgumentException("博客地址只能使用英文、數字、-連字符、_下劃線!");
            }
            this.TargetBlogApp = targetBlogApp;
        }

        public string TargetBlogApp { get; private set; }

        public Status GetStatus()
        {
            if (this.Status == Status.Deny && DateTime.Now > this.ApplyTime.AddDays(3))
            {
                return Status.None;
            }
            return this.Status;
        }

        public async Task<bool> Pass()
        {
            var replyContent = $"恭喜您!您的博客地址更改申請已通過,新的博客地址:<a href='{this.TargetBlogApp}' target='_blank'>{this.TargetBlogApp}</a>";
            return await base.Pass(replyContent, new BlogChangedEvent() { UserAlias = this.User.Alias, TargetUserAlias = this.TargetBlogApp });
        }

        public bool Lock()
        {
            var replyContent = "抱歉!您的博客地址更改申請沒有被批準,并且申請已被鎖定,具體請聯系contact@cnblogs.com。";
            return base.Lock(replyContent);
        }

        public async Task Passed()
        {
            await base.Passed("您的博客地址更改申請已批準");
        }

        public async Task Denied()
        {
            await base.Passed("您的博客地址更改申請未通過審批");
        }

        public async Task Locked()
        {
            await Denied();
        }
    }
}

BlogChangeApply 繼承自 ApplyAggregateRoot,并且單獨的 TargetBlogApp 操作,其他一些實現都是基本的參數傳遞操作,沒有具體實現,JsPermissionApply 的實體代碼就不貼了,和 BlogChangeApply 比較類似,只不過有一些不同的業務實現。

CNBlogs.Apply.Domain 改造之后,還要對應改造下 Repository,之前的代碼大家可以看下 Github,這邊我簡單說下改造的過程,首先 IRepository 的設計不變:

namespace CNBlogs.Apply.Repository.Interfaces
{
    public interface IRepository<TAggregateRoot> 
        where TAggregateRoot : class, IAggregateRoot
    {
        IQueryable<TAggregateRoot> Get(int id);

        IQueryable<TAggregateRoot> GetAll();
    }
}

IRepository 對應 BaseRepository 實現,它的作用就是抽離出所有聚合根的 Repository 操作,并不單獨包含 ApplyAggregateRoot,所以,我們還需要一個對 ApplyAggregateRoot 操作的 Repository 實現,定義如下:

namespace CNBlogs.Apply.Repository.Interfaces
{
    public interface IApplyRepository<TApplyAggregateRoot> : IRepository<TApplyAggregateRoot>
        where TApplyAggregateRoot : ApplyAggregateRoot
    {
        IQueryable<TApplyAggregateRoot> GetByUserId(int userId);

        IQueryable<TApplyAggregateRoot> GetWaiting(int userId);

        IQueryable<TApplyAggregateRoot> GetWaiting();
    }
}

大家如果熟悉之前代碼的話,會發現 IApplyRepository 的定義和 IJsPermissionApplyRepository 的定義是一摸一樣的,設計 IApplyRepository 的好處就是,對于申請實體的相同操作,我們就不需要再寫重復代碼了,比如 IJsPermissionApplyRepository 和 IBlogChangeApplyRepository 的定義:

namespace CNBlogs.Apply.Repository.Interfaces
{
    public interface IJsPermissionApplyRepository : IApplyRepository<JsPermissionApply>
    { }
}

namespace CNBlogs.Apply.Repository.Interfaces
{
    public interface IBlogChangeApplyRepository : IApplyRepository<BlogChangeApply>
    {
        IQueryable<BlogChangeApply> GetByTargetAliasWithWait(string targetBlogApp);
    }
}

當然,除了上面的代碼改造,還有一些其他功能的添加,比如 ApplyAuthenticationService 領域服務增加了 VerfiyForBlogChange 等等,具體的一些改變,大家可以查看提交

CNBlogs.Apply.Sample 開發進行到這,對于現階段的我來說,應用領域驅動設計我是比較滿意的,雖然還有一些不完善的地方,但至少除了 CNBlogs.Apply.Domain,在其他項目中是看不到業務實現代碼的,如果業務需求發生變化,首先更改的是 CNBlogs.Apply.Domain,而不是不是其它項目,這是一個基本點。

先設計 CNBlogs.Apply.Domain 和 CNBlogs.Apply.Domain.Tests,就能完成整個的業務系統設計,其它都是一些技術實現或工作流程實現,這個路子我覺得是正確的,以后邊做邊完善并學習。


文章列表




Avast logo

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


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

    IT工程師數位筆記本

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