這是一個講解DDD落地的文章系列,作者是《實現領域驅動設計》的譯者滕雲。本文章系列以一個真實的並已成功上線的軟體項目——碼如雲(https://www.mryqr.com)為例,系統性地講解DDD在落地實施過程中的各種典型實踐,以及在面臨實際業務場景時的諸多取捨。 本系列包含以下文章: DDD入門( ...
這是一個講解DDD落地的文章系列,作者是《實現領域驅動設計》的譯者滕雲。本文章系列以一個真實的並已成功上線的軟體項目——碼如雲(https://www.mryqr.com)為例,系統性地講解DDD在落地實施過程中的各種典型實踐,以及在面臨實際業務場景時的諸多取捨。
本系列包含以下文章:
案例項目介紹
既然DDD是“領域”驅動,那麼我們便不能拋開業務而只講技術,為此讓我們先從業務上瞭解一下貫穿本文章系列的案例項目 —— 碼如雲(不是馬雲,也不是碼雲)。如你已經在本系列的其他文章中瞭解過該案例,可跳過。
碼如雲是一個基於二維碼的一物一碼管理平臺,可以為每一件“物品”生成一個二維碼,並以該二維碼為入口展開對“物品”的相關操作,典型的應用場景包括固定資產管理、設備巡檢以及物品標簽等。
在使用碼如雲時,首先需要創建一個應用(App),一個應用包含了多個頁面(Page),也可稱為表單,一個頁面又可以包含多個控制項(Control),比如單選框控制項。應用創建好後,可在應用下創建多個實例(QR)用於表示被管理的對象(比如機器設備)。每個實例均對應一個二維碼,手機掃碼便可對實例進行相應操作,比如查看實例相關信息或者填寫頁面表單等,對錶單的一次填寫稱為提交(Submission);更多概念請參考碼如雲術語。
在技術上,碼如雲是一個無代碼平臺,包含了表單引擎、審批流程和數據報表等多個功能模塊。碼如雲全程採用DDD完成開發,其後端技術棧主要有Java、Spring Boot和MongoDB等。
碼如雲的源代碼是開源的,可以通過以下方式訪問:
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。這種方式存在以下問題:
- 業務邏輯的泄漏:對於維持“設置手機號”和“標記手機號已識別”同時發生的職責來說,本應該由Member對象自身完成的,結果泄漏到了Member對象的外部;
- 增加調用者的負擔:對於作為Member客戶方的
updateMyMobile()
和updateMemberMobile()
方法來講,他們本應該將Member當做一個黑盒,但在本例中卻需要瞭解Member的內部細節(先後調用setMobileNumber()
和setMobileIdentified()
方法),這無疑是調用者的負擔。 - 難於維護:如果以後業務需求有變,那麼需要同時修改
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對象之外,我們還會考慮如何設計和安排這些行為才最得當,比如將高內聚的mobileNumber
和mobileIdentified
放到同一個方法中,此時的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
自身完成的,只能通過一個可以跨多個Member
的MemberDomainService
完成。
對於諸如限流器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());
}
可以看到,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);
}
如前文所述,mobile
和mobileIdentified
是高度內聚的,因此放在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有個全景式的認識。