這是一個講解DDD落地的文章系列,作者是《實現(xiàn)領(lǐng)域驅(qū)動設(shè)計》的譯者滕云。本文章系列以一個真實的并已成功上線的軟件項目——碼如云(https://www.mryqr.com)為例,系統(tǒng)性地講解DDD在落地實施過程中的各種典型實踐,以及在面臨實際業(yè)務(wù)場景時的諸多取舍。
本系列包含以下文章:
- DDD入門(本文)
- DDD概念大白話
- 戰(zhàn)略設(shè)計
- 代碼工程結(jié)構(gòu)
- 請求處理流程
- 聚合根與資源庫
- 實體與值對象
- 應(yīng)用服務(wù)與領(lǐng)域服務(wù)
- 領(lǐng)域事件
- CQRS
案例項目介紹
既然DDD是“領(lǐng)域”驅(qū)動,那么我們便不能拋開業(yè)務(wù)而只講技術(shù),為此讓我們先從業(yè)務(wù)上了解一下貫穿本文章系列的案例項目 ——?碼如云(不是馬云,也不是碼云)。如你已經(jīng)在本系列的其他文章中了解過該案例,可跳過。
碼如云是一個基于二維碼的一物一碼管理平臺,可以為每一件“物品”生成一個二維碼,并以該二維碼為入口展開對“物品”的相關(guān)操作,典型的應(yīng)用場景包括固定資產(chǎn)管理、設(shè)備巡檢以及物品標(biāo)簽等。
在使用碼如云時,首先需要創(chuàng)建一個應(yīng)用(App),一個應(yīng)用包含了多個頁面(Page),也可稱為表單,一個頁面又可以包含多個控件(Control),比如單選框控件。應(yīng)用創(chuàng)建好后,可在應(yīng)用下創(chuàng)建多個實例(QR)用于表示被管理的對象(比如機(jī)器設(shè)備)。每個實例均對應(yīng)一個二維碼,手機(jī)掃碼便可對實例進(jìn)行相應(yīng)操作,比如查看實例相關(guān)信息或者填寫頁面表單等,對表單的一次填寫稱為提交(Submission);更多概念請參考碼如云術(shù)語。
在技術(shù)上,碼如云是一個無代碼平臺,包含了表單引擎、審批流程和數(shù)據(jù)報表等多個功能模塊。碼如云全程采用DDD完成開發(fā),其后端技術(shù)棧主要有Java、Spring Boot和MongoDB等。
碼如云的源代碼是開源的,可以通過以下方式訪問:
碼如云源代碼:https://github.com/mryqr-com/mry-backend
DDD入門
本文是本系列的第一篇文章,主要講解DDD入門知識,如果你已經(jīng)對DDD有所了解,可跳過本文。
在閱讀本文之前,你可能會認(rèn)為DDD是整天做PPT的架構(gòu)師們才應(yīng)該去關(guān)注的東西;或者會認(rèn)為DDD是比較頂層的東西,跟我寫代碼的程序員關(guān)系不大;你可能還會認(rèn)為DDD是一種被咨詢師們吹得天花亂墜但是卻無法落地的概念炒作而已。在日常實踐中,我接觸過不懂裝懂的言必稱DDD者,也見識過聲稱DDD與編碼毫無關(guān)系的虛無主義者,當(dāng)然也接觸過真正能將DDD落地者。在本系列文章中,我將向你證明,DDD正是軟件工程師的工具,可以用于編寫更好的代碼,設(shè)計更好的架構(gòu),進(jìn)而做出更好的軟件。當(dāng)然,我也會針對DDD中被夸大其詞的那部分進(jìn)行澄清,甚至批評。
DDD是什么呢?是架構(gòu)思想?是方法論?還是軟件之道?從某種層度上說這些都對,但是對于程序員或者架構(gòu)師來講,最接地氣的回答應(yīng)該是:DDD是面向?qū)ο筮M(jìn)階。對于寫了幾年代碼希望在職業(yè)生涯中更上一層樓的程序員來說,學(xué)習(xí)DDD是再適合不過的了。為了能讓DDD新手們更快地上手,我們還是以代碼為入口展開講解,首先讓我們來看看DDD項目代碼和非DDD項目代碼有何不同。
實現(xiàn)業(yè)務(wù)邏輯的三種方式
在案例項目碼如云中有這樣一個業(yè)務(wù)需求:所有可登錄的用戶被稱為成員(Member),成員可以自行修改自己的手機(jī)號碼,修改后該成員將被標(biāo)記為“手機(jī)號已識別”的狀態(tài)。為了實現(xiàn)這個需求,我們分別通過三種方式予以實現(xiàn),讀者可以對照看看這些實現(xiàn)方式是不是和自己曾經(jīng)的編碼方式有相似之處。
第一種: 事務(wù)腳本
對于上述需求,從純技術(shù)上講,我們希望達(dá)到的最終目的不過是在數(shù)據(jù)庫中的member
表中更新2個字段而已,一個是手機(jī)號(mobile_number
)字段,另一個是手機(jī)號已識別(mobile_identified
)字段。為了實現(xiàn)這個需求,最簡單直接的方式難道不是直接寫個SQL語句直接更新數(shù)據(jù)庫表么?的確如此,這個簡單的方式其實有個專門的名詞 ——?事務(wù)腳本(Transactional Script),也即通過類似編寫腳本的方式完成一個業(yè)務(wù)用例,一個業(yè)務(wù)用例對應(yīng)一次事務(wù)。
@Transactional//事務(wù)邊界
public void updateMyMobile(String mobileNumber, String memberId) {
//采用事務(wù)腳本的方式,直接通過SQL語句實現(xiàn)業(yè)務(wù)邏輯
String sql = "update member set mobile_number = ? , mobile_identified = 1 where id = ?;";
jdbcTemplate.update(sql, mobileNumber,memberId);
}
這種直接通過技術(shù)手段實現(xiàn)業(yè)務(wù)功能的方式?jīng)]有任何軟件建模可言,它將原本可以分開的業(yè)務(wù)性代碼和技術(shù)性代碼揉雜在一起,既不利于業(yè)務(wù)的重用,也不利于系統(tǒng)的長期演進(jìn),因此通常被認(rèn)為只適合一些小型軟件項目。
第二種:貧血對象
看到第一種實現(xiàn)方式你可能會想:這都什么年代了,還在像寫C語言那樣編寫代碼,不使用點兒面向?qū)ο蠹夹g(shù)連一個剛?cè)肼毜漠厴I(yè)生估計都不好意思。那好吧,讓我們創(chuàng)建一個Member對象。
@Transactional
public void updateMyMobile(String mobileNumber) {
String memberId = CurrentUserContext.getCurrentMemberId();
Member member = memberRepository.findMemberById(memberId);
//先后調(diào)用Member對象中的2個setter方法實現(xiàn)業(yè)務(wù)邏輯
member.setMobileNumber(mobileNumber);
member.setMobileIdentified(true);
memberRepository.updateMember(member);
}
在上例中,首先我們將數(shù)據(jù)庫訪問相關(guān)的邏輯全部封裝在memberRepository
中,從而解決了“技術(shù)性代碼和業(yè)務(wù)性代碼揉雜”的問題。其次,創(chuàng)建了Member
對象,其中包含兩個setter方法,setMobileNumber()
用于設(shè)置手機(jī)號碼,setMobileIdentified()
用于標(biāo)記標(biāo)記手機(jī)號已識別,這應(yīng)該面向?qū)ο罅税??!但是,問題恰恰出在了這兩個setter方法上:此時的Member
對象只是一個數(shù)據(jù)容器而已,而非真正的對象。這種只有數(shù)據(jù)沒有行為的對象被稱為貧血對象。
問題還不止于此,本例中先后調(diào)用的兩個setter方法事實上違背了軟件開發(fā)的一個根本性原則 —— 內(nèi)聚性。簡單來講,“設(shè)置手機(jī)號”和“標(biāo)記手機(jī)號已識別”這兩個步驟在業(yè)務(wù)上是緊密聯(lián)系在一起的,應(yīng)該由Member中的單個方法完成,而不應(yīng)該由2個獨立的方法完成。為了解釋這里體現(xiàn)的內(nèi)聚性,讓我們再來看個需求:除了成員自己可以修改手機(jī)號外,管理員也可以為任何成員設(shè)置手機(jī)號,為此我們再實現(xiàn)一個updateMemberMobile()
方法。
@Transactional
public void updateMemberMobile(String mobileNumber,String memberId) {
Member member = memberRepository.findMemberById(memberId);
//與updateMyMobile()相同,需要先后調(diào)用Member對象中的2個setter方法實現(xiàn)業(yè)務(wù)邏輯
member.setMobileNumber(mobileNumber);
member.setMobileIdentified(true);
memberRepository.updateMember(member);
}
這里,updateMemberMobile()
方法也需要顯式地先后調(diào)用Member的setMobileNumber()
和setMobileIdentified()
方法,也就是說編碼者需要記住必須同時調(diào)用2個方法,否則程序就會出Bug。這種方式存在以下問題:
- 業(yè)務(wù)邏輯的泄漏:對于維持“設(shè)置手機(jī)號”和“標(biāo)記手機(jī)號已識別”同時發(fā)生的職責(zé)來說,本應(yīng)該由Member對象自身完成的,結(jié)果泄漏到了Member對象的外部;
- 增加調(diào)用者的負(fù)擔(dān):對于作為Member客戶方的
updateMyMobile()
和updateMemberMobile()
方法來講,他們本應(yīng)該將Member當(dāng)做一個黑盒,但在本例中卻需要了解Member的內(nèi)部細(xì)節(jié)(先后調(diào)用setMobileNumber()
和setMobileIdentified()
方法),這無疑是調(diào)用者的負(fù)擔(dān)。 - 難于維護(hù):如果以后業(yè)務(wù)需求有變,那么需要同時修改
updateMyMobile()
和updateMemberMobile()
2個方法,這可能不是能夠輕易做到的,特別是在人員流動頻繁的軟件項目中。
與事務(wù)腳本相似,貧血對象除了可用于一些小的軟件項目外,通常被認(rèn)為是一種反模式,應(yīng)該避免使用。
第三種:領(lǐng)域?qū)ο?/h3>
領(lǐng)域?qū)ο?/strong>是一個與貧血對象相對立的概念,它表示直接體現(xiàn)業(yè)務(wù)邏輯的一類對象,這類對象不僅包含業(yè)務(wù)數(shù)據(jù),還包含業(yè)務(wù)行為。領(lǐng)域?qū)ο笙M_(dá)到的理想狀態(tài)是:所有業(yè)務(wù)邏輯均由領(lǐng)域?qū)ο笸瓿?,外界將領(lǐng)域?qū)ο螽?dāng)做一個黑盒向其發(fā)送指令(調(diào)用方法)即可。在本例中,設(shè)置手機(jī)號的同時需要標(biāo)記“手機(jī)號已識別”均屬業(yè)務(wù)邏輯,應(yīng)該全部放到領(lǐng)域?qū)ο笾型瓿伞?/p>
這里, 在本例中,除了將數(shù)據(jù)和行為同時放到Member對象之外,我們還會考慮如何設(shè)計和安排這些行為才最得當(dāng),比如將高內(nèi)聚的 看到這里,你可能會問:領(lǐng)域?qū)ο蟮膶崿F(xiàn)方式不就是將貧血對象中的業(yè)務(wù)邏輯實現(xiàn)挪了個位置嗎?的確,但是這一挪,便挪出了編程的講究與思考,挪出了模型的設(shè)計與原則,挪出了軟件的發(fā)展與進(jìn)步。就像云計算早年被認(rèn)為不過是將本地的計算資源搬移到網(wǎng)絡(luò)上一樣,我們將很多看似并不具有顛覆性的微小創(chuàng)新合在一起,便可將理想編織成一個個能夠為行業(yè)為社會帶來實際進(jìn)步的美好現(xiàn)實。 你可能還會說,領(lǐng)域?qū)ο筮@種實現(xiàn)方式我平時就是這么做的呀?。繘]錯,我們平時編程的很多做法其實已經(jīng)包含了DDD中的某些思想或?qū)嵺`,因為DDD并不是什么全新的東西要把你所寫的代碼全部推翻重來,而是很多具有邏輯歸因性的東西其實大家都能總結(jié)出來,只是那些大牛總結(jié)得比我們更早,更系統(tǒng),更全面而已。 對于以上三種實現(xiàn)方式,我們在前面提到事務(wù)腳本和貧血對象只適合一些小型的軟件項目,那么問題來了,到底多小才算小呢?這個問題沒有標(biāo)準(zhǔn)答案,就像你問微服務(wù)多小算小一樣,It depends!然而,但凡是企業(yè)中立過項的軟件項目,都不會是實現(xiàn)一個Code Kata這么簡單,都不能被定義為“小型項目”。因此,對于幾乎所有企業(yè)級軟件系統(tǒng)來說,使用領(lǐng)域?qū)ο筮M(jìn)而DDD都不會是個錯誤的選擇。 由于本文是入門性質(zhì)的文章,故到目前為止所使用的代碼均不是碼如云的產(chǎn)品代碼。接下來,讓我們來看看真實的產(chǎn)品代碼,對于“成員修改自己的手機(jī)號”的業(yè)務(wù)功能,碼如云代碼庫中的實現(xiàn)如下: 源碼出處:com/mryqr/core/member/command/MemberCommandService.java 為了讓讀者能對代碼有更加詳盡的了解,我們在源代碼中加上了注釋,建議讀者通過閱讀這些注釋來理解代碼的意圖。(真實的碼如云代碼庫中是很少有注釋的,因為我們堅持“代碼即是設(shè)計”的原則,讓代碼本身直接體現(xiàn)業(yè)務(wù)意圖) 在本例中,首先使用限流器 對于諸如限流器 源碼出處:com/mryqr/core/member/domain/MemberDomainService.java 可以看到, 最后, 源碼出處:com/mryqr/core/member/domain/Member.java 如前文所述, 我基本上參閱完了市面上所有的DDD書籍(截止到2023年3月份),在這些書籍中,真正值得推崇的有以下4本書: 對于英文書籍,建議大家如果有條件的話,一定閱讀英文原版,因為那才是第一手資料,中文翻譯始終存在漏譯錯譯等無法表達(dá)原書本意的情況。文章來源:http://www.zghlxwxcb.cn/news/detail-642057.html 本文從事務(wù)腳本、貧血對象和領(lǐng)域?qū)ο笕N實現(xiàn)業(yè)務(wù)邏輯的方式為入口,一步一步地引入DDD的概念,希望能讓DDD新手們平滑地開啟DDD的學(xué)習(xí)之路。在下一篇:DDD概念大白話文章中,我們將通過大白話的方式給大家講解DDD中的各種概念,以讓讀者對DDD有個全景式的認(rèn)識。文章來源地址http://www.zghlxwxcb.cn/news/detail-642057.html 到了這里,關(guān)于產(chǎn)品代碼都給你看了,可別再說不會DDD(一):DDD入門的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)! @Transactional
public void updateMyMobile(String mobileNumber) {
String memberId = CurrentUserContext.getCurrentMemberId();
Member member = memberRepository.findMemberById(memberId);
//只需調(diào)用Member種的updateMobile()方法即可
member.updateMobile(mobileNumber);
memberRepository.updateMember(member);
}
updateMyMobile()
方法只需調(diào)用Member中的updateMobile()
方法即可,然后由Member自行處理具體的業(yè)務(wù)邏輯: //由Member對象自身處理同時更新mobileNumber和mobileIdentified字段
public void updateMobile(String mobileNumber) {
this.mobileNumber = mobileNumber;
this.mobileIdentified = true;
}
mobileNumber
和mobileIdentified
放到同一個方法中,此時的Member便是一個行為飽滿的領(lǐng)域?qū)ο螅㈤_始變得有些“領(lǐng)域驅(qū)動”的意味了,所謂的"DDD是面向?qū)ο筮M(jìn)階"這個說法也正體現(xiàn)于此。事實上,在DDD中Member對象也被稱為聚合根,而“更新mobileNumber
的同時需要一并更新mobileIdentified
”則被稱為聚合根的不變條件,我們將在后續(xù)文章中對此做詳細(xì)講解。真實產(chǎn)品代碼
@Transactional
public void changeMyMobile(ChangeMyMobileCommand command, User user) {
//API限流器,與DDD無關(guān),讀者可忽略
mryRateLimiter.applyFor(user.getTenantId(), "Member:ChangeMyMobile", 5);
//將所有請求相關(guān)的數(shù)據(jù)封裝到Command對象中
String mobile = command.getMobile();
//修改手機(jī)號時,需要驗證發(fā)往新手機(jī)號的驗證碼
verificationCodeChecker.check(mobile, command.getVerification(), CHANGE_MOBILE);
Member member = memberRepository.byId(user.getMemberId());
//這里調(diào)用了MemberDomainService中的方法,而不是直接調(diào)用Member,因為需要檢查手機(jī)號是否重復(fù),而Member自身無法完成該檢查
memberDomainService.changeMyMobile(member, mobile, command.getPassword());
memberRepository.save(member);
log.info("Mobile changed by member[{}].", member.getId());
}
MryRateLimiter
對請求進(jìn)行限流處理,然后使用VerificationCodeChecker
對手機(jī)號驗證碼進(jìn)行檢查,最后才調(diào)用MemberDomainService
完成實際的業(yè)務(wù)邏輯。你可能有些納悶兒,為什么不像前文中那樣直接調(diào)用Member
對象中的方法,而是調(diào)用MemberDomainService
呢?事實上,這里的MemberDomainService
在DDD中被稱為領(lǐng)域服務(wù),用于處理領(lǐng)域?qū)ο笞陨頍o法處理的業(yè)務(wù)邏輯。在本例中,成員在修改手機(jī)號時,系統(tǒng)需要檢查該手機(jī)號是否已經(jīng)被其他成員所占用,這部分邏輯是無法通過單個Member
自身完成的,只能通過一個可以跨多個Member
的MemberDomainService
完成。MryRateLimiter
這些與DDD無關(guān)的代碼,我們將在后續(xù)文章的代碼中予以刪除,以使代碼集中在對DDD的闡述上。MemberDomainService.changeMyMobile()
方法實現(xiàn)如下: public void changeMyMobile(Member member, String newMobile, String password) {
//修改手機(jī)號時,需要驗證密碼
if (!mryPasswordEncoder.matches(password, member.getPassword())) {
throw new MryException(PASSWORD_NOT_MATCH, "修改手機(jī)號失敗,密碼不正確。", "memberId", member.getId());
}
if (Objects.equals(member.getMobile(), newMobile)) {
return;
}
//檢查手機(jī)號是否已被占用
if (memberRepository.existsByMobile(newMobile)) {
throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS, "修改手機(jī)號失敗,手機(jī)號對應(yīng)成員已存在。",
mapOf("mobile", newMobile, "memberId", member.getId()));
}
//調(diào)用Member對象中的方法,完成對手機(jī)號的修改
member.changeMobile(newMobile, member.toUser());
}
MemberDomainService
調(diào)用了MemberRepository.existsByMobile()
用于檢查手機(jī)號是否已經(jīng)被占用,如果是,則拋出異常。MemberDomainService
調(diào)用Member.changeMobile()
方法完成對手機(jī)號的修改:public void changeMobile(String mobile, User user) {
if (Objects.equals(this.mobile, mobile)) {
return;
}
//同時設(shè)置mobile字段和mobileIdentified的值,高度內(nèi)聚
this.mobile = mobile;
this.mobileIdentified = true;
this.addOpsLog("修改手機(jī)號為[" + mobile + "]", user);
}
mobile
和mobileIdentified
是高度內(nèi)聚的,因此放在Member
的同一個方法changeMobile()
中完成更新。以后,無論通過什么業(yè)務(wù)渠道修改成員的手機(jī)號,都只需要調(diào)用相同的Member.changeMobile()
方法即可。DDD書籍推薦
總結(jié)