《領域驅動設計精簡版》讀書筆記(2)——模型驅動設計

基本概念

通用語言應該在建模過程中廣泛嘗試以推動軟件專家和領域專家之間的溝通,以及發現要在模型中使用的主要的領域概念。建模過程的目的是創建一個優良的模型,下一步是將模型實現成代碼。這是軟件開發過程中同等重要的兩個階段。

某些特殊的領域(例如數學)可以藉助過程化編程被輕易地建模和
實現,是因爲許多數學理論大多數都是關於計算的,可以用函數調
用和數據結構簡單解決。許多複雜的領域不僅是一組抽象概念涉及
到的計算,所以不能簡化成一系列的算法,因此過程化語言不足以
完成表述各自模型的任務。因爲這個原因,模型驅動設計中不推薦
過程化編程

基本構成要素

下圖是要展現的模式和模式間關係的總圖:
模式與模式間關係總圖
領域驅動設計的分層架構如下圖所示:
分層架構圖
當我們創建一個軟件應用時,這個應用的很大一部分是不能直接跟領域關聯的,但它們是基礎設施的一部分或者是爲軟件服務的。最好能讓應用中的領域部分儘可能少地和其他的部分摻雜在一起,因爲一個典型的應用包含了很多和數據庫訪問,文件或網絡訪問以及用戶界面等相關的代碼。
在一個面向對象的程序中,用戶界面、數據庫以及其他支持性代碼經常被直接寫到業務對象中。附加的業務邏輯被嵌入到 UI 組件和數據庫腳本的行爲中。之所以這樣做的某些原因是這樣可以很容易地讓事情快速工作起來。

但是,當領域相關的代碼被混入到其他層時,要閱讀和思考它也變得極其困難。表面看上去是對 UI 的修改,卻變成了對業務邏輯的修改。對業務規則的變更可能需要謹慎跟蹤用戶界面層代碼、數據庫代碼以及其他程序元素。實現粘連在了一起,模型驅動對象於是變得不再可行。也很難使用自動化測試。對於每個活動中涉及到的技術和邏輯,程序必須保持簡單,否則就會變得很難理解。

因此,將一個複雜的程序切分成層。開發每一個層中內聚的設計,讓每個層僅依賴於它底下的那層。遵照標準的架構模式以提供層的低耦合。將領域模型相關的代碼集中到一個層中,把它從用戶界面、應用和基礎設施代碼中分隔開來。釋放領域對象的顯示自己、保存自己、管理應用任務等職責,讓它專注於展現領域模型。這會讓一個模型進一步富含知識,更清晰地捕獲基礎的業務知識,讓它們正常工作。
一個領域驅動設計的架構性解決方案包含4個概念層:

層次 功能
用戶界面層/展現層 負責向用戶展示信息以及解釋用戶命令
應用層 很薄的一層,用來協調應用的活動,不包含業務邏輯,不保留業務對象的狀態,但它保有應用任務的進度狀態
領域層 包含關於領域的信息,是業務軟件的核心,在這裏保留業務對象的狀態,對業務對象和他們狀態的持久化被委託給了基礎設施層
基礎設施層 本層作爲其它層的支撐庫存在,它提供了層間的通信,實現對業務對象的持久化,包含對用戶界面層的支撐庫等作用

將應用劃分成分離的層並建立層間的交換規則很重要。如果代碼沒有被清晰隔離到某層中,它會迅即混亂,因爲它變得非常難以管理變更。在某處對代碼的一個簡單修改會對其他地方的代碼造成不可估量的結果。領域層應該關注核心的領域問題。它應該不涉及基礎設施類的活動。用戶界面既不跟業務邏輯緊密捆綁也不包含通常屬於基礎設施層的任務。在很多情況下應用層是必要的。它會成爲業務邏輯之上的管理者,用來監督和協調應用的整個活動。

例如,對一個典型的交互型應用,領域和基礎設施層看上去會這樣:用戶希望預定一個飛行路線,要求用一個應用層中的應用服務來完成。應用依次從基礎設施中取得相關的領域對象,調用它們的相關方法,比如檢查與另一個已經被預定的飛行線路的安全邊界。當領域對象執行完所有的檢查並修改了它們的狀態決定後,應用服務將對象持久化到基礎設施中。

實體(Entity)

有一類對象看上去好像擁有標識符,它的標識符在歷經軟件的各種狀態後仍能保持不變。對這些對象來講這已經不再是它們關心的屬性,這意味着這些對象能夠跨越系統的生命週期,我們把這樣的對象稱爲實體

OOP 語言會把對象的實例放於內存,它們對每個對象會保持一個對象引用或者是記錄一個對象地址。在給定的某個時刻,這種引用對每一個對象而言是唯一的,但是很難保證在不確定的某個時間段它也是如此。實際上恰恰相反。對象經常被移出或者移回內存,它被序列化後在網絡上傳輸,然後在另一端被重新建立,或者它們都被消除。在程序的運行環境中,那個看起來像標識符的引用關係其實並不是我們在談論的標識符。

如果有一個存放了天氣信息(如溫度)的類,很容易產生同一個類的不同實例,這兩個實例都包含了同樣的值,這兩個對象是完全相當的,可以用其中一個跟另一個交換,但它們擁有不同的引用,它們不是實體。

如果我們要用軟件程序實現一個“人”的概念,我們可能會創建一個 Person 類,這個類會帶有一系列的屬性,如:名稱,出生日期,出生地等。這些屬性中有哪個可以作爲 Person 的標識符嗎?名字不可以作爲標識符,因爲可能有很多人擁有同一個名字。如果我們只考慮兩個人的名字的話,我們不能使用同一個名字來區分他們兩個。我們也不能使用出生日期作爲標識符,因爲會有很多人出在同一天出生。同樣也不能用出生地作爲標識符。一個對象必須與其他的對象區分開來,即使是它們擁有着相同的屬性。錯誤的標識符可能會導致數據混亂。

考慮一下一個銀行會計系統。每一個賬戶擁有它自己的數字碼,每一個賬戶可以用它的數字碼來精確標識。這個數字碼在系統的生命週期中會保持不變,並保證延續性。賬戶碼可以作爲一個對象存在於內存中,也可以被在內存中銷燬,發送到數據庫中。當這個賬戶被關閉時,它還可以被歸檔,只要還有人對它感興趣,它就依然在某處存在。不論它的表現形式如何,數字碼會保持一致。

因此,在軟件中實現實體意味着創建標識符。對一個人而言,其標識符可能是屬性的組合:名稱,出生日期,出生地,父母名稱、當前地址。在美國,社會保險號也會用來創建標識符。對一個銀行賬戶來說,賬號看上去已經足可以作爲標識符了。通常標識符或是對象的一個屬性(比如賬戶號碼)或屬性的組合,一個專門爲保存和表現標識符而創建的屬性(比如自動生成的序列號),也或是一種行爲。對兩個擁有不同標識符的對象來說,能用系統輕易地把它們區分開來,或者兩個使用了相同標識符的對象能被系統看成是相同的,這些都是非常重要的。如果不能滿足這個條件,整個系統可能是有問題的。

有很多不同的方式來爲每一個對象創建一個唯一的標識符:可能由一個模型來自動產生 ID,在軟件中內部使用,不會讓它對用戶可見;它可能是數據庫表的一個主鍵,會被保證在數據庫中是唯一的。只要對象從數據庫中被檢索,它的 ID 就會被檢索出並在內存中被重建;ID 也可能由用戶創建,例如每個機場會有一個關聯的代碼。每個機場擁有一個唯一的字符串 ID,這個字符串是在世界範圍內通用的,被世界上的每一個旅行代理使用以標識它們的旅行計劃中涉及的機場。另一種解決方案是使用對象的屬性來創建標識符,當這個屬性不足以代表標識符時,另一個屬性就會被加入以幫助確定每一個對象。

當一個對象可以用其標識符而不是它的屬性來區分時,可以將它作爲模型中的主要定義。保證類定義簡潔並關注生命週期的延續性和可標識性。對每個對象定義一個有意義的區分,而不管它的形式或者歷史。警惕要求使用屬性匹配對象的需求。定義一個可以保證對每一個對象產生一個唯一的結果的操作,這個過程可能需要某個符號以保證唯一性。這意味着標識可以來自外部,或者它可以是由系統產生、使用任意的標識符,但它必須符合模型中的身份差別。模型必須定義哪些被看作同一事物。

實體是領域模型中非常重要的對象,並且它們應該在建模過程開始時就被考慮。決定一個對象是否需要成爲一個實體也很重要,這會在下一個模型中被討論。

值對象(Value object)

我們可能被引導將所有對象看成實體。實體是可以被跟蹤的。但跟蹤和創建標識符需要很大的成本。我們需要保證每一個實體都有唯一標識,跟蹤標識也並非易事。我們需要確保每個實例擁有它唯一的標識,跟蹤標識也並非易事。需要花費很多仔細的考慮來決定由什麼來構成一個標識符,因爲一個錯誤的決定可能會讓對象擁有相同的標識,而這並不是我們所預期的。將所有的對象視爲實體也會帶來隱含的性能問題,因爲需要對每個對象產生一個實例。

如果我們對某個對象是什麼不感興趣,只關心它擁有的屬性。用來描述領域的特殊方面、且沒有標識符的一個對象,叫做值對象。實際上,只建議選擇那些符合實體定義的對象作爲實體,將剩下的對象處理成值對象(目前假設現在只有實體對象和值對象兩種)。這會簡化設計,並且將會產生某些其他的積極的意義。推薦所有值對象的屬性都儘量是值對象。

沒有標識符,值對象就可以被輕易地創建或者丟棄。沒有人關心創建一個標識符,在沒有其他對象引用時,垃圾回收會處理這個對象。這極大簡化了設計。極力推薦值對象是不變的。它們由一個構造器創建,並且在它們的生命週期內永遠不會被修改。當你希望一個對象的不同的值時,你會簡單地去創建另一個對象。這會對設計產生重要的結果。保持不變,並且不具有標識符,值對象就可以被共享了。這對某些設計是必要的。不變的對象可在重要的性能語境中共享。它們也能表明一致性,如:數據一致性。設想一下共享可變的對象會意味着什麼。一個航空旅行預定系統可能爲每個航班創建對象,這個對象會有一個可能是航班號的屬性。一個客戶會爲一個特定的目的地預定一個航班。另一個客戶希望訂購同一個航班。因爲是同一個航班,系統選擇了重用持有那個航班號的對象。這時,客戶改變了主意,選擇換成一個不同的航班。因爲它不是不可修改的,所以系統改變了航班號。這會導致第一個客戶的航班號也發生了變化。如果值對象是可共享的,那麼它們應該是不可變的

值對象可以包含其他的值對象,它們甚至還可以包含對實體對象的
引用。雖然值對象可用來簡化一個領域對象要包含的屬性,但這並
不意味着它應該包含所有的一大長列的屬性。屬性可以被分組到不
同的對象中。如下圖所示:
值對象分組

服務(Service)

當我們分析領域並試圖定義構成模型的主要對象時,我們發現有些方面的領域很難映射成對象。對象要通常考慮的是擁有屬性,對象會管理它的內部狀態並暴露行爲。在我們開發通用語言時,領域中的主要概念被引入到語言中,語言中的名詞很容易被映射成對象。語言中對應那些名詞的動詞變成那些對象的行爲。但是有些領域中的動作,它們是一些動詞,看上去卻不屬於任何對象。它們代表了領域中的一個重要的行爲,所以不能忽略它們或者簡單的把它們合併到某個實體或者值對象中。面向對象編程的行爲一定要依附於某個對象,通常這種行爲類的功能會跨越若干個對象,或許是不同的類。例如,爲了從一個賬戶向另一個賬戶轉錢,這個功能應該放到轉出的賬戶還是在接收的賬戶中?感覺放在這兩個中的哪一個也不對勁。當這樣的行爲從領域中被識別出來時,最佳實踐是將它聲明成一個服務,一個服務是多個對象的鏈接點,表示某個無狀態的操作,用於實現某個領域的任務。服務不再擁有內置的狀態了,它的作用是爲了簡化所提供的領域功能。服務所能提供的協調作用是非常重要的,一個服務可以將服務於實體和值對象的相關功能進行分組。最好顯式聲明服務,因爲它創建了領域中的一個清晰的特性,它封裝了一個概念。把這樣的功能放入實體或者值對象都會導致混亂,因爲那些對象的立場將變得不清楚。

以下是服務的3個特徵:

  1. 服務執行的操作涉及一個領域概念,這個領域概念通常不屬於一個實體或者值對象。
  2. 被執行的操作涉及到領域中的其他的對象。
  3. 操作是無狀態的

服務可以屬於領域層也可以屬於基礎設施層,不論是應用服務還是領域服務,通常都是建立在領域實體和值對象的上層,以便直接爲這些相關的對象提供所需的服務。決定一個服務所應歸屬的層是非常困難的事情。如果所執行的操作概念上屬於領域層,那麼服務就應該放到這個層。如果操作和領域對象相關,而且確實也跟領域有關,能夠滿足領域的需要,那麼它就應該屬於領域層。

模塊(Module)

對於很大的複雜項目而言,模塊可以用來組織相關概念和任務,以便於降低系統的複雜度。模塊在許多項目中被廣泛使用。如果你查看模快包含的內容以及那些模塊間的關係,就會很容易從中掌握大型模型的概況。理解了模型間的交互之後,人們就可以開始處理模塊中的細節了。這是管理複雜性的簡單有效的方法。將高關聯度的類分組到一個模塊以提供儘可能大的內聚和儘量低的耦合,這可以提升代碼質量。內聚性分兩種類型:

  1. 通信性內聚:通常在模塊的部件操作相同的數據時使用。把它們分到一組很有意義,因爲它們之間存在很強的關聯性。
  2. 功能性內聚:在模塊中的部件協同工作以完成定義好的任務時使用。這被認爲是最佳的內聚類型。

模塊應該由在功能上或者邏輯上屬於一體的元素構成,以保證內聚。模塊應該具有定義好的接口,這些接口可以被其他的模塊訪問。最好用訪問一個接口的方式替代調用模塊中的三個對象,因爲這可以降低耦合。低耦合降低了複雜性並增強了可維護性。當要執行定義好的功能時,模塊間僅有極少的連接會讓人很容易理解系統是如何工作的,這要比每個模塊同其他的模塊間存在許多關聯好很多。強烈推薦模塊的設計擁有一些彈性,允許模塊隨着項目的進展而演化而不是被凍結,如果模塊的設計被發現有錯誤,也需要及時修正,儘管有時候這樣的代價比較大。

聚合(Aggregate)

通過聚合我們可以簡化對象與對象之間的關聯關係,聚合是針對數據變化可以考慮成一個單元的一組相關的對象。聚合使用邊界將內部和外部的對象劃分開來。

每個聚合有一個根,這個根是一個實體,並且它是外部可以訪問的唯一的對象。根可以保持對任意聚合對象的引用,並且其他的對象可以持有任意其他的對象,但一個外部對象只能持有根對象的引用。如果邊界內有其他的實體,那些實體的標識符是本地化的,只在聚合內有意義,一個實體的引用只能被其所屬的聚合根所持有,而其它對象只能持有根對象的引用,只能通過根對象變更聚合內的其它對象。如果根從內存中被刪除或者移除,聚合內的其他所有的對象也將被刪除,因爲再不會有其他的對象持有它們當中的任何一個了。如果聚合對象被保存到數據庫中,只有根可以通過查詢來獲得,其他的對象只能通過導航關聯來獲得。聚合內的對象可以被允許持有對其他聚合的根的引用。一個簡單的聚合案例如圖所示:
聚合
客戶是聚合的根,並且其他所有的對象都是內部的。如果需要地址,一個它的拷貝將被傳遞到外部對象。

工廠(Factory)

實體和聚合通常會很大很複雜,根實體的構造函數內的創建邏輯也會很複雜。如果通過構造函數創建對象,有時候客戶程序需要持有關於對象構建的專有知識,這破壞了領域對象和聚合的封裝性。一個對象的創建可能是它自身的主要操作,但是複雜的組裝操作不應該成爲被創建對象的職責。

所以可以通過工廠來封裝複雜的對象創建過程、用來封裝對象創建所必需的知識,它們對創建聚合特別有用。當聚合的根建立時,所有聚合包含的對象將隨之建立,所有的不變量得到了強化。

將創建過程原子化非常重要。如果不這樣做,創建過程就會存在對某個對象執行了一半操作的機會,將這些對象置於未定義的狀態,對聚合而言更是如此。當根被創建時,所有對象服從的不變量也必須被創建完畢,否則,不變量將不能得到保證。對不變的值對象而言則意味着所有的對象被初始化成有效的狀態。如果一個對象不能被正常創建,將會產生一個異常,確保沒有返回一個無效值。因此,轉變創建複雜對象和聚合的實例的職責給一個單獨的對象,雖然這個對象本身在領域模型中沒有職責,但它仍是領域設計的一部分。提供一個封裝了所有複雜組裝的接口,客戶程序將不再需要引用要初始化的對象的具體的類。將整個聚合當作一個單元來創建,強化它們的不變量。工廠方法舉例:
工廠方法Container 包含着許多組件,這些組件都是特定類型的。當這樣的一個組件被創建後能自動歸屬於一個 Container 是很有必要的。客戶程序調用 Container 的 createComponent(Type t)方法,Container實例化一個新的組件。組件的具體類取決於它的類型。在創建之後,組件被增加到 Container 所包含的組件的集合中,並且返回給客戶程序一個拷貝。

有時工廠是不需要的,一個簡單的構造函數就足夠了。在如下情況下使用構造函數:

  1. 構造過程並不複雜。
  2. 對象的創建不涉及到其他對象的創建,所有的屬性需要傳遞給構造函數。
  3. 客戶程序對實現很感興趣,可能希望選擇使用策略模式。
  4. 類是特定的類型,不涉及到繼承,所以不用在一系列的具體實現中進行選擇。

資源庫

我們可以推導出大部分的對象可以從數據庫中直接獲取到。這解決了獲取對象引用的問題。當一個客戶程序需要使用一個對象時,它可以訪問數據庫,從中檢索出對象並使用它。這看上去是個非常快捷並且簡單的解決方案,但它對設計會產生負面的影響。數據庫是基礎設施的一部分。一個不好的解決方案是客戶程序必須知道要訪問數據庫所需的細節。例如,客戶需要創建 sql 查詢語句來檢索所需的數據。數據庫查詢可能會返回一組記錄,甚至暴露其內部的更細節信息。當許多客戶程序不得不直接從數據庫創建對象時,會導致這樣的代碼在整個模型中四散。
使用一個資源庫,它的目的是封裝所有獲取對象引用所需的邏輯。領域對象不需處理基礎設施,以得到領域中對其他對象的所需的引用。只需從資源庫中獲取它們,於是模型重獲它應有的清晰和焦點。資源庫會保存對某些對象的引用。當一個對象被創建出來時,它可以被保存到資源庫中,然後以後使用時可從資源庫中檢索到。如果客戶程序從資源庫中請求一個對象,而資源庫中並沒有它,就會從存儲介質中獲取它。
資源庫
資源庫可能包含一定的策略。它可能基於一個特定的策略來訪問某個或者另一個持久化存儲介質。它可能會對不同類型的對象使用不同的存儲位置。最終結果是領域模型同需要保存的對象和它們的引用中解耦,可以訪問潛在的持久化基礎設施。要注意在有聚合的場景中僅對真正需要直接訪問的聚合根提供資源庫。另外要注意的是工廠是“純的領域”,而資源庫會包含對基礎設施的連接,如數據庫。下圖爲資源庫的接口實例:
資源庫舉例
看上去資源庫的實現可能會非常類似於基礎設施,但資源庫的接口是純粹的領域模型(所以Java具體實現的時候可以把接口定義在領域層,在基礎設施層提供接口的具體實現類)。

參考

《領域驅動設計精簡版》
DDD領域驅動聚合根

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章