領域驅動設計系列(1)通過現實例子顯示領域驅動設計的威力
曾經參與過系統維護或是在現有系統中進行迭代開發的軟件工程師們,你們是否有過這樣的痛苦經歷:當需要修改一個Bug的時候,面對一個類中成百上千行的代碼,沒有注釋,千奇百怪的方法和變量名字,層層嵌套的方法調用,混亂不堪的結構,不要說準確找到Bug所在的位置,就是要清晰知道一段代碼究竟是做了什么也非常困難。最終,改對了一個Bug,卻多冒出N個新Bug。同樣的情況,當你拿到一份新的需求,需要在現有系統中添加功能的時候,面對一行行完全過程式的代碼,需要使用一個功能時,不知道是應該自己編寫,還是應該尋找是否已經存在的方法,編寫一個非常簡單的新、刪、改功能,卻要費盡九牛二虎之力。最終發現,系統存在著太多的重復邏輯,閱讀、測試、修改非常困難。在經歷了這些痛苦之后,你們是否會不約而同的發出一個感慨:與其進行系統維護和迭代開發,還不如重新設計開發一個新的系統來得痛快?
面對這一系列讓軟件陷入無底泥潭的問題,基于面向對象思想的領域驅動設計方法是一個很好的解決方法。從事過系統設計的富有經驗的設計師們,對職責單一原則、信息專家、充血/貧血模型、模型驅動設計這些名詞或概念應該不會感到陌生。面向對象的設計大師Martin Fowler不止一次的在他的Blog和著作《企業應用架構模式》中倡導過上述概念在設計中的巨大威力,而另外一位領域模型的出色專家Eric Evans的著作《領域驅動設計》也為我們提供了不少寶貴的經驗和方法。
筆者從事系統設計多年,將會在本系列文章中把本人對領域驅動設計的理解,結合工作過程中積累的實際項目經驗進行淺析,希望與大家交流學習。
在本系列博文的開篇中,我將會拿出一個例子,先用傳統的面向過程方式,使用貧血模型進行設計,然后再逐步加入需求變更。讓讀者發現,隨著系統的不斷變更,基于貧血模型的設計將會讓系統慢慢陷入泥潭,越來越難于維護。然后再用基于面向對象的領域驅動設計重新上述過程,通過對比展示領域驅動設計對于復雜的業務系統的威力。
假設現在有一個銀行支付系統項目,其中的一個重要的業務用例是賬戶轉賬業務。系統使用迭代的方式進行開發,在1.0版本中,該用例的功能需求非常簡單,事件流描述如下:
主事件流:
1)用戶登錄銀行的在線支付系統
2)選擇用戶在該銀行注冊的網上銀行賬戶
3)選擇需要轉賬的目標賬戶,輸入轉賬金額,申請轉賬
4)銀行系統檢查轉出賬戶的金額是否足夠
5)從轉出賬戶中扣除轉出金額(debit),更新轉出賬戶的余額
6)把轉出金額加入到轉入賬戶中(credit),更新轉入賬戶的余額
備選事件流:
4a)如果轉出賬戶中的余額不足,轉賬失敗,返回錯誤信息
面向過程的設計方式(貧血模型)
設計方案如下(忽略展示層部分):
1)設計一個賬戶交易服務接口AccountingService,設計一個服務方法transfer(),并提供一個具體實現類AccountingServiceImpl,所有賬戶交易業務的業務邏輯都置于該服務類中。
2)提供一個AccountInfo和一個Account,前者是一個用于與展示層交換賬戶數據的賬戶數據傳輸對象,后者是一個賬戶實體(相當于一個EntityBean),這兩個對象都是普通的JavaBean,具有相關屬性和簡單的get/set方法。
下面是AccountingServiceImpl.transfer()方法的實現邏輯(偽代碼):
public class AccountingServiceImpl implements AccountingService { public void transfer(Long srcAccountId, Long destAccountId, BigDecimal amount) throws AccountingServiceException { Account srcAccount = accountRepository.getAccount(srcAccountId); Account destAccount = accountRepository.getAccount(destAccountId); if(srcAccount.getBalance().compareTo(amount)<0){ throw new AccountingServiceException(AccountingService.BALANCE_IS_NOT_ENOUGH); } srcAccount.setBalance(srcAccount.getBalance().sbustract(amount)); destAccount.setBalance(destAccount.getBalance().add(amount)); } } public class Account implements DomainObject { private Long id; private Bigdecimal balance; /** * getter/setter */ }
可以看到,由于1.0版本的功能需求非常簡單,按面向過程的設計方式,把所有業務代碼置于AccountingServiceImpl中完全沒有問題。
這時候,新需求來了,在1.0.1版本中,需要為賬戶轉賬業務增加如下功能,在轉賬時,首先需要判斷賬戶是否可用,然后,賬戶的余額還要分成兩部分:凍結部分和活躍部分,處于凍結部分的金額不能用于任何交易業務,我們來看看變更后的代碼:
public class AccountingServiceImpl implements AccountingService { public void transfer(Long srcAccountId,Long destAccountId,BigDecimal amount) throws AccountingServiceException { Account srcAccount = accountRepository.getAccount(srcAccountId); Account destAccount = accountRepository.getAccount(destAccountId); if(!srcAccount.isActive() || !destAccount.isActive()) throw new AccountingServiceException(AccountingService.ACCOUNT_IS_NOT_AVAILABLE); BigDecimal availableAmount = srcAccount.getBalance().substract(srcAccount.getFrozenAmount()); if(availableAmount.compareTo(amount)<0) throw new AccountingServiceException(AccountingService.BALANCE_IS_NOT_ENOUGH); srcAccount.setBalance(srcAccount.getBalance().sbustract(amount)); destAccount.setBalance(destAccount.getBalance().add(amount)); } } public class Account implements DomainObject { private Long id; private BigDecimal balance; private BigDecimal frozenAmount; /** * getter/setter */ }
可以看到,情況變得稍微復雜了,這時候,1.0.2的需求又來了,需要在每次交易成功后,創建一個交易明細賬,于是,我們又必須在transfer()方面里面增加創建并持久化交易明細賬的業務邏輯:
AccountTransactionDetails details= new AccountTransactionDetails(…); accountRepository.save(details);
業務需求不斷復雜化:賬戶每筆轉賬的最大額度需要由其信用指數確定、需要根據銀行的手續費策略計算并扣除一定的手續費用……,隨著業務的復雜化,transfer()方法的邏輯變得越來越復雜,逐漸形成了上文所述的成百上千行代碼。有經驗的程序員可能會做出類此“方法抽取”的重構,把轉賬業務按邏輯劃分成若干塊:判斷余額是否足夠、判斷賬戶的信用指數以確定每筆最大轉賬金額、根據銀行的手續費策略計算手續費、記錄交易明細賬……,從而使代碼更加結構化。這是一個好的開始,但還是顯然不足。
假設某一天,系統需求增加一個新的模塊,為系統增加一個網上商城,讓銀行用戶可以進行在線購物,而在線購物也存在著很多與賬戶貸記借記業務相同或相似的業務邏輯:判斷余額是否足夠、對賬戶進行借貸操作(credit/debit)以改變余額、收取手續費用、產生交易明細賬……
面對這種情況,有兩種解決辦法:
1) 把AccountingServiceImpl中的相同邏輯拷貝到OnlineShoppingServiceImplementation中
2) 讓OnlineShoppingServiceImpl調用AccountingServiceImpl的相同服務
顯然,第二種方法比第一種方法更好,結構更清晰,維護更容易。但問題在于,這樣就會形成網上商城服務模塊與賬戶收支服務模塊的不必要的依賴關系,系統的耦合度高了,如果系統為了更靈活的伸縮性,讓每個大業務模塊獨立進行部署,還需要因為兩者的依賴關系建立分布式調用,這無疑增加了設計、開發和運維的成本。
有經驗的設計人員可能會發現第三種解決辦法:把相同的業務邏輯抽取成一個新的服務,作為公共服務同時供上述兩個業務模塊使用。這就是筆者將會馬上討論的方案——使用領域驅動設計。
面向對象的領域驅動設計方式(充血模型)
為了節省篇幅,這里就直接以最復雜的業務需求來進行設計。
領域驅動設計的一個重要的概念是領域模型,首先,我們根據業務領域抽象出以下核心業務對象模型:
Account:賬戶,是整個系統的最核心的業務對象,它包括以下屬性:對象標識、賬戶號、是否有效標識、余額、凍結金額、賬戶交易明細集合、賬戶信用等級。
AccountTransactionDetails:賬戶交易明細,它從屬于賬戶,每個賬戶有多個交易明細,它包括以下屬性:對象標識、所屬賬戶、交易類型、交易發生金額、交易發生時間。
AccountCreditDegree:賬戶信用等級,它用于限制賬戶的每筆交易發生金額,包含以下屬性:對象標識、對應賬戶、信用指數。
BankTransactionFeeCalculator:銀行交易手續費用計算器,它包含一個常量:每筆交易的手續費上限。
我們知道,領域對象除了具有自身的屬性和狀態之外,它的一個很重要的標志是,它具有屬于自己職責范圍之內的行為,這些行為封裝了其領域內的領域業務邏輯。于是,我們進行進一步的建模,根據業務需求為領域對象設計業務方法:
根據職責單一的原則,我們把功能需求中描述的功能合理的分配到不同的領域對象中:
Account:
- credit:向銀行賬戶存入金額,貸記
- debit:從銀行賬戶劃出金額,借記
- transferTo:把固定金額轉入指定賬戶
- createTransactionDetails:創建交易明細賬
- updateCreditIndex:更新賬戶的信用指數
(我們可以看到,后兩個業務方法被聲明為protected,具體原因見后述)
AccountCreditDegree:
- getMaxTransactionAmount:獲取所屬賬戶的每筆交易最大金額
BankTransactionFeeCalculator:
- calculateTransactionFee:根據交易信息計算該筆交易的手續費
經過這樣的設計,前例中所有放置在服務對象的業務邏輯被分別劃入不同的負責相關職責的領域對象當中,下面的時序圖描述了AccountingServiceImpl的轉賬業務的實現邏輯(為了簡化邏輯,我們忽略掉事物、持久化等邏輯):
再看看AccountingServiceImpl.transfer()的實現邏輯:
public class AccountingServiceImpl implements AccountingService { public void transfer(Long srcAccountId,Long destAccountId,BigDecimal amount) throws AccountDomainException { Account srcAccount = accountRepository.getAccount(srcAccountId); Account destAccount = accountRepository.getAccount(destAccountId); srcAccount.transferTo(destAccount,amount); } }
我們可以看到,上例那些復雜的業務邏輯:判斷余額是否足夠、判斷賬戶是否可用、改變賬戶余額、計算手續費、判斷交易額度、產生交易明細賬……,都不再存在于AccountingServiceImplementation的transfer方法中,它們被委派給負責這些業務的領域對象的業務方法中去,現在應該猜到為什么Account中有兩個方法被聲明為protected了吧,因為他們是在debit和credit方法被調用時,由這兩個方法調用的,對于AccountingServiceImpl來說,由于產生交易明細(createTransactionDetails)和更新賬戶信用指數(updateCreditIndex)都不屬于其職責范圍,它不需要也無權使用這些邏輯。
我們可以看到,使用領域驅動設計至少會帶來下述優點:
- 業務邏輯被合理的分散到不同的領域對象中,代碼結構更加清晰,可讀性,可維護性更高。
- 對象職責更加單一,內聚度更高。
- 復雜的業務模型可以通過領域建模(UML是一種主要方式)清晰的表達,開發人員甚至可以在不讀源碼的情況下就能了解業務和系統結構,這有利于對現存的系統進行維護和迭代開發。
再看看如果這時需要加入網上商城的一個新的模塊,開發人員需要怎么去做,還記得上面提過的第三種方案嗎?就是把賬戶貸記和借記的相關業務抽取到成一個公共服務,同時供銀行在線支付系統和網上商城系統服務,其實這個公共的服務,本質上就是這些具有領域邏輯的領域對象:Account、AccountCreditDegree……,由此我們又可以發現領域驅動設計的一大優點:
- 系統高度模塊化,代碼重用度高,不會出現太多的重復邏輯。
筆者經驗尚淺,而且文筆拙劣,希望通過這樣的一個場景的分析比較,能讓讀者初步認識到基于面向對象的領域驅動設計的威力,并在實際項目中嘗試應用。本篇是領取驅動設計系列博文的第一篇,在系列文章的第二篇博文中,筆者將會淺析VO、DTO、DO、PO的概念、用處和區別,敬請各位對本系列博文感興趣的讀者關注并給予指導修正。