長期以來我都在實(shí)踐OOP脚祟,進(jìn)而通過OOP來實(shí)現(xiàn)DDD寓涨,通過面向?qū)ο蟮募记蓙斫⒁粋€領(lǐng)域模型。OO的一些特性在建立領(lǐng)域模型時顯得恰如其分,能否掌握OO的技巧炕檩,對創(chuàng)建領(lǐng)域模型有著至關(guān)重要的作用。
這篇文章為大家介紹一種常見的函數(shù)式架構(gòu)揩悄,特別是如何通過函數(shù)式語言實(shí)現(xiàn)DDD丈积,進(jìn)而利用函數(shù)式組合的特性,創(chuàng)建函數(shù)pipeline惠拭。
軟件架構(gòu)是圍繞著領(lǐng)域模型而做的若干設(shè)計(jì)扩劝,如果按照C4模型的定義,軟件架構(gòu)由下面四個級別的架構(gòu)組成的:
- “System context”是最高層的架構(gòu),代表著整個系統(tǒng)
- “Container”是組成”System context”的單元棒呛,通常用來表示可部署的單元聂示,例如一個”API service”, 一個web應(yīng)用程序等
- “Component”是組成”Container”的基本單元,通常指組若干抽象組件簇秒,是一個”Container”里面的骨架鱼喉,也是本文要重點(diǎn)介紹的架構(gòu)
- “Code”具體到了代碼級別,通常指實(shí)現(xiàn)某個”Component”應(yīng)該有哪幾個類組成
使用單體應(yīng)用來承載多個限界上下文
領(lǐng)域驅(qū)動設(shè)計(jì)中有一半概念是在討論問題域趋观,并不是一上來就教你如何寫代碼蒲凶,這說明理解一個問題域是復(fù)雜的,看清問題的本質(zhì)是需要時間的拆内。當(dāng)你開始著手劃分限界上下文的時候旋圆,說明你已經(jīng)對需求有了很好的了解。但是經(jīng)驗(yàn)告訴我們麸恍,剛開始你的理解灵巧,往往都不是最終的需求,或者仍然需要多次跟領(lǐng)域?qū)<掖_認(rèn)和交互抹沪,才能得到最終的需求刻肄。
這個時候,如果你一上來就按照限界上下文劃分微服務(wù)融欧,往往可能會步入Microservice Premium敏弃。
要想軟件在一開始就能達(dá)到快速試錯的目的,一上來就做微服務(wù), 會讓步子邁得有點(diǎn)大噪馏。微服務(wù)架構(gòu)帶來了分布式的復(fù)雜性麦到,使得前期生產(chǎn)效率大大降低,另外還存在船大難掉頭的情況欠肾,一旦設(shè)計(jì)出現(xiàn)返工瓶颠,生產(chǎn)效率也會打折扣。當(dāng)然刺桃,這不是絕對的粹淋,如果架構(gòu)師已經(jīng)在該行業(yè)深耕多年,對業(yè)務(wù)更是了如指掌瑟慈,項(xiàng)目一開始就設(shè)計(jì)為微服務(wù)也未嘗不可桃移。
在項(xiàng)目初期,在需求還不是非常明確的時候葛碧,你完全可以創(chuàng)建一個單體應(yīng)用借杰,然后通過不同的模塊或程序集來隔離不同的界限上下文,通過不斷的試錯和快速反饋來調(diào)整你的解決方案吹埠。
一種比較嚴(yán)格的說法是第步,當(dāng)你關(guān)閉其中一個微服務(wù)疮装,如果整個應(yīng)用程序都崩了,其實(shí)你設(shè)計(jì)的不是一個微服務(wù)架構(gòu)粘都,而是一個分布式單體應(yīng)用程序廓推。
代碼結(jié)構(gòu)
在過去的若干年里,我經(jīng)常使用一種叫“Layer architecture”的軟件架構(gòu), 這種架構(gòu)往往把代碼分成若干層:
- 基礎(chǔ)設(shè)施層:通常用來負(fù)責(zé)跟第三方或者數(shù)據(jù)庫打交道翩隧,用來持久化數(shù)據(jù)或者API請求樊展。
- 領(lǐng)域?qū)踊蛘邩I(yè)務(wù)邏輯層:用來封裝業(yè)務(wù)邏輯
- 應(yīng)用程序?qū)樱和ǔJ呛鼙〉囊粚樱脕韰f(xié)調(diào)領(lǐng)域?qū)雍突A(chǔ)設(shè)施層
- 展現(xiàn)層:用來展現(xiàn)UI或者輸出API結(jié)果
這種架構(gòu)方式是一個自上往下的輸入堆生,最后從下往上輸出結(jié)果的工作流(圖1)
- 實(shí)際上专缠,當(dāng)我在使用這種方式組織代碼時,遇到最大的挑戰(zhàn)在于:這種分層方式淑仆,把同一個輸入到輸出的的若干部分涝婉,橫向的分散到了若干層中。當(dāng)你需要修改某個API時蔗怠,需要同時修改若干個層墩弯。另外這種組織代碼的方式,往往會讓OO走向混亂寞射,一個名叫OrderApplicationService的類中放滿了各種跟Order相關(guān)的方法渔工,通常對Order的操作有數(shù)十種之多,他們屬于OrderApplicationService嗎桥温?如果屬于引矩,任何一個跟Order相關(guān)操作的參數(shù)變化,都會引起這個類被改動侵浸,這種對類的頻繁修改合理嗎旺韭?
- 函數(shù)式編程中,更傾向于縱向組織代碼(圖2)通惫,例如一個API操作茂翔,就是一個文件或者模塊混蔼,整個操作自上而下的流程被組織到同一個文件里履腋,這樣做的好處是,針對某個功能的修改惭嚣,只關(guān)注與當(dāng)前工作流相關(guān)的文件即可遵湖。
信任邊界
在問題域里,各種業(yè)務(wù)之間的邊界是模糊的晚吞,限界上下文則是業(yè)務(wù)在解決方案上的映射延旧,是人為劃分的邊界。在邊界里面的內(nèi)容槽地,是可信任和合法的迁沫,相反芦瘾,界限外面的一切輸入,則是非法和不可信任的(圖3)集畅。
這就要求我們在限界上下文的邊界近弟,引入驗(yàn)證邏輯,從而阻止外部輸入挺智,以及驗(yàn)證對外部的輸出祷愉。
常見的驗(yàn)證邏輯如:
- 輸入DTO,需要轉(zhuǎn)化為領(lǐng)域模型赦颇,用于處理業(yè)務(wù)邏輯
- 對輸入數(shù)據(jù)的合法性驗(yàn)證二鳄,例如:用戶名不能為空,郵件格式是否正確
- 對輸出類型的安全性校驗(yàn)媒怯,例如:防止在輸出數(shù)據(jù)里包含用戶密碼等敏感信息
驗(yàn)證邏輯并不是FP獨(dú)有的订讼,不過FP中常常使用Applicative對數(shù)據(jù)進(jìn)行驗(yàn)證,從而收集多個用戶Error扇苞。關(guān)于Applicative, 以后會單獨(dú)寫文章介紹躯嫉。
一旦輸入數(shù)據(jù)突破信任邊界,在領(lǐng)域模型建模的過程中杨拐,你不需要擔(dān)心用戶名是否是空祈餐,郵件格式是否正確等問題。你應(yīng)該專注于使用FP的代數(shù)數(shù)據(jù)類型進(jìn)行領(lǐng)域建模哄陶,請參考我之前寫過一篇使用函數(shù)式語言來建立領(lǐng)域模型—類型組合帆阳。
對輸出的驗(yàn)證則不太一樣,主要關(guān)心對輸出數(shù)據(jù)的安全性保護(hù)屋吨,防止將一些領(lǐng)域模型中的私有屬性輸出到外部世界蜒谤。
通過狀態(tài)機(jī)來處理業(yè)務(wù)邏輯
縱然,通過FP的代數(shù)數(shù)據(jù)類型(Algebraic data type)能夠快速完成領(lǐng)域建模至扰,但是我們知道鳍徽,領(lǐng)域模型不是靜態(tài)的,它是由一些列事件組成的過程敢课。而這種轉(zhuǎn)化過程阶祭,正是領(lǐng)域模型狀態(tài)發(fā)生變化的過程,即狀態(tài)機(jī)(圖4)直秆。
領(lǐng)域模型狀態(tài)轉(zhuǎn)換的過程跟實(shí)現(xiàn)語言無關(guān)濒募,一個設(shè)計(jì)精良的領(lǐng)域模型,就好比一個狀態(tài)機(jī)圾结。例如在買機(jī)票的過程中瑰剃,填寫個人信息,填寫聯(lián)系人筝野,選座晌姚,買保險(xiǎn)和付款的過程粤剧,就是訂單狀態(tài)發(fā)生變化的過程。再比如用戶注冊的過程挥唠,填寫基本信息俊扳,驗(yàn)證郵箱,也是用戶信息狀態(tài)發(fā)生變化的過程猛遍。以O(shè)O為例馋记,我們習(xí)慣于通過增加標(biāo)志位的方式,進(jìn)行領(lǐng)域建模:
type User = {
name: string
password: string
email: Email | null
isEmailVerified: boolean //當(dāng)驗(yàn)證完email后設(shè)置為true
canLogin: boolean //當(dāng)email被驗(yàn)證后方可login
}
業(yè)務(wù)邏輯的實(shí)現(xiàn)過程懊烤,就是填充用戶屬性和修改標(biāo)志位的過程梯醒。然而,這種方式實(shí)際上存在若干問題:
- 有些屬性在業(yè)務(wù)前期是不需要的腌紧,例如canLogin, 只有驗(yàn)證完email才有效
- 有些標(biāo)志位實(shí)際上不是單獨(dú)存在的茸习,例如isPhoneVerified就跟phone是緊密相關(guān)的,而這個模型無法反映出來這一信息
- phone和email被定義為可空類型壁肋,導(dǎo)致使用該模型的地方不得不使用null檢查
通過狀態(tài)機(jī)的機(jī)制号胚,重新考慮用戶注冊過程:(圖5)
- 按照上面的狀態(tài)重新對用戶建模,得到的模型如下:
type UnVerifiedUser = {
name: string
password: string
}
type VerifiedEmailUser = {
name: string
password: string
email: Email
}
type User =
| UnVerifiedUser
| VerifiedEmailUser
如果有更多的用戶狀態(tài)浸遗,你還可以持續(xù)添加到User類型中猫胁。
這種通過”|”創(chuàng)建的User類型被稱為在FP中被稱為union類型,也叫product或sum類型, 在TypeScript被稱為Discriminated union跛锌。這時候的User類型弃秆,可以用來在領(lǐng)域模型中實(shí)現(xiàn)領(lǐng)域邏輯,通常這種union類型需要配合模式匹配來完成髓帽,例如修改密碼菠赚,登錄,修改郵件地址等邏輯郑藏,都是針對User類型做模式匹配的過程衡查。關(guān)于模式匹配的用法,在此不再細(xì)說必盖。
這種通過狀態(tài)機(jī)的方式拌牲,實(shí)現(xiàn)業(yè)務(wù)邏輯時有下面幾個好處:
- 業(yè)務(wù)模型在不同的狀態(tài),提供不同的業(yè)務(wù)能力
- 模式匹配會強(qiáng)制你處理每種狀態(tài)的行為筑悴,避免遺漏一些邊邊角角的情況
- 相比于將所有狀態(tài)記錄在同一個模型中们拙,狀態(tài)機(jī)可以幫你梳理整個業(yè)務(wù)狀態(tài)的變化
保持純凈的領(lǐng)域模型
函數(shù)式編程的一個主要目標(biāo)就是讓代碼有預(yù)測性,通過函數(shù)簽名理解函數(shù)的用途阁吝。
為了達(dá)到這個目的,函數(shù)式語言設(shè)計(jì)了若干特性械拍,例如不可變的數(shù)據(jù)結(jié)構(gòu)突勇,還有各類Monad來避免副作用装盯。
在DDD實(shí)踐中,應(yīng)該避免I/O相關(guān)的代碼出現(xiàn)Domain中甲馋。例如讀寫數(shù)據(jù)庫埂奈,調(diào)用第三方系統(tǒng)的API等相關(guān)代碼,需要把這類具有副作用的代碼推到Domain的外圍定躏。
如果需要做的更好账磺,那就必須使用CQRS加Event Sourcing。我在之前一篇文章提到過這個觀點(diǎn)痊远,不過部分讀者沒有理解其中的意思垮抗,我在這里再做一些說明。
首先碧聪,CQRS不僅僅是為了讀寫分離冒版,從而提高讀寫性能。讀模型和寫模型(領(lǐng)域模型)的分離意味著職責(zé)也是分離的逞姿,從而在設(shè)計(jì)領(lǐng)域模型的時候辞嗡,打消對查詢性能的考慮,有助于設(shè)計(jì)出純凈的領(lǐng)域模型滞造。
當(dāng)然僅靠CQRS還是不夠的续室,有些時候任然無法完全脫離數(shù)據(jù)庫的考慮,因?yàn)轭I(lǐng)域模型始終是要持久化在數(shù)據(jù)庫里谒养,你就要考慮數(shù)據(jù)庫相關(guān)的約束猎贴,例如主外鍵,如何建表蝴光,如何高效存儲一個列表等她渴。而持久化一個Event則完全擺脫了數(shù)據(jù)庫技術(shù),因?yàn)橐粋€Event就是一個json, 只有這樣才能設(shè)計(jì)出理想的領(lǐng)域模型蔑祟。當(dāng)然引入CQRS和ES在項(xiàng)目初期成本略高趁耗,不再詳細(xì)描述。
通過Monad創(chuàng)建pipeline
以API為例疆虚,一個完整的用戶請求就是一個Pipeline(圖6)苛败。假設(shè)每一步都是有若干個函數(shù)組成,我們能夠?qū)⑺麄兘M合到一起嗎径簿?答案是很難罢屈,主要原因如下:
- 每一步的若干個函數(shù)簽名很難保持一致,導(dǎo)致compose這樣的函數(shù)無法正常工作
- 部分I/O相關(guān)的函數(shù)可能是異步的篇亭,領(lǐng)域模型中的代碼大多是同步的缠捌,很難將他們組合在一起
- 在函數(shù)式編程中,通常不會通過throw的方式處理異常译蒂,一方面異常也是一種副作用曼月,另一方面谊却,異常讓函數(shù)簽名不再完整。如何把每一步的異常帶到最外面也成了問題哑芹。
而解決這一切的手段就是Monad, 簡而言之炎辨,Monad是一種抽象方式,能夠?qū)onadic風(fēng)格的函數(shù)連接起來聪姿。什么又是monadic? 簡單來說這是一種接收普通類型碴萧,返回某種lift類型(泛型)的函數(shù)。例如通過IO, Task, Either相關(guān)的Monad來解決此類問題末购。具體內(nèi)容請關(guān)注本人的函數(shù)式系列博客破喻。
小結(jié)
這篇文章總結(jié)了一些使用函數(shù)式語言實(shí)踐DDD的大致思路,也為函數(shù)式架構(gòu)提供了一些參考招盲。由于篇幅的原因低缩,并沒有介紹到DDD的方方面面,同時曹货,一些實(shí)現(xiàn)細(xì)節(jié)則是點(diǎn)到為止咆繁,例如如何使用Monad《プ眩總體來說玩般,函數(shù)式語言的代數(shù)數(shù)據(jù)類型,以及函數(shù)式的一些思想礼饱,為實(shí)踐領(lǐng)域驅(qū)動設(shè)計(jì)提供了其他的選擇坏为。