文章出處

注:科比今天要退役了,我是 60 億分之一,滿腹懷念~😭😭😭

前幾天看了園友的一篇文章《我眼中的領域驅動設計》,文中有段話直擊痛點:有人誤認為項目架構中加入 Repository,Domain,ValueObject 就變成了 DDD 架構。沒錯,我就是這樣,不過準確的來說,并不能稱為 DDD 架構,而是我之前經常說的“偽 DDD”設計,后來我還抽離出了一個偽 DDD 設計框架:DDD.Sample,大家有興趣的可以瞧瞧,在實際項目的開發中,我用它做過了幾個不太大的項目,我個人覺得用起來很順手,當然并沒有真正的 DDD 設計,只不過用了一個空的架構而已,然后冠以”DDD 開發“的名號。

因為之前有過 DDD 設計的痛苦經歷(短消息系統),具體表現就是,如果真正 DDD 設計,需要花費大量的時間和精力,可能幾天都在思考一個問題或者考慮幾行代碼的編寫,但最后也可能沒什么結論或結果,并且這個過程是很艱難和痛苦的,所以我后來就變懶了,懶的去思考項目所表現的業務,也不再考慮如何去設計領域模型,只是在考慮如何讓框架用起來更爽,DDD.Sample 前兩個應用的實際項目,我都是在完善這個框架,比如 Repository 和 UnitOfWork 的設計等等,所以,關于領域模型的設計,就是一堆貧血模型。不過,后來應用的第三個項目,也就是上一個實際項目,我覺得不能再這樣下去了,因為沒啥意義,框架一遍一遍的套用,而 DDD 卻和它沒半毛錢關系,所以,我就花了點時間去思考(只是簡單的思考):我做這個項目的核心業務是什么?我該如何提煉出核心業務?提煉出核心業務之后,其他的任何實現都是為核心業務服務的,所以,你可以把這個核心業務看成領域模型。

關于第三個應用項目,實際上就是我們園子的“提到我”系統,現在已經應用在新聞評論了,大家可以去瞧瞧,類似為微博的“提到我”,相對比較簡單的一個系統,你可以在評論中 @一個人,然后另一個人會接受通知,那這個系統的核心業務是什么?其實就是上面那句話,只不過你需要抽離出一些內容,如果領域專家和開發人員進行交流這個系統的設計,那領域專家的表述就是:你可以在評論中 @一個人,然后另一個人會接受通知,領域專家可能不懂代碼設計,他的這個表述就是最直接和最精準的業務,所以,我們需要針對這段話,再和領域專家深入探討下所蘊含的業務:

  • 你可以在評論中 @一個人 -> @一個人 -> 怎么能得到并確認這個“一個人” -> @匹配規則
  • 另一個人會接受通知 -> 通知 -> 通知所 @的人

所以,@匹配規則通知所 @的人是“提到我”系統的核心業務,確定好核心業務了,那就該具體的實現了,關于這個我沒有深入的去考慮,就直接放在了 Mention(提到)中,大致代碼:

namespace CNBlogs.Mention.Domain
{
    public class Mention : IAggregateRoot
    {
        private static readonly string SplitRegex = "::  @,";
        private static readonly Regex MentionsRegex = new Regex($"@([^{SplitRegex}]+)", RegexOptions.Compiled);

        public int Id { get; set; }

        public string Content { get; set; }

        public int ContentId { get; set; }

        public AppType AppType { get; set; }

        ...

        //抽離
        public async Task<List<int>> Extract()
        {
            ...
        }

        //通知
        public async Task Notify()
        {
            ...
        }
    }
}

看起來很簡單,就是把兩個方法放在了 Mention 中,但這簡單的操作卻好像給 Mention 領域模型生命一樣,不再那么貧血,對于復雜系統的業務變化,往往是核心業務的變化,其他的都是為核心業務服務的業務流程,并不能真正稱為業務,比如 Application 層的代碼,現在領域專家說 @一個人的規則需要改變,或者通知規則需要變化,我們只需要修改 Mention 領域模型的代碼就行了,其他的代碼并不需要修改,這就是 DDD 設計最淺顯的體現。

大致貼下 Application 層的偽代碼:

namespace CNBlogs.Mention.Application.Services
{
    public class MentionService : IMentionService
    {
        private IMentionRepository _mentionRepository;
        private IUnitOfWork _unitOfWork;

        public MentionService(IUnitOfWork unitOfWork,
            IMentionRepository mentionRepository)
        {
            _unitOfWork = unitOfWork;
            _mentionRepository = mentionRepository;
        }

        public async Task<SubmitResult> Submit(string content ,int contentId, AppType appType)
        {
            var notifyMentions = new List<Domain.Mention>();
            var existingQuery = _mentionRepository.Get(contentId, appType);
            var mention = new Domain.Mention()
            {
                Content = content,
                ContentId = contentId,
                AppType = appType
            };
            var userIds = await mention.Extract();
            foreach (var userId in userIds)
            {
                var userQuery = existingQuery.Where(x => x.UserId == userId);
                if (await userQuery.AnyAsync())
                {
                    await userQuery.UpdateAsync(x => new Domain.Mention { Content = content, DateUpdated = DateTime.Now });
                }
                else
                {
                    mention.UserId = userId;
                    _unitOfWork.RegisterNew(mention);
                    notifyMentions.Add(mention);
                }
            }
            if (await _unitOfWork.CommitAsync())
            {
                foreach (var notifyMention in notifyMentions)
                {
                    await notifyMention.Notify();
                }
                return new SubmitResult();
            }
            return new SubmitResult { IsSucceed = false };
        }
    }
}

可以看到,Submit 中的操作基本上都是工作流程,先抽取用戶,再進行判斷更新,然后進行持久化,最后進行通知,沒有任何業務體現,所以,如果核心業務發生了變化,這部分的代碼并不需要隨之改變。

如何 DDD?

引自:《Implementing DDD Reading - Strategic Design

如何 DDD?其實答案都在上面的圖中,圖中的設計在《實現領域驅動設計》書中,被定義為戰略建模(Strategic Modeling),主要包含領域、核心域、子域、通用子域、支撐子域、限界上下文、協作上下文、上下文映射圖等等概念,我之前的幾篇《IDDD 實現領域驅動設計》系統文章,也有過相關的介紹,說實話,我只是當時讀過寫過有些記憶,現在讓我再說任何一個概念,基本上我說不上來,對于我個人來說,戰略建模是一種宏觀的建模方式,你需要站在高處去俯瞰整個系統,并需要抽離出系統所包含的業務,并將它們一一劃分,這個工作是非常難的,推薦幾篇戰略建模相關的文章:

除了戰略建模,還有一種建模方式叫戰術建模(Tactical Modeling),主要包含聚合(Aggregate)、實體(Entity)、值對象(Value Objects)、資源庫(Repository)、領域服務(Domain Services)、領域事件(Domain Events)、模塊(Modules)等等概念。

在《實現領域驅動設計》書中,Scrum 團隊(一個實際項目的開發團隊)一開始就是采用的戰術建模,并且在開發的過程中,他們并不知道戰略建模是什么?最后導致了很多問題,書中有個節點就專門講了“戰略設計為什么重要?”,但我個人覺得,戰略建模的重要也只是相對而言,它在應對大型復雜性的業務系統設計中,可以充分發揮它的特點,但針對一些相對簡單的系統,還不如直接進行戰術建模,比如上面說的“提到我”系統。

所以,目前來說,進行戰術建模比較現實和有意義,但在進行戰術建模之前,我覺得還有一個重要的工作,就是和領域專家進行交流系統業務,這個工作并不包含具體的戰術建模該如何設計,比如聚合、實體啥的,和領域專家并不需要討論這部分內容,而是系統所包含的業務,就像“提到我”系統中,我問我自己“我做這個項目的核心業務是什么?”。

領域專家并不是一個職位,他可以是精通業務的任何人。他們可能了解更多的關于業務領域的背景知識,他們可能是軟件產品的設計者,甚至有可能是銷售員。

實際項目的實踐

好,概念說太多沒什么意義,實際應用才有價值,我現在在開發一個 JS 權限申請和審核系統,就是我們園子里的 JS 權限申請,因為現在申請 JS 權限需要發郵件進行申請,對于用戶申請和我們處理來說,都比較麻煩并且費時間,所以為了解決這個問題,我們想做把 JS 權限申請做成像申請博客一樣,園友填寫申請內容,然后我們進行后臺審核,效率可以提升很多,大概就是這樣的一個系統,真實的不能再真實了,畢竟園友和我們都會實際接觸并使用,這也是一個相對較小的系統,我們就拿它來開刀,看看 DDD 的這把刀鋒不鋒利。

對于 JS 權限申請和審核系統來說,領域專家是誰?應該是園友和管理員,畢竟他們在使用這個系統,沒有人比他們更了解其中的業務了,所以他們就是這個系統的領域專家,需要強調的是,雖然有時候領域專家是開發人員,但在一開始探討業務系統的時候,一定不能牽扯到數據庫和代碼的設計,我們應該忘掉數據庫和代碼,只是單純的站在領域專家的角度,去探討和思考業務系統,那領域專家該如何表述這個系統的業務呢?

下面我大致的表述下:

用戶填寫 JS 權限申請內容,管理員后臺進行審核。

有沒有搞錯?就這么簡單???好像又無言以對,因為關于 JS 權限申請和審核系統,最簡單的表述就是這樣,但如何提煉出所蘊含的業務呢?接下來需要我們深入的探討下,作為領域專家身份的我,繪制了一張大致的業務流程圖:

上面這張圖可以進行反復的修改,每個領域專家都可以發表自己的意見和建議,經過最激烈的探討才會讓業務系統更加準確,當業務系統確定好之后,我們就可以從中抽離出核心業務了,上面這張圖,哪些是核心業務?哪些又是業務流程呢?我大致圈一下:

方框圈的是核心業務,圓形圈的是實體的狀態變化,核心業務一般包含在最簡單的描述中,比如“提到我”系統中的表述“抽離”和“通知”,還有一種區分方式:判斷其是否經常發生變化,對于業務流程來說,一般是不會發生變化的,變化的是核心業務,DDD 的設計應對的就是這個變化,再大致總結下:

  • 核心業務:驗證用戶信息,可以稱為申請狀態改為“待審核”的前提條件,主要是判斷用戶是否符合要求,前面的“驗證申請狀態”也屬于這一類。
  • 實體狀態變化:JS 權限申請就是一個實體,它有自己的生命周期,并且對于用戶來說,它是唯一存在的,從上面的圖中,我們可以看到 JS 權限申請的狀態變化,這是領域所關心的,能改變實體的狀態,就是業務。

那領域模型關心的是哪些業務?其實就是能影響 JS 權限申請狀態變化的條件,暫時不看用戶申請的部分,先看下管理員審核的部分,因為是人工審核的,所以這就有人為因素的產生,這部分我們在領域模型設計的時候,就沒有辦法把控,所以可以把這部分排除在領域模型之外,后面 JS 權限申請狀態的改變,也是由人為進行導致的,也就是說,對于領域模型來說,我們沒有辦法進行控制 JS 權限申請狀態的改變,所以后面的狀態改變我們可以看作是業務流程或者工作流程,有人可能會問:“開通 JS 權限”和“消息通知用戶”,算不算是業務?其實這部分可以算是業務,因為它是狀態改變后的一種行為,我們可以使用領域事件實現它。

跟蹤變化最實用的方法是領域事件和事件存儲。我們為領域專家所關心的所以狀態改變都創建單獨的事件類型,事件的名字和屬性表明發生了什么樣的事件。當命令操作執行完后,系統發出這些領域事件。事件的訂閱方可以接收發生在模型上的所有事件。在接收到事件后,訂閱方將事件保存在事件存儲中。

有點越說越亂的感覺,先暫時概括下我們設計領域模型所包含的東西:

  • 聚合根和實體:JS 權限申請,命名為 JsPermissionApply,具有唯一性。
  • 值對象:申請狀態,命名為 Status,包含待審核、審核通過、審核不通過等。
  • 領域事件:處理 JsPermissionApply 狀態改變后的一些工作(開通 JS 權限和發送消息通知)。
  • 領域服務:UserAuthenticationService,驗證用戶是否合法,以及驗證此用戶是否能創建 JsPermissionApply。
  • 實體驗證:基本驗證由 JsPermissionApply 自身負責,在 JsPermissionApply 的構造函數中處理。
  • 實體行為:管理員的審核處理 Process,領域事件在這里觸發。

UserAuthenticationService 所做的工作就是上面圖中第一個圈和第一個方框的內容,總的概述就是驗證用戶是否能創建 JsPermissionApply?我之前考慮用工廠實現,但感覺還是不太妥,因為工廠是為創建復雜實體服務的,內部會有一些復雜的操作,對于一些簡單的實體創建,我們直接用實體的構造函數進行創建就行,比如 JsPermissionApply 的創建,既然用工廠實現不合適,那直接將操作放在 JsPermissionApply 中會怎樣呢?驗證自己能否被創建?想想還是有些別扭,所以還是用 UserAuthenticationService 領域服務實現吧,況且領域服務的定義就是如此。

領域服務表示一個無狀態的操作,它用于實現特定于某個領域的任務,當某個操作不適合放在聚合和值對象上時,最好的方式便是使用領域服務。

另外,關于實體、值對象、領域事件、領域服務和倉儲接口的實現,最好在不同的項目中,如果再同一個項目中的話,可能會造成循環引用的情況,比如倉儲接口引用了實體,領域服務引用了倉儲接口,如果實體和領域服務實現在同一個項目,就會出現循環引用的問題。

再來總結下,我們分析系統和設計領域模型的步驟:先和領域專家探討業務系統,經過反反復復的研究,抽離出業務系統的核心業務,然后用戰術建模的方式設計領域模型,最后用代碼進行實現。領域模型設計好了之后,下面就開始用代碼實現了,在代碼實現的時候,最好解決方案中只有領域層、基礎設施層和單元測試,并且一開始設計的時候,先編寫領域層的代碼,然后再編寫單元測試,最后進行不斷的測試和完善,關于數據的持久化,現在最好不要關注,盡量用 Mock 的方式模擬數據。

我先貼一下 JsPermissionApply 實體的部分代碼:

using CNBlogs.Apply.Domain.DomainEvents;
using CNBlogs.Apply.Domain.ValueObjects;
using System;
using Microsoft.Practices.Unity;
using CNBlogs.Apply.Infrastructure.IoC.Contracts;

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

        public JsPermissionApply()
        { }

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

        public int Id { get; private set; }

        public string Reason { get; private set; }

        public int UserId { 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 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 });
            }
        }
    }
}

JsPermissionApplyTest 單元測試的代碼:

using CNBlogs.Apply.Domain.DomainServices;
using CNBlogs.Apply.Domain.ValueObjects;
using CNBlogs.Apply.Infrastructure.Interfaces;
using CNBlogs.Apply.Infrastructure.IoC.Contracts;
using CNBlogs.Apply.Repository.Interfaces;
using System;
using System.Data.Entity;
using System.Threading.Tasks;
using Xunit;
using Microsoft.Practices.Unity;

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());
        }
    }
}

代碼我就不分析了,基本上是按照我們上面的設計方案實現的,本來想在倉儲層模擬數據的,但時間有限,還是使用的 EF 進行數據的持久化和訪問,完整的解決方案目錄:

開源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample

上面提交的代碼,只是一開始的實現,有些地方可能沒有考慮全面,代碼也可能會有些粗糙,但因為不是簡簡單單的示例 Demo,而是實際項目,所以后面我會不斷的進行完善,大家如果有什么意見或建議,歡迎探討~~~


實現領域驅動設計的方式有很多種,你可以戰略設計、你也可以戰術設計、你可以直接面向對象編寫代碼、你也可以和領域專家只畫一張白板圖、又或者寫一篇分析業務系統的文章,但就像埃文斯(Eric Evans)的書名一樣《領域驅動設計:軟件核心復雜性應對之道》,領域驅動設計的核心目的,就是應對軟件業務系統的復雜性,所以,不管哪種實現方式,只要能達到這個目的,那就是好的實現領域驅動設計的方式。

JS 權限申請和審核系統還沒完,下面又要加一個 Blog 地址更改申請和審核系統,它們會碰撞什么樣的火花呢?拭目以待吧。😏


文章列表




Avast logo

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


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

    IT工程師數位筆記本

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