【轉】領域驅動設計之領域模型
領域驅動設計之領域模型
加一個導航,關於如何設計聚合的詳細思考,見這篇文章。
2004年Eric Evans 發表Domain-Driven Design –Tackling Complexity in the Heart of Software (領域驅動設計),簡稱Evans DDD。領域驅動設計分為兩個階段:
以一種領域專家、設計人員、開發人員都能理解的通用語言作為相互交流的工具,在交流的過程中發現領域概念,然後將這些概念設計成一個領域模型;
由領域模型驅動軟體設計,用程式碼來實現該領域模型;
由此可見,領域驅動設計的核心是建立正確的領域模型。
為什麼建立一個領域模型是重要的
領域驅動設計告訴我們,在通過軟體實現一個業務系統時,建立一個領域模型是非常重要和必要的,因為領域模型具有以下特點:
- 領域模型是對具有某個邊界的領域的一個抽象,反映了領域內使用者業務需求的本質;領域模型是有邊界的,只反應了我們在領域內所關注的部分;
- 領域模型只反映業務,和任何技術實現無關;領域模型不僅能反映領域中的一些實體概念,如貨物,書本,應聘記錄,地址,等;還能反映領域中的一些過程概念,如資金轉賬,等;
- 領域模型確保了我們的軟體的業務邏輯都在一個模型中,都在一個地方;這樣對提高軟體的可維護性,業務可理解性以及可重用性方面都有很好的幫助;
- 領域模型能夠幫助開發人員相對平滑地將領域知識轉化為軟體構造;
- 領域模型貫穿軟體分析、設計,以及開發的整個過程;領域專家、設計人員、開發人員通過領域模型進行交流,彼此共享知識與資訊;因為大家面向的都是同一個模型,所以可以防止需求走樣,可以讓軟體設計開發人員做出來的軟體真正滿足需求;
- 要建立正確的領域模型並不簡單,需要領域專家、設計、開發人員積極溝通共同努力,然後才能使大家對領域的認識不斷深入,從而不斷細化和完善領域模型;
- 為了讓領域模型看的見,我們需要用一些方法來表示它;圖是表達領域模型最常用的方式,但不是唯一的表達方式,程式碼或文字描述也能表達領域模型;
- 領域模型是整個軟體的核心,是軟體中最有價值和最具競爭力的部分;設計足夠精良且符合業務需求的領域模型能夠更快速的響應需求變化;
領域通用語言(UBIQUITOUS LANGUAGE)
我們認識到由軟體專家和領域專家通力合作開發出一個領域的模型是絕對需要的,但是,那種方法通常會由於一些基礎交流的障礙而存在難點。開發人員滿腦子都是類、方法、演算法、模式、架構,等等,總是想將實際生活中的概念和程式工件進行對應。他們希望看到要建立哪些物件類,要如何對物件類之間的關係建模。他們會習慣按照封裝、繼承、多型等面向物件程式設計中的概念去思考,會隨時隨地這樣交談,這對他們來說這太正常不過了,開發人員就是開發人員。但是領域專家通常對這一無所知,他們對軟體類庫、框架、持久化甚至資料庫沒有什麼概念。他們只瞭解他們特有的領域專業技能。比如,在空中交通監控樣例中,領域專家知道飛機、路線、海拔、經度、緯度,知道飛機偏離了正常路線,知道飛機的發射。他們用他們自己的術語討論這些事情,有時這對於外行來說很難直接理解。如果一個人說了什麼事情,其他的人不能理解,或者更糟的是錯誤理解成其他事情,又有什麼機會來保證專案成功呢?
在交流的過程中,需要做翻譯才能讓其他的人理解這些概念。開發人員可能會努力使用外行人的語言來解析一些設計模式,但這並一定都能成功奏效。領域專家也可能會建立一種新的行話以努力表達他們的這些想法。在這個痛苦的交流過程中,這種型別的翻譯並不能對知識的構建過程產生幫助。
領域驅動設計的一個核心的原則是使用一種基於模型的語言。因為模型是軟體滿足領域的共同點,它很適合作為這種通用語言的構造基礎。使用模型作為語言的核心骨架,要求團隊在進行所有的交流是都使用一致的語言,在程式碼中也是這樣。在共享知識和推敲模型時,團隊會使用演講、文字和圖形。這兒需要確保團隊使用的語言在所有的交流形式中看上去都是一致的,這種語言被稱為“通用語言(Ubiquitous Language)”。通用語言應該在建模過程中廣泛嘗試以推動軟體專家和領域專家之間的溝通,從而發現要在模型中使用的主要的領域概念。
將領域模型轉換為程式碼實現的最佳實踐
擁有一個看上去正確的模型不代表模型能被直接轉換成程式碼,也或者它的實現可能會違背某些我們所不建議的軟體設計原則。我們該如何實現從模型到程式碼的轉換,並讓程式碼具有可擴充套件性、可維護性,高效能等指標呢?另外,如實反映領域的模型可能會導致物件持久化的一系列問題,或者導致不可接受的效能問題。那麼我們應該怎麼做呢?
我們應該緊密關聯領域建模和設計,緊密將領域模型和軟體編碼實現捆綁在一起,模型在構建時就考慮到軟體和設計。開發人員會被加入到建模的過程中來。主要的想法是選擇一個能夠恰當在軟體中表現的模型,這樣設計過程會很順暢並且基於模型。程式碼和其下的模型緊密關聯會讓程式碼更有意義並與模型更相關。有了開發人員的參與就會有反饋。它能保證模型被實現成軟體。如果其中某處有錯誤,會在早期就被標識出來,問題也會容易修正。寫程式碼的人會很好地瞭解模型,會感覺自己有責任保持它的完整性。他們會意識到對程式碼的一個變更其實就隱含著對模型的變更,另外,如果哪裡的程式碼不能表現原始模型的話,他們會重構程式碼。如果分析人員從實現過程中分離出去,他會不再關心開發過程中引入的侷限性。最終結果是模型不再實用。任何技術人員想對模型做出貢獻必須花費一些時間來接觸程式碼,無論他在專案中擔負的是什麼主要角色。任何一個負責修改程式碼的人都必須學會用程式碼表現模型。每位開發人員都必須參與到一定級別的領域討論中並和領域專家聯絡。
領域建模時思考問題的角度
“使用者需求”不能等同於“使用者”,捕捉“使用者心中的模型”也不能等同於“以使用者為核心設計領域模型”。 《老子》書中有個觀點:有之以為利,無之以為用。在這裡,有之利,即建立領域模型;無之用,即包容使用者需求。舉些例子,一個杯子要裝滿一杯水,我們在製作杯子時,製作的是空杯子,即要把水倒出來,之後才能裝下水;再比如,一座房子要住人,我們在建造房子時,建造的房子是空的,唯有空的才能容納人的居住。因此,建立領域模型時也要將使用者置於模型之外,這樣才能包容使用者的需求。
所以,我的理解是:
- 我們設計領域模型時不能以使用者為中心作為出發點去思考問題,不能老是想著使用者會對系統做什麼;而應該從一個客觀的角度,根據使用者需求挖掘出領域內的相關事物,思考這些事物的本質關聯及其變化規律作為出發點去思考問題。
- 領域模型是排除了人之外的客觀世界模型,但是領域模型包含人所扮演的參與者角色,但是一般情況下不要讓參與者角色在領域模型中佔據主要位置,如果以人所扮演的參與者角色在領域模型中佔據主要位置,那麼各個系統的領域模型將變得沒有差別,因為軟體系統就是一個人機互動的系統,都是以人為主的活動記錄或跟蹤;比如:論壇中如果以人為主導,那麼領域模型就是:人發帖,人回帖,人結貼,等等;DDD的例子中,如果是以人為中心的話,就變成了:託運人託運貨物,收貨人收貨物,付款人付款,等等;因此,當我們談及領域模型時,已經預設把人的因素排除開了,因為領域只有對人來說才有意義,人是在領域範圍之外的,如果人也劃入領域,領域模型將很難保持客觀性。領域模型是與誰用和怎樣用是無關的客觀模型。歸納起來說就是,領域建模是建立虛擬模型讓我們現實的人使用,而不是建立虛擬空間,去模仿現實。
以Eric Evans(DDD之父)在他的書中的一個貨物運輸系統為例子簡單說明一下。在經過一些使用者需求討論之後,在使用者需求相對明朗之後,Eric這樣描述領域模型:
- 一個Cargo(貨物)涉及多個Customer(客戶,如託運人、收貨人、付款人),每個Customer承擔不同的角色;
- Cargo的運送目標已指定,即Cargo有一個運送目標;
- 由一系列滿足Specification(規格)的Carrier Movement(運輸動作)來完成運輸目標;
從上面的描述我們可以看出,他完全沒有從使用者的角度去描述領域模型,而是以領域內的相關事物為出發點,考慮這些事物的本質關聯及其變化規律的。上述這段描述完全以貨物為中心,把客戶看成是貨物在某個場景中可能會涉及到的關聯角色,如貨物會涉及到託運人、收貨人、付款人;貨物有一個確定的目標,貨物會經過一系列列的運輸動作到達目的地;其實,我覺得以使用者為中心來思考領域模型的思維只是停留在需求的表面,而沒有挖掘出真正的需求的本質;我們在做領域建模時需要努力挖掘使用者需求的本質,這樣才能真正實現使用者需求;
關於使用者、參與者這兩個概念的區分,可以看一下下面的例子:
試想兩個人共同玩足球遊戲,操作者(使用者)是驅動者,它驅使足球比賽領域中,各個“人”(參與者)的活動。這裡立下一個假設,假設操作者A操作某一隊員a,而隊員a擁有著某人B的資訊,那麼有以下說法,a是B的映象,a是領域參與者,A是驅動者。
領域驅動設計的經典分層架構
使用者介面/展現層
負責向用戶展現資訊以及解釋使用者命令。更細的方面來講就是:
- 請求應用層以獲取使用者所需要展現的資料;
- 傳送命令給應用層要求其執行某個使用者命令;
應用層
很薄的一層,定義軟體要完成的所有任務。對外為展現層提供各種應用功能(包括查詢或命令),對內呼叫領域層(領域物件或領域服務)完成各種業務邏輯,應用層不包含業務邏輯。
領域層
負責表達業務概念,業務狀態資訊以及業務規則,領域模型處於這一層,是業務軟體的核心。
基礎設施層
本層為其他層提供通用的技術能力;提供了層間的通訊;為領域層實現持久化機制;總之,基礎設施層可以通過架構和框架來支援其他層的技術需求;
領域驅動設計過程中使用的模式
所有模式的總攬圖
關聯的設計
關聯本身不是一個模式,但它在領域建模的過程中非常重要,所以需要在探討各種模式之前,先討論一下物件之間的關聯該如何設計。我覺得物件的關聯的設計可以遵循如下的一些原則:
- 關聯儘量少,物件之間的複雜的關聯容易形成物件的關係網,這樣對於我們理解和維護單個物件很不利,同時也很難劃分物件與物件之間的邊界;另外,同時減少關聯有助於簡化物件之間的遍歷;
- 對多的關聯也許在業務上是很自然的,通常我們會用一個集合來表示1對多的關係。但我們往往也需要考慮到效能問題,尤其是當集合內元素非常多的時候,此時往往需要通過單獨查詢來獲取關聯的集合資訊;
- 關聯儘量保持單向的關聯;
- 在建立關聯時,我們需要深入去挖掘是否存在關聯的限制條件,如果存在,那麼最好把這個限制條件加到這個關聯上;往往這樣的限制條件能將關聯化繁為簡,即可以將多對多簡化為1對多,或將1對多簡化為1對1;
實體(Entity)
實體就是領域中需要唯一標識的領域概念。因為我們有時需要區分是哪個實體。有兩個實體,如果唯一標識不一樣,那麼即便實體的其他所有屬性都一樣,我們也認為他們兩個不同的實體;因為實體有生命週期,實體從被建立後可能會被持久化到資料庫,然後某個時候又會被取出來。所以,如果我們不為實體定義一種可以唯一區分的標識,那我們就無法區分到底是這個實體還是哪個實體。另外,不應該給實體定義太多的屬性或行為,而應該尋找關聯,發現其他一些實體或值物件,將屬性或行為轉移到其他關聯的實體或值物件上。比如Customer實體,他有一些地址資訊,由於地址資訊是一個完整的有業務含義的概念,所以,我們可以定義一個Address物件,然後把Customer的地址相關的資訊轉移到Address物件上。如果沒有Address物件,而把這些地址資訊直接放在Customer物件上,並且如果對於一些其他的類似Address的資訊也都直接放在Customer上,會導致Customer物件很混亂,結構不清晰,最終導致它難以維護和理解;
值物件(Value Object)
在領域中,並不是沒一個事物都必須有一個唯一標識,也就是說我們不關心物件是哪個,而只關心物件是什麼。就以上面的地址物件Address為例,如果有兩個Customer的地址資訊是一樣的,我們就會認為這兩個Customer的地址是同一個。也就是說只要地址資訊一樣,我們就認為是同一個地址。用程式的方式來表達就是,如果兩個物件的所有的屬性的值都相同我們會認為它們是同一個物件的話,那麼我們就可以把這種物件設計為值物件。因此,值物件沒有唯一標識,這是它和實體的最大不同。另外值物件在判斷是否是同一個物件時是通過它們的所有屬性是否相同,如果相同則認為是同一個值物件;而我們在區分是否是同一個實體時,只看實體的唯一標識是否相同,而不管實體的屬性是否相同;值物件另外一個明顯的特徵是不可變,即所有屬性都是隻讀的。因為屬性是隻讀的,所以可以被安全的共享;當共享值物件時,一般有複製和共享兩種做法,具體採用哪種做法還要根據實際情況而定;另外,我們應該給值物件設計的儘量簡單,不要讓它引用很多其他的物件,因為他只是一個值,就像int a = 3;那麼”3”就是一個我們傳統意義上所說的值,而值物件其實也可以和這裡的”3”一樣理解,也是一個值,只不過是用物件來表示。所以,當我們在C#語言中比較兩個值物件是否相等時,會重寫GetHashCode和Equals這兩個方法,目的就是為了比較物件的值;值物件雖然是隻讀的,但是可以被整個替換掉。就像你把a的值修改為”4”(a = 4;)一樣,直接把”3”這個值替換為”4”了。值物件也是一樣,當你要修改Customer的Address物件引用時,不是通過Customer.Address.Street這樣的方式來實現,因為值物件是隻讀的,它是一個完整的不可分割的整體。我們可以這樣做:Customer.Address = new Address(…);
領域服務(Domain Service)
領域中的一些概念不太適合建模為物件,即歸類到實體物件或值物件,因為它們本質上就是一些操作,一些動作,而不是事物。這些操作或動作往往會涉及到多個領域物件,並且需要協調這些領域物件共同完成這個操作或動作。如果強行將這些操作職責分配給任何一個物件,則被分配的物件就是承擔一些不該承擔的職責,從而會導致物件的職責不明確很混亂。但是基於類的面嚮物件語言規定任何屬性或行為都必須放在物件裡面。所以我們需要尋找一種新的模式來表示這種跨多個物件的操作,DDD認為服務是一個很自然的正規化用來對應這種跨多個物件的操作,所以就有了領域服務這個模式。和領域物件不同,領域服務是以動詞開頭來命名的,比如資金轉帳服務可以命名為MoneyTransferService。當然,你也可以把服務理解為一個物件,但這和一般意義上的物件有些區別。因為一般的領域物件都是有狀態和行為的,而領域服務沒有狀態只有行為。需要強調的是領域服務是無狀態的,它存在的意義就是協調領域物件共完成某個操作,所有的狀態還是都儲存在相應的領域物件中。我覺得模型(實體)與服務(場景)是對領域的一種劃分,模型關注領域的個體行為,場景關注領域的群體行為,模型關注領域的靜態結構,場景關注領域的動態功能。這也符合了現實中出現的各種現象,有動有靜,有獨立有協作。
領域服務還有一個很重要的功能就是可以避免領域邏輯洩露到應用層。因為如果沒有領域服務,那麼應用層會直接呼叫領域物件完成本該是屬於領域服務該做的操作,這樣一來,領域層可能會把一部分領域知識洩露到應用層。因為應用層需要了解每個領域物件的業務功能,具有哪些資訊,以及它可能會與哪些其他領域物件互動,怎麼互動等一系列領域知識。因此,引入領域服務可以有效的防治領域層的邏輯洩露到應用層。對於應用層來說,從可理解的角度來講,通過呼叫領域服務提供的簡單易懂但意義明確的介面肯定也要比直接操縱領域物件容易的多。這裡似乎也看到了領域服務具有Façade的功能,呵呵。
說到領域服務,還需要提一下軟體中一般有三種服務:應用層服務、領域服務、基礎服務。
應用層服務
- 獲取輸入(如一個XML請求);
- 傳送訊息給領域層服務,要求其實現轉帳的業務邏輯;
- 領域層服務處理成功,則呼叫基礎層服務傳送Email通知;
領域層服務
- 獲取源帳號和目標帳號,分別通知源帳號和目標帳號進行扣除金額和增加金額的操作;
- 提供返回結果給應用層;
基礎層服務
按照應用層的請求,傳送Email通知;
所以,從上面的例子中可以清晰的看出,每種服務的職責;
聚合及聚合根(Aggregate,Aggregate Root)
聚合,它通過定義物件之間清晰的所屬關係和邊界來實現領域模型的內聚,並避免了錯綜複雜的難以維護的物件關係網的形成。聚合定義了一組具有內聚關係的相關物件的集合,我們把聚合看作是一個修改資料的單元。
聚合有以下一些特點:
- 每個聚合有一個根和一個邊界,邊界定義了一個聚合內部有哪些實體或值物件,根是聚合內的某個實體;
- 聚合內部的物件之間可以相互引用,但是聚合外部如果要訪問聚合內部的物件時,必須通過聚合根開始導航,絕對不能繞過聚合根直接訪問聚合內的物件,也就是說聚合根是外部可以保持 對它的引用的唯一元素;
- 聚合內除根以外的其他實體的唯一標識都是本地標識,也就是隻要在聚合內部保持唯一即可,因為它們總是從屬於這個聚合的;
- 聚合根負責與外部其他物件打交道並維護自己內部的業務規則;
- 基於聚合的以上概念,我們可以推論出從資料庫查詢時的單元也是以聚合為一個單元,也就是說我們不能直接查詢聚合內部的某個非根的物件;
- 聚合內部的物件可以保持對其他聚合根的引用;
- 刪除一個聚合根時必須同時刪除該聚合內的所有相關物件,因為他們都同屬於一個聚合,是一個完整的概念;
關於如何識別聚合以及聚合根的問題:
我覺得我們可以先從業務的角度深入思考,然後慢慢分析出有哪些物件是:
- 有獨立存在的意義,即它是不依賴於其他物件的存在它才有意義的;
- 可以被獨立訪問的,還是必須通過某個其他物件導航得到的;
如何識別聚合?
我覺得這個需要從業務的角度深入分析哪些物件它們的關係是內聚的,即我們會把他們看成是一個整體來考慮的;然後這些物件我們就可以把它們放在一個聚合內。所謂關係是內聚的,是指這些物件之間必須保持一個固定規則,固定規則是指在資料變化時必須保持不變的一致性規則。當我們在修改一個聚合時,我們必須在事務級別確保整個聚合內的所有物件滿足這個固定規則。作為一條建議,聚合儘量不要太大,否則即便能夠做到在事務級別保持聚合的業務規則完整性,也可能會帶來一定的效能問題。有分析報告顯示,通常在大部分領域模型中,有70%的聚合通常只有一個實體,即聚合根,該實體內部沒有包含其他實體,只包含一些值物件;另外30%的聚合中,基本上也只包含兩到三個實體。這意味著大部分的聚合都只是一個實體,該實體同時也是聚合根。
如何識別聚合根?
如果一個聚合只有一個實體,那麼這個實體就是聚合根;如果有多個實體,那麼我們可以思考聚合內哪個物件有獨立存在的意義並且可以和外部直接進行互動。
工廠(Factory)
DDD中的工廠也是一種體現封裝思想的模式。DDD中引入工廠模式的原因是:有時建立一個領域物件是一件比較複雜的事情,不僅僅是簡單的new操作。正如物件封裝了內部實現一樣(我們無需知道物件的內部實現就可以使用物件的行為),工廠則是用來封裝建立一個複雜物件尤其是聚合時所需的知識,工廠的作用是將建立物件的細節隱藏起來。客戶傳遞給工廠一些簡單的引數,然後工廠可以在內部創建出一個複雜的領域物件然後返回給客戶。領域模型中其他元素都不適合做這個事情,所以需要引入這個新的模式,工廠。工廠在建立一個複雜的領域物件時,通常會知道該滿足什麼業務規則(它知道先怎樣例項化一個物件,然後在對這個物件做哪些初始化操作,這些知識就是建立物件的細節),如果傳遞進來的引數符合建立物件的業務規則,則可以順利建立相應的物件;但是如果由於引數無效等原因不能創建出期望的物件時,應該丟擲一個異常,以確保不會創建出一個錯誤的物件。當然我們也並不總是需要通過工廠來建立物件,事實上大部分情況下領域物件的建立都不會太複雜,所以我們只需要簡單的使用建構函式建立物件就可以了。隱藏建立物件的好處是顯而易見的,這樣可以不會讓領域層的業務邏輯洩露到應用層,同時也減輕了應用層的負擔,它只需要簡單的呼叫領域工廠創建出期望的物件即可。
倉儲(Repository)
- 倉儲被設計出來的目的是基於這個原因:領域模型中的物件自從被創建出來後不會一直留在記憶體中活動的,當它不活動時會被持久化到資料庫中,然後當需要的時候我們會重建該物件;重建物件就是根據資料庫中已儲存的物件的狀態重新建立物件的過程;所以,可見重建物件是一個和資料庫打交道的過程。從更廣義的角度來理解,我們經常會像集合一樣從某個類似集合的地方根據某個條件獲取一個或一些物件,往集合中新增物件或移除物件。也就是說,我們需要提供一種機制,可以提供類似集合的介面來幫助我們管理物件。倉儲就是基於這樣的思想被設計出來的;
- 倉儲裡面存放的物件一定是聚合,原因是之前提到的領域模型中是以聚合的概念去劃分邊界的;聚合是我們更新物件的一個邊界,事實上我們把整個聚合看成是一個整體概念,要麼一起被取出來,要麼一起被刪除。我們永遠不會單獨對某個聚合內的子物件進行單獨查詢或做更新操作。因此,我們只對聚合設計倉儲。
- 倉儲還有一個重要的特徵就是分為倉儲定義部分和倉儲實現部分,在領域模型中我們定義倉儲的介面,而在基礎設施層實現具體的倉儲。這樣做的原因是:由於倉儲背後的實現都是在和資料庫打交道,但是我們又不希望客戶(如應用層)把重點放在如何從資料庫獲取資料的問題上,因為這樣做會導致客戶(應用層)程式碼很混亂,很可能會因此而忽略了領域模型的存在。所以我們需要提供一個簡單明瞭的介面,供客戶使用,確保客戶能以最簡單的方式獲取領域物件,從而可以讓它專心的不會被什麼資料訪問程式碼打擾的情況下協調領域物件完成業務邏輯。這種通過介面來隔離封裝變化的做法其實很常見。由於客戶面對的是抽象的介面並不是具體的實現,所以我們可以隨時替換倉儲的真實實現,這很有助於我們做單元測試。
- 儘管倉儲可以像集合一樣在記憶體中管理物件,但是倉儲一般不負責事務處理。一般事務處理會交給一個叫“工作單元(Unit Of Work)”的東西。關於工作單元的詳細資訊我在下面的討論中會講到。
- 另外,倉儲在設計查詢介面時,可能還會用到規格模式(Specification Pattern),我見過的最厲害的規格模式應該就是LINQ以及DLINQ查詢了。一般我們會根據專案中查詢的靈活度要求來選擇適合的倉儲查詢介面設計。通常情況下只需要定義簡單明瞭的具有固定查詢引數的查詢介面就可以了。只有是在查詢條件是動態指定的情況下才可能需要用到Specification等模式。
設計領域模型的一般步驟
- 根據需求建立一個初步的領域模型,識別出一些明顯的領域概念以及它們的關聯,關聯可以暫時沒有方向但需要有(1:1,1:N,M:N)這些關係;可以用文字精確的沒有歧義的描述出每個領域概念的涵義以及包含的主要資訊;
- 分析主要的軟體應用程式功能,識別出主要的應用層的類;這樣有助於及早發現哪些是應用層的職責,哪些是領域層的職責;
- 進一步分析領域模型,識別出哪些是實體,哪些是值物件,哪些是領域服務;
- 分析關聯,通過對業務的更深入分析以及各種軟體設計原則及效能方面的權衡,明確關聯的方向或者去掉一些不需要的關聯;
- 找出聚合邊界及聚合根,這是一件很有難度的事情;因為你在分析的過程中往往會碰到很多模稜兩可的難以清晰判斷的選擇問題,所以,需要我們平時一些分析經驗的積累才能找出正確的聚合根;
- 為聚合根配備倉儲,一般情況下是為一個聚合分配一個倉儲,此時只要設計好倉儲的介面即可;
- 走查場景,確定我們設計的領域模型能夠有效地解決業務需求;
- 考慮如何建立領域實體或值物件,是通過工廠還是直接通過建構函式;
- 停下來重構模型。尋找模型中覺得有些疑問或者是蹩腳的地方,比如思考一些物件應該通過關聯導航得到還是應該從倉儲獲取?聚合設計的是否正確?考慮模型的效能怎樣,等等;
領域建模是一個不斷重構,持續完善模型的過程,大家會在討論中將變化的部分反映到模型中,從而是模型不斷細化並朝正確的方向走。領域建模是領域專家、設計人員、開發人員之間溝通交流的過程,是大家工作和思考問題的基礎。
在分層架構中其他層如何與領域層互動
從經典的領域驅動設計分層架構中可以看出,領域層的上層是應用層,下層是基礎設施層。那麼領域層是如何與其它層互動的呢?
對於會影響領域層中領域物件狀態的應用層功能
一般應用層會先啟動一個工作單元,然後:
- 對於修改領域物件的情況,通過倉儲獲取領域物件,呼叫領域物件的相關業務方法以完成業務邏輯處理;
- 對於新增領域物件的情況,通過建構函式或工廠創建出領域物件,如果需要還可以繼續對該新建立的領域物件做一些操作,然後把該新建立的領域物件新增到倉儲中;
- 對於刪除領域物件的情況,可以先把領域物件從倉儲中取出來,然後將其從倉儲中刪除,也可以直接傳遞一個要刪除的領域物件的唯一標識給倉儲通知其移除該唯一標識對應領域物件;
- 如果一個業務邏輯涉及到多個領域物件,則呼叫領域層中的相關領域服務完成操作;
注意,以上所說的所有領域物件都是隻聚合根,另外在應用層需要獲取倉儲介面以及領域服務介面時,都可以通過IOC容器獲取。最後通知工作單元提交事務從而將所有相關的領域物件的狀態以事務的方式持久化到資料庫;
關於Unit of Work(工作單元)的幾種實現方法
- 基於快照的實現,即領域物件被取出來後,會先儲存一個備份的物件,然後當在做持久化操作時,將最新的物件的狀態和備份的物件的狀態進行比較,如果不相同,則認為有做過修改,然後進行持久化;這種設計的好處是物件不用告訴工作單元自己的狀態修改了,而缺點也是顯而易見的,那就是效能可能會低,備份物件以及比較物件的狀態是否有修改的過程在當物件本身很複雜的時候,往往是一個比較耗時的步驟,而且要真正實現物件的深拷貝以及判斷屬性是否修改還是比較困難的;
- 不基於快照,而是倉儲的相關更新或新增或刪除介面被呼叫時,倉儲通知工作單元某個物件被新增了或更新了或刪除了。這樣工作單元在做資料持久化時也同樣可以知道需要持久化哪些物件了;這種方法理論上不需要ORM框架的支援,對領域模型也沒有任何傾入性,同時也很好的支援了工作單元的模式。對於不想用高階ORM框架的朋友來說,這種方法挺好;
- 不基於快照,也不用倉儲告訴工作單元資料更改了。而是採用AOP的思想,採用透明代理的方式進行一個攔截。在NHibernate中,我們的屬性通常要被宣告為virtual的,一個原因就是NHibernate會生成一個透明代理,用於攔截物件的屬性被修改時,自動通知工作單元物件的狀態被更新了。這樣工作單元也同樣知道需要持久化哪些物件了。這種方法對領域模型的傾入性不大,並且能很好的支援工作單元模式,如果用NHibernate作為ORM,這種方法用的比較多;
- 一般是微軟用的方法,那就是讓領域物件實現.NET框架中的INotifiyPropertyChanged介面,然後在每個屬性的set方法的最後一行呼叫OnPropertyChanged的方法從而顯示地通知別人自己的狀態修改了。這種方法相對來說對領域模型的傾入性最強。
對於不會影響領域層中領域物件狀態的查詢功能
可以直接通過倉儲查詢出所需要的資料。但一般領域層中的倉儲提供的查詢功能也許不能滿足介面顯示的需要,則可能需要多次呼叫不同的倉儲才能獲取所需要顯示的資料;其實針對這種查詢的情況,我在後面會講到可以直接通過CQRS的架構來實現。即對於查詢,我們可以在應用層不呼叫領域層的任何東西,而是直接通過某個其他的用另外的技術架構實現的查詢引擎來完成查詢,比如直接通過構造引數化SQL的方式從資料庫一個表或多個表中查詢出任何想要顯示的資料。這樣不僅效能高,也可以減輕領域層的負擔。領域模型不太適合為應用層提供各種查詢服務,因為往往介面上要顯示的資料是很多物件的組合資訊,是一種非物件概念的資訊,就像報表;
為什麼面向物件比面向過程更能適應業務變化
物件將需求用類一個個隔開,就像用儲物箱把東西一個個封裝起來一樣,需求變了,分幾種情況,最嚴重的是大變,那麼每個儲物箱都要開啟改,這種方法就不見得有好處;但是這種情況發生概率比較小,大部分需求變化都是侷限在一兩個儲物箱中,那麼我們只要開啟這兩個儲物箱修改就可以,不會影響其他儲物櫃了。
而面向過程是把所有東西都放在一個大儲物箱中,修改某個部分以後,會引起其他部分不穩定,一個BUG修復,引發新的無數BUG,最後程式設計師陷入焦頭爛額,如日本東京電力公司員工處理核危機一樣,心力交瘁啊。
所以,我們不能粗粒度看需求變,認為需求變了,就是大範圍變,萬事萬物都有邊界,老子說,無慾觀其繳,什麼事物都要觀察其邊界,雖然需求可以用“需求”這個名詞表達,談到需求變了,不都意味著最大邊界範圍的變化,這樣看問題容易走極端。
其實就是就地畫圈圈——邊界。我們小時候寫作文分老三段也是同樣道理,各自職責明確,劃分邊界明確,通過過渡句實現承上啟下——介面。為什麼組織需要分不同部門,同樣是邊界思維。畫圈圈容易,但如何畫才難,所以OO中思維非常重要。
需求變化所引起的變化是有邊界,若果變化的邊界等於整個領域,那麼已經是完全不同的專案了。要掌握邊界,是需要大量的領域知識的。否則,走進銀行連業務職責都分不清的,如何畫圈圈呢?
面向過程是無邊界一詞的(就算有也只是最大的邊界),它沒有要求各自獨立,它可以橫跨邊界進行呼叫,這就是容易引起BUG的原因,引起BUG不一定是技術錯誤,更多的是邏輯錯誤。分別封裝就是畫圈圈了,所有邊界都以介面實現。不用改或者小改介面,都不會牽一髮動全身。若果面向過程中考慮邊界,那麼也就已經上升到OO思維,即使用的不是物件語言,但物件已經隱含其中。說白了,面向物件與面向過程最大區別就是:分解。邊界的分解。從需求到最後實現都貫穿。
面向物件的實質就是邊界劃分,封裝,不但對需求變化能夠量化,縮小影響面;因為邊界劃分也會限制出錯的影響範圍,所以OO對軟體後期BUG等出錯也有好處。
軟體世界永遠都有BUG,BUG是清除不乾淨的,就像人類世界永遠都存在不完美和陰暗面,問題關鍵是:上帝用空間和時間的邊界把人類世界痛苦災難等不完美侷限在一個範圍內;而軟體世界如果你不採取OO等方法進行邊界劃分的話,一旦出錯,追查起來情況會有多糟呢?
軟體世界其實類似人類現實世界,有時出問題了,探究原因一看,原來是兩個看上去毫無聯絡的因素導致的,古人只好經常求神拜佛,我們程式設計師在自己的軟體上線執行時,大概心裡也在求神拜佛別出大紕漏,如果我們的軟體採取OO封裝,我們就會坦然些,肯定會出錯,但是我們已經預先劃定好邊界,所以,不會產生嚴重後果,甚至也不會出現難以追查的魔鬼BUG。
領域驅動設計的其他一些主題
上面只是涉及到DDD中最基本的內容,DDD中還有很多其他重要的內容在上面沒有提到,如:
- 模型上下文、上下文對映、上下文共享;
- 如何將分析模式和設計模式運用到DDD中;
- 一些關於柔性設計的技巧;
- 如果保持模型完整性,以及持續整合方面的知識;
- 如何精煉模型,識別核心模型以及通用子領域;
這些主題都很重要,因為篇幅有限以及我目前掌握的知識也有限,並且為了突出這篇文章的重點,所以不對他們做詳細介紹了,大家有興趣的可以自己閱讀一下。
一些相關的擴充套件閱讀
CQRS架構
核心思想是將應用程式的查詢部分和命令部分完全分離,這兩部分可以用完全不同的模型和技術去實現。比如命令部分可以通過領域驅動設計來實現;查詢部分可以直接用最快的非面向物件的方式去實現,比如用SQL。這樣的思想有很多好處:
- 實現命令部分的領域模型不用經常為了領域物件可能會被如何查詢而做一些折中處理;
- 由於命令和查詢是完全分離的,所以這兩部分可以用不同的技術架構實現,包括資料庫設計都可以分開設計,每一部分可以充分發揮其長處;
- 高效能,命令端因為沒有返回值,可以像訊息佇列一樣接受命令,放在佇列中,慢慢處理;處理完後,可以通過非同步的方式通知查詢端,這樣查詢端可以做資料同步的處理;
Event Sourcing(事件溯源)
對於DDD中的聚合,不儲存聚合的當前狀態,而是儲存物件上所發生的每個事件。當要重建一個聚合物件時,可以通過回溯這些事件(即讓這些事件重新發生)來讓物件恢復到某個特定的狀態;因為有時一個聚合可能會發生很多事件,所以如果每次要在重建物件時都從頭回溯事件,會導致效能低下,所以我們會在一定時候為聚合建立一個快照。這樣,我們就可以基於某個快照開始建立聚合物件了。
DCI架構
DCI架構強調,軟體應該真實的模擬現實生活中物件的互動方式,程式碼應該準確樸實的反映使用者的心智模型。在DCI中有:資料模型、角色模型、以及上下文這三個概念。資料模型表示程式的結構,目前我們所理解的DDD中的領域模型可以很好的表示資料模型;角色模型表示資料如何互動,一個角色定義了某個“身份”所具有的互動行為;上下文對應業務場景,用於實現業務用例,注意是業務用例而不是系統用例,業務用例只與業務相關;軟體執行時,根據使用者的操作,系統建立相應的場景,並把相關的資料物件作為場景參與者傳遞給場景,然後場景知道該為每個物件賦予什麼角色,當物件被賦予某個角色後就真正成為有互動能力的物件,然後與其他物件進行互動;這個過程與現實生活中我們所理解的物件是一致的;
DCI的這種思想與DDD中的領域服務所做的事情是一樣的,但實現的角度有些不同。DDD中的領域服務被建立的出發點是當一些職責不太適合放在任何一個領域物件上時,這個職責往往對應領域中的某個活動或轉換過程,此時我們應該考慮將其放在一個服務中。比如資金轉帳的例子,我們應該提供一個資金轉帳的服務,用來對應領域中的資金轉帳這個領域概念。但是領域服務內部做的事情是協調多個領域物件完成一件事情。因此,在DDD中的領域服務在協調領域物件做事情時,領域物件往往是處於一個被動的地位,領域服務通知每個物件要求其做自己能做的事情,這樣就行了。這個過程中我們似乎看不到物件之間互動的意思,因為整個過程都是由領域服務以面向過程的思維去實現了。而DCI則通用引入角色,賦予角色以互動能力,然後讓角色之間進行互動,從而可以讓我們看到物件與物件之間互動的過程。但前提是,物件之間確實是在互動。因為現實生活中並不是所有的物件在做互動,比如有A、B、C三個物件,A通知B做事情,A通知C做事情,此時可以認為A和B,A和C之間是在互動,但是B和C之間沒有互動。所以我們需要分清這種情況。資金轉帳的例子,A相當於轉帳服務,B相當於帳號1,C相當於帳號2。因此,資金轉帳這個業務場景,用領域服務比較自然。有人認為DCI可以替換DDD中的領域服務,我持懷疑態度。
四色原型分析模式
時刻-時間段原型(Moment-Interval Archetype)
表示在某個時刻或某一段時間內發生的某個活動。使用粉紅色表示,簡寫為MI。
參與方-地點-物品原型(Part-Place-Thing Archetype)
表示參與某個活動的人或物,地點則是活動的發生地。使用綠色表示。簡寫為PPT。
描述原型(Description Archetype)
表示對PPT的本質描述。它不是PPT的分類!Description是從PPT抽象出來的不變的共性的屬性的集合。使用藍色表示,簡寫為DESC。
舉個例子,有一個人叫張三,如果某個外星人問你張三是什麼?你會怎麼說?可能會說,張三是個人,但是外星人不知道“人”是什麼。然後你會怎麼辦?你就會說:張三是個由一個頭、兩隻手、兩隻腳,以及一個身體組成的客觀存在。雖然這時外星人仍然不知道人是什麼,但我已經可以借用這個例子向大家說明什麼是“Description”了。在這個例子中,張三就是一個PPT,而“由一個頭、兩隻手、兩隻腳,以及一個身體組成的客觀存在”就是對張三的Description,頭、手、腳、身體則是人的本質的不變的共性的屬性的集合。但我們人類比較聰明,很會抽象總結和命名,已經把這個Description用一個字來代替了,那就是“人”。所以就有所謂的張三是人的說法。
角色原型(Role Archetype)
角色就是我們平時所理解的“身份”。使用黃色表示,簡寫為Role。為什麼會有角色這個概念?因為有些活動,只允許具有特定角色(身份)的PPT(參與者)才能參與該活動。比如一個人只有具有教師的角色才能上課(一種活動);一個人只有是一個合法公民才能參與選舉和被選舉;但是有些活動也是不需要角色的,比如一個人不需要具備任何角色就可以睡覺(一種活動)。當然,其實說人不需要角色就能睡覺也是錯誤的,錯在哪裡?因為我們可以這樣理解:一個客觀存在只要具有“人”的角色就能睡覺,其實這時候,我們已經把DESC當作角色來看待了。所以,其實角色這個概念是非常廣的,不能用我們平時所理解的狹義的“身份”來理解,因為“教師”、“合法公民”、“人”都可以被作為角色來看待。因此,應該這樣說:任何一個活動,都需要具有一定角色的參與者才能參與。
用一句話來概括四色原型就是:一個什麼什麼樣的人或組織或物品以某種角色在某個時刻或某段時間內參與某個活動。 其中“什麼什麼樣的”就是DESC,“人或組織或物品”就是PPT,“角色”就是Role,而”某個時刻或某段時間內的某個活動"就是MI。
以上這些東西如果在學習了DDD之後再去學習會對DDD有更深入的瞭解,但我覺得DDD相對比較基礎,如果我們在已經瞭解了DDD的基礎之上再去學習這些東西會更加有效和容易掌握。
希望本文對大家有所幫助。
推薦:http://www.cnblogs.com/netfocus/archive/2011/10/10/2204949.html