產品代碼都給你看了,可別再說不會DDD(一):DDD入門

来源:https://www.cnblogs.com/davenkin/archive/2023/08/12/ddd-introduction.html
-Advertisement-
Play Games

這是一個講解DDD落地的文章系列,作者是《實現領域驅動設計》的譯者滕雲。本文章系列以一個真實的並已成功上線的軟體項目——碼如雲(https://www.mryqr.com)為例,系統性地講解DDD在落地實施過程中的各種典型實踐,以及在面臨實際業務場景時的諸多取捨。 本系列包含以下文章: DDD入門( ...


這是一個講解DDD落地的文章系列,作者是《實現領域驅動設計》的譯者滕雲。本文章系列以一個真實的並已成功上線的軟體項目——碼如雲https://www.mryqr.com)為例,系統性地講解DDD在落地實施過程中的各種典型實踐,以及在面臨實際業務場景時的諸多取捨。

本系列包含以下文章:

  1. DDD入門(本文)
  2. DDD概念大白話
  3. 戰略設計
  4. 代碼工程結構
  5. 請求處理流程
  6. 聚合根與資源庫
  7. 實體與值對象
  8. 應用服務與領域服務
  9. 領域事件
  10. CQRS

案例項目介紹

既然DDD是“領域”驅動,那麼我們便不能拋開業務而只講技術,為此讓我們先從業務上瞭解一下貫穿本文章系列的案例項目 —— 碼如雲(不是馬雲,也不是碼雲)。如你已經在本系列的其他文章中瞭解過該案例,可跳過。

碼如雲是一個基於二維碼的一物一碼管理平臺,可以為每一件“物品”生成一個二維碼,並以該二維碼為入口展開對“物品”的相關操作,典型的應用場景包括固定資產管理、設備巡檢以及物品標簽等。

在使用碼如雲時,首先需要創建一個應用(App),一個應用包含了多個頁面(Page),也可稱為表單,一個頁面又可以包含多個控制項(Control),比如單選框控制項。應用創建好後,可在應用下創建多個實例(QR)用於表示被管理的對象(比如機器設備)。每個實例均對應一個二維碼,手機掃碼便可對實例進行相應操作,比如查看實例相關信息或者填寫頁面表單等,對錶單的一次填寫稱為提交(Submission);更多概念請參考碼如雲術語

在技術上,碼如雲是一個無代碼平臺,包含了表單引擎、審批流程和數據報表等多個功能模塊。碼如雲全程採用DDD完成開發,其後端技術棧主要有Java、Spring Boot和MongoDB等。

碼如雲的源代碼是開源的,可以通過以下方式訪問:

碼如雲源代碼:https://github.com/mryqr-com/mry-backend

DDD入門

本文是本系列的第一篇文章,主要講解DDD入門知識,如果你已經對DDD有所瞭解,可跳過本文。

在閱讀本文之前,你可能會認為DDD是整天做PPT的架構師們才應該去關註的東西;或者會認為DDD是比較頂層的東西,跟我寫代碼的程式員關係不大;你可能還會認為DDD是一種被咨詢師們吹得天花亂墜但是卻無法落地的概念炒作而已。在日常實踐中,我接觸過不懂裝懂的言必稱DDD者,也見識過聲稱DDD與編碼毫無關係的虛無主義者,當然也接觸過真正能將DDD落地者。在本系列文章中,我將向你證明,DDD正是軟體工程師的工具,可以用於編寫更好的代碼,設計更好的架構,進而做出更好的軟體。當然,我也會針對DDD中被誇大其詞的那部分進行澄清,甚至批評。

DDD是什麼呢?是架構思想?是方法論?還是軟體之道?從某種層度上說這些都對,但是對於程式員或者架構師來講,最接地氣的回答應該是:DDD是面向對象進階。對於寫了幾年代碼希望在職業生涯中更上一層樓的程式員來說,學習DDD是再適合不過的了。為了能讓DDD新手們更快地上手,我們還是以代碼為入口展開講解,首先讓我們來看看DDD項目代碼和非DDD項目代碼有何不同。

實現業務邏輯的三種方式

在案例項目碼如雲中有這樣一個業務需求:所有可登錄的用戶被稱為成員(Member),成員可以自行修改自己的手機號碼,修改後該成員將被標記為“手機號已識別”的狀態。為了實現這個需求,我們分別通過三種方式予以實現,讀者可以對照看看這些實現方式是不是和自己曾經的編碼方式有相似之處。

第一種: 事務腳本

對於上述需求,從純技術上講,我們希望達到的最終目的不過是在資料庫中的member表中更新2個欄位而已,一個是手機號(mobile_number)欄位,另一個是手機號已識別(mobile_identified)欄位。為了實現這個需求,最簡單直接的方式難道不是直接寫個SQL語句直接更新資料庫表麽?的確如此,這個簡單的方式其實有個專門的名詞 —— 事務腳本(Transactional Script),也即通過類似編寫腳本的方式完成一個業務用例,一個業務用例對應一次事務。

    @Transactional//事務邊界
    public void updateMyMobile(String mobileNumber, String memberId) {
        
        //採用事務腳本的方式,直接通過SQL語句實現業務邏輯
        String sql = "update member set mobile_number = ? , mobile_identified = 1 where id = ?;";
        jdbcTemplate.update(sql, mobileNumber,memberId);
    }

這種直接通過技術手段實現業務功能的方式沒有任何軟體建模可言,它將原本可以分開的業務性代碼和技術性代碼揉雜在一起,既不利於業務的重用,也不利於系統的長期演進,因此通常被認為只適合一些小型軟體項目。

第二種:貧血對象

看到第一種實現方式你可能會想:這都什麼年代了,還在像寫C語言那樣編寫代碼,不使用點兒面向對象技術連一個剛入職的畢業生估計都不好意思。那好吧,讓我們創建一個Member對象。

    @Transactional
    public void updateMyMobile(String mobileNumber) {
        String memberId = CurrentUserContext.getCurrentMemberId();
        Member member = memberRepository.findMemberById(memberId);

        //先後調用Member對象中的2個setter方法實現業務邏輯
        member.setMobileNumber(mobileNumber);
        member.setMobileIdentified(true);

        memberRepository.updateMember(member);
    }

在上例中,首先我們將資料庫訪問相關的邏輯全部封裝在memberRepository中,從而解決了“技術性代碼和業務性代碼揉雜”的問題。其次,創建了Member對象,其中包含兩個setter方法,setMobileNumber()用於設置手機號碼,setMobileIdentified()用於標記標記手機號已識別,這應該面向對象了吧?!但是,問題恰恰出在了這兩個setter方法上:此時的Member對象只是一個數據容器而已,而非真正的對象。這種只有數據沒有行為的對象被稱為貧血對象

問題還不止於此,本例中先後調用的兩個setter方法事實上違背了軟體開發的一個根本性原則 —— 內聚性。簡單來講,“設置手機號”和“標記手機號已識別”這兩個步驟在業務上是緊密聯繫在一起的,應該由Member中的單個方法完成,而不應該由2個獨立的方法完成。為瞭解釋這裡體現的內聚性,讓我們再來看個需求:除了成員自己可以修改手機號外,管理員也可以為任何成員設置手機號,為此我們再實現一個updateMemberMobile()方法。

    @Transactional
    public void updateMemberMobile(String mobileNumber,String memberId) {
        Member member = memberRepository.findMemberById(memberId);

        //與updateMyMobile()相同,需要先後調用Member對象中的2個setter方法實現業務邏輯
        member.setMobileNumber(mobileNumber);
        member.setMobileIdentified(true);

        memberRepository.updateMember(member);
    }

這裡,updateMemberMobile()方法也需要顯式地先後調用Member的setMobileNumber()setMobileIdentified()方法,也就是說編碼者需要記住必須同時調用2個方法,否則程式就會出Bug。這種方式存在以下問題:

  1. 業務邏輯的泄漏:對於維持“設置手機號”和“標記手機號已識別”同時發生的職責來說,本應該由Member對象自身完成的,結果泄漏到了Member對象的外部;
  2. 增加調用者的負擔:對於作為Member客戶方的updateMyMobile()updateMemberMobile()方法來講,他們本應該將Member當做一個黑盒,但在本例中卻需要瞭解Member的內部細節(先後調用setMobileNumber()setMobileIdentified()方法),這無疑是調用者的負擔。
  3. 難於維護:如果以後業務需求有變,那麼需要同時修改updateMyMobile()updateMemberMobile()2個方法,這可能不是能夠輕易做到的,特別是在人員流動頻繁的軟體項目中。

與事務腳本相似,貧血對象除了可用於一些小的軟體項目外,通常被認為是一種反模式,應該避免使用。

第三種:領域對象

領域對象是一個與貧血對象相對立的概念,它表示直接體現業務邏輯的一類對象,這類對象不僅包含業務數據,還包含業務行為。領域對象希望達到的理想狀態是:所有業務邏輯均由領域對象完成,外界將領域對象當做一個黑盒向其發送指令(調用方法)即可。在本例中,設置手機號的同時需要標記“手機號已識別”均屬業務邏輯,應該全部放到領域對象中完成。

    @Transactional
    public void updateMyMobile(String mobileNumber) {
        String memberId = CurrentUserContext.getCurrentMemberId();
        Member member = memberRepository.findMemberById(memberId);

        //只需調用Member種的updateMobile()方法即可
        member.updateMobile(mobileNumber);

        memberRepository.updateMember(member);
    }

這裡,updateMyMobile()方法只需調用Member中的updateMobile()方法即可,然後由Member自行處理具體的業務邏輯:

    //由Member對象自身處理同時更新mobileNumber和mobileIdentified欄位
    public void updateMobile(String mobileNumber) {
        this.mobileNumber = mobileNumber;
        this.mobileIdentified = true;
    }

在本例中,除了將數據和行為同時放到Member對象之外,我們還會考慮如何設計和安排這些行為才最得當,比如將高內聚的mobileNumbermobileIdentified放到同一個方法中,此時的Member便是一個行為飽滿的領域對象,並開始變得有些“領域驅動”的意味了,所謂的"DDD是面向對象進階"這個說法也正體現於此。事實上,在DDD中Member對象也被稱為聚合根,而“更新mobileNumber的同時需要一併更新mobileIdentified”則被稱為聚合根的不變條件,我們將在後續文章中對此做詳細講解。

看到這裡,你可能會問:領域對象的實現方式不就是將貧血對象中的業務邏輯實現挪了個位置嗎?的確,但是這一挪,便挪出了編程的講究與思考,挪出了模型的設計與原則,挪出了軟體的發展與進步。就像雲計算早年被認為不過是將本地的計算資源搬移到網路上一樣,我們將很多看似並不具有顛覆性的微小創新合在一起,便可將理想編織成一個個能夠為行業為社會帶來實際進步的美好現實。

你可能還會說,領域對象這種實現方式我平時就是這麼做的呀!?沒錯,我們平時編程的很多做法其實已經包含了DDD中的某些思想或實踐,因為DDD並不是什麼全新的東西要把你所寫的代碼全部推翻重來,而是很多具有邏輯歸因性的東西其實大家都能總結出來,只是那些大牛總結得比我們更早,更系統,更全面而已。

對於以上三種實現方式,我們在前面提到事務腳本和貧血對象只適合一些小型的軟體項目,那麼問題來了,到底多小才算小呢?這個問題沒有標準答案,就像你問微服務多小算小一樣,It depends!然而,但凡是企業中立過項的軟體項目,都不會是實現一個Code Kata這麼簡單,都不能被定義為“小型項目”。因此,對於幾乎所有企業級軟體系統來說,使用領域對象進而DDD都不會是個錯誤的選擇。

真實產品代碼

由於本文是入門性質的文章,故到目前為止所使用的代碼均不是碼如雲的產品代碼。接下來,讓我們來看看真實的產品代碼,對於“成員修改自己的手機號”的業務功能,碼如雲代碼庫中的實現如下:

    @Transactional
    public void changeMyMobile(ChangeMyMobileCommand command, User user) {
        //API限流器,與DDD無關,讀者可忽略
        mryRateLimiter.applyFor(user.getTenantId(), "Member:ChangeMyMobile", 5);

        //將所有請求相關的數據封裝到Command對象中
        String mobile = command.getMobile();

        //修改手機號時,需要驗證發往新手機號的驗證碼
        verificationCodeChecker.check(mobile, command.getVerification(), CHANGE_MOBILE);

        Member member = memberRepository.byId(user.getMemberId());

        //這裡調用了MemberDomainService中的方法,而不是直接調用Member,因為需要檢查手機號是否重覆,而Member自身無法完成該檢查
        memberDomainService.changeMyMobile(member, mobile, command.getPassword());

        memberRepository.save(member);
        log.info("Mobile changed by member[{}].", member.getId());
    }

源碼出處:com/mryqr/core/member/command/MemberCommandService.java

為了讓讀者能對代碼有更加詳盡的瞭解,我們在源代碼中加上了註釋,建議讀者通過閱讀這些註釋來理解代碼的意圖。(真實的碼如雲代碼庫中是很少有註釋的,因為我們堅持“代碼即是設計”的原則,讓代碼本身直接體現業務意圖)

在本例中,首先使用限流器MryRateLimiter對請求進行限流處理,然後使用VerificationCodeChecker對手機號驗證碼進行檢查,最後才調用MemberDomainService完成實際的業務邏輯。你可能有些納悶兒,為什麼不像前文中那樣直接調用Member對象中的方法,而是調用MemberDomainService呢?事實上,這裡的MemberDomainService在DDD中被稱為領域服務,用於處理領域對象自身無法處理的業務邏輯。在本例中,成員在修改手機號時,系統需要檢查該手機號是否已經被其他成員所占用,這部分邏輯是無法通過單個Member自身完成的,只能通過一個可以跨多個MemberMemberDomainService完成。

對於諸如限流器MryRateLimiter這些與DDD無關的代碼,我們將在後續文章的代碼中予以刪除,以使代碼集中在對DDD的闡述上。

MemberDomainService.changeMyMobile()方法實現如下:

    public void changeMyMobile(Member member, String newMobile, String password) {
        //修改手機號時,需要驗證密碼
        if (!mryPasswordEncoder.matches(password, member.getPassword())) {
            throw new MryException(PASSWORD_NOT_MATCH, "修改手機號失敗,密碼不正確。", "memberId", member.getId());
        }

        if (Objects.equals(member.getMobile(), newMobile)) {
            return;
        }

        //檢查手機號是否已被占用
        if (memberRepository.existsByMobile(newMobile)) {
            throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS, "修改手機號失敗,手機號對應成員已存在。",
                    mapOf("mobile", newMobile, "memberId", member.getId()));
        }

        //調用Member對象中的方法,完成對手機號的修改
        member.changeMobile(newMobile, member.toUser());
    }

源碼出處:com/mryqr/core/member/domain/MemberDomainService.java

可以看到,MemberDomainService調用了MemberRepository.existsByMobile()用於檢查手機號是否已經被占用,如果是,則拋出異常。

最後,MemberDomainService調用Member.changeMobile()方法完成對手機號的修改:

public void changeMobile(String mobile, User user) {
        if (Objects.equals(this.mobile, mobile)) {
            return;
        }

        //同時設置mobile欄位和mobileIdentified的值,高度內聚
        this.mobile = mobile;
        this.mobileIdentified = true;
        
        this.addOpsLog("修改手機號為[" + mobile + "]", user);
    }

源碼出處:com/mryqr/core/member/domain/Member.java

如前文所述,mobilemobileIdentified是高度內聚的,因此放在Member的同一個方法changeMobile()中完成更新。以後,無論通過什麼業務渠道修改成員的手機號,都只需要調用相同的Member.changeMobile()方法即可。

DDD書籍推薦

我基本上參閱完了市面上所有的DDD書籍(截止到2023年3月份),在這些書籍中,真正值得推崇的有以下4本書:

  • 《領域驅動設計:軟體核心複雜性應對之道》(藍皮書,從左往右第一本,首版時間2003年):DDD的開山之作,對於初學者來說閱讀起來有些晦澀,不建議初學者直接閱讀該書
  • 《實現領域驅動設計》(紅皮書,從左往右第二本,首版時間2013年):這本是講DDD落地的經典書籍,其中包含大量代碼示例,很多人都是通過這本書才真正進入DDD的世界
  • 《領域驅動設計模式、原理與實踐》(從左往右第三本,首版時間2015年):這也是一本能夠幫你系統的完成DDD落地的書籍
  • 《解構領域驅動設計》(首版時間2021年):國內第一本關於DDD的專著,作者張逸在DDD社區具有比較大的影響力

對於英文書籍,建議大家如果有條件的話,一定閱讀英文原版,因為那才是第一手資料,中文翻譯始終存在漏譯錯譯等無法表達原書本意的情況。

總結

本文從事務腳本、貧血對象和領域對象三種實現業務邏輯的方式為入口,一步一步地引入DDD的概念,希望能讓DDD新手們平滑地開啟DDD的學習之路。在下一篇:DDD概念大白話文章中,我們將通過大白話的方式給大家講解DDD中的各種概念,以讓讀者對DDD有個全景式的認識。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 就在今天,測試提一個BUG,是什麼呢?就是在計算商品採購價時,需要保留2位小數,當時是使用【Math.Round(採購價,2)】這種方法進行四捨五入的,但是這樣寫會有問題,至於什麼問題呢,來看看這篇文章就對了! ...
  • ## 前言 上兩篇文章分享了過濾器實現JWT進行鑒權,分別是通過授權過濾器和操作過濾器實現,這兩個過濾器也是最常用的。文章鏈接:[授權過濾器—MVC中使用授權過濾器實現JWT許可權認證](https://www.cnblogs.com/wml-it/p/17612434.html),[操作過濾器—MV ...
  • ## 前言 上一篇文章分享了授權過濾器實現JWT進行鑒權,文章鏈接:[授權過濾器—MVC中使用授權過濾器實現JWT許可權認證](https://www.cnblogs.com/wml-it/p/17612434.html),接下來將用操作過濾器實現昨天的JWT鑒權。 ## 一、什麼是操作過濾器? ​ ...
  • 本機環境:win10專業版,64位,16G記憶體。 原先用的AS2.2,是很早之前在看《第一行代碼Android(第2版)》的時候,按書里的鏈接下載安裝的,也不用怎麼配置。(PS:第一行代碼這本書對新手確實很適合,第1版是eclise,第2版是Android studio) 最近想給AS升級一下,果不 ...
  • 博客推行版本更新,成果積累制度,已經寫過的博客還會再次更新,不斷地琢磨,高質量高數量都是要追求的,工匠精神是學習必不可少的精神。因此,大家有何建議歡迎在評論區踴躍發言,你們的支持是我最大的動力,你們敢投,我就敢肝 ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 在切換詳情頁中有這麼一個場景,點擊上一條,會顯示上一條的詳情頁,同理,點擊下一條,會顯示下一條的詳情頁。 偽代碼如下所示: 我們定義了一個 switcher 模版, 用戶點擊上一條、下一條時調用 goToPreOrNext 方法。該頁面通 ...
  • 隨著 Web 應用的複雜性不斷增加,性能優化成為了開發人員必須面對的挑戰之一。Vue 路由懶載入是一項關鍵技術,它可以幫助我們提高 Web 應用的載入速度,從而提升用戶體驗。 在本篇技術博文中,我們將深入探討 Vue 路由懶載入的背景、原理以及使用方法。我們還將分享一些優化和進階技巧,幫助開發人員... ...
  • 倉庫地址:https://gitee.com/JSTGitee/element-jst-admin 登錄 首頁 表格 前言 該方案作為一套多功能的後臺框架模板,適用於絕大部分的後臺管理系統開發。基於 Vue2,使用 vue-cli2 腳手架,引用 Element ui 組件庫,方便開發快速簡潔好看的 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...